feat: polish admin policy UI and tool selection
This commit is contained in:
parent
68b2ace43f
commit
99cbea30da
@ -9,6 +9,7 @@ LOGS_DIR = "./logs"
|
||||
USER_SPACE_DIR = "./users"
|
||||
USERS_DB_FILE = f"{DATA_DIR}/users.json"
|
||||
INVITE_CODES_FILE = f"{DATA_DIR}/invite_codes.json"
|
||||
ADMIN_POLICY_FILE = f"{DATA_DIR}/admin_policy.json"
|
||||
|
||||
__all__ = [
|
||||
"DEFAULT_PROJECT_PATH",
|
||||
@ -18,4 +19,5 @@ __all__ = [
|
||||
"USER_SPACE_DIR",
|
||||
"USERS_DB_FILE",
|
||||
"INVITE_CODES_FILE",
|
||||
"ADMIN_POLICY_FILE",
|
||||
]
|
||||
|
||||
@ -137,13 +137,20 @@ class MainTerminal:
|
||||
self.focused_files = {} # {path: content} 存储聚焦的文件内容
|
||||
|
||||
self.current_session_id = 0 # 用于标识不同的任务会话
|
||||
# 工具类别(可被管理员动态覆盖)
|
||||
self.tool_categories_map = dict(TOOL_CATEGORIES)
|
||||
self.admin_forced_category_states: Dict[str, Optional[bool]] = {}
|
||||
self.admin_disabled_models: List[str] = []
|
||||
self.admin_policy_ui_blocks: Dict[str, bool] = {}
|
||||
self.admin_policy_version: Optional[str] = None
|
||||
|
||||
# 工具启用状态
|
||||
self.tool_category_states = {
|
||||
self.tool_category_states: Dict[str, bool] = {
|
||||
key: category.default_enabled
|
||||
for key, category in TOOL_CATEGORIES.items()
|
||||
for key, category in self.tool_categories_map.items()
|
||||
}
|
||||
self.disabled_tools = set()
|
||||
self.disabled_notice_tools = set()
|
||||
self.disabled_tools: Set[str] = set()
|
||||
self.disabled_notice_tools: Set[str] = set()
|
||||
self._refresh_disabled_tools()
|
||||
self.thinking_fast_interval = THINKING_FAST_INTERVAL
|
||||
self.default_disabled_tool_categories: List[str] = []
|
||||
@ -358,12 +365,12 @@ class MainTerminal:
|
||||
if isinstance(raw_disabled, list):
|
||||
disabled_categories = [
|
||||
key for key in raw_disabled
|
||||
if isinstance(key, str) and key in TOOL_CATEGORIES
|
||||
if isinstance(key, str) and key in self.tool_categories_map
|
||||
]
|
||||
self.default_disabled_tool_categories = disabled_categories
|
||||
|
||||
# Reset category states to defaults before applying overrides
|
||||
for key, category in TOOL_CATEGORIES.items():
|
||||
for key, category in self.tool_categories_map.items():
|
||||
self.tool_category_states[key] = False if key in disabled_categories else category.default_enabled
|
||||
self._refresh_disabled_tools()
|
||||
|
||||
@ -539,30 +546,71 @@ class MainTerminal:
|
||||
|
||||
def set_tool_category_enabled(self, category: str, enabled: bool) -> None:
|
||||
"""设置工具类别的启用状态 / Toggle tool category enablement."""
|
||||
if category not in TOOL_CATEGORIES:
|
||||
categories = self.tool_categories_map
|
||||
if category not in categories:
|
||||
raise ValueError(f"未知的工具类别: {category}")
|
||||
forced = self.admin_forced_category_states.get(category)
|
||||
if isinstance(forced, bool) and forced != enabled:
|
||||
raise ValueError("该类别被管理员强制为启用/禁用,无法修改")
|
||||
self.tool_category_states[category] = bool(enabled)
|
||||
self._refresh_disabled_tools()
|
||||
|
||||
def set_admin_policy(
|
||||
self,
|
||||
categories: Optional[Dict[str, "ToolCategory"]] = None,
|
||||
forced_category_states: Optional[Dict[str, Optional[bool]]] = None,
|
||||
disabled_models: Optional[List[str]] = None,
|
||||
) -> None:
|
||||
"""应用管理员策略(工具分类、强制开关、模型禁用)。"""
|
||||
if categories:
|
||||
self.tool_categories_map = dict(categories)
|
||||
# 重新构建启用状态映射,保留已有值
|
||||
new_states: Dict[str, bool] = {}
|
||||
for key, cat in self.tool_categories_map.items():
|
||||
if key in self.tool_category_states:
|
||||
new_states[key] = self.tool_category_states[key]
|
||||
else:
|
||||
new_states[key] = cat.default_enabled
|
||||
self.tool_category_states = new_states
|
||||
# 清理已被移除的类别
|
||||
for removed in list(self.tool_category_states.keys()):
|
||||
if removed not in self.tool_categories_map:
|
||||
self.tool_category_states.pop(removed, None)
|
||||
|
||||
self.admin_forced_category_states = forced_category_states or {}
|
||||
self.admin_disabled_models = disabled_models or []
|
||||
self._refresh_disabled_tools()
|
||||
|
||||
def get_tool_settings_snapshot(self) -> List[Dict[str, object]]:
|
||||
"""获取工具类别状态快照 / Return tool category states snapshot."""
|
||||
snapshot: List[Dict[str, object]] = []
|
||||
for key, category in TOOL_CATEGORIES.items():
|
||||
categories = self.tool_categories_map
|
||||
for key, category in categories.items():
|
||||
forced = self.admin_forced_category_states.get(key)
|
||||
enabled = self.tool_category_states.get(key, category.default_enabled)
|
||||
if isinstance(forced, bool):
|
||||
enabled = forced
|
||||
snapshot.append({
|
||||
"id": key,
|
||||
"label": category.label,
|
||||
"enabled": self.tool_category_states.get(key, category.default_enabled),
|
||||
"enabled": enabled,
|
||||
"tools": list(category.tools),
|
||||
"locked": isinstance(forced, bool),
|
||||
"locked_state": forced if isinstance(forced, bool) else None,
|
||||
})
|
||||
return snapshot
|
||||
|
||||
def _refresh_disabled_tools(self) -> None:
|
||||
"""刷新禁用工具列表 / Refresh disabled tool set."""
|
||||
disabled = set()
|
||||
notice = set()
|
||||
for key, enabled in self.tool_category_states.items():
|
||||
if not enabled:
|
||||
category = TOOL_CATEGORIES[key]
|
||||
disabled: Set[str] = set()
|
||||
notice: Set[str] = set()
|
||||
categories = self.tool_categories_map
|
||||
for key, category in categories.items():
|
||||
state = self.tool_category_states.get(key, category.default_enabled)
|
||||
forced = self.admin_forced_category_states.get(key)
|
||||
if isinstance(forced, bool):
|
||||
state = forced
|
||||
if not state:
|
||||
disabled.update(category.tools)
|
||||
if not getattr(category, "silent_when_disabled", False):
|
||||
notice.update(category.tools)
|
||||
|
||||
228
modules/admin_policy_manager.py
Normal file
228
modules/admin_policy_manager.py
Normal file
@ -0,0 +1,228 @@
|
||||
"""管理员策略配置管理。
|
||||
|
||||
职责:
|
||||
- 持久化管理员在工具分类、模型禁用、前端功能禁用等方面的策略。
|
||||
- 支持作用域:global / role / user / invite_code,按优先级合并得到最终生效策略。
|
||||
- 仅在此文件读写配置,避免侵入现有模块。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import time
|
||||
from pathlib import Path
|
||||
from typing import Dict, Any, Tuple
|
||||
|
||||
from config.paths import ADMIN_POLICY_FILE
|
||||
|
||||
# 可用的模型 key(与前端、model_profiles 保持一致)
|
||||
ALLOWED_MODELS = {"kimi", "deepseek", "qwen3-max", "qwen3-vl-plus"}
|
||||
|
||||
# UI 禁用项键名,前后端统一
|
||||
UI_BLOCK_KEYS = [
|
||||
"collapse_workspace",
|
||||
"block_file_manager",
|
||||
"block_personal_space",
|
||||
"block_upload",
|
||||
"block_conversation_review",
|
||||
"block_tool_toggle",
|
||||
"block_realtime_terminal",
|
||||
"block_focus_panel",
|
||||
"block_token_panel",
|
||||
"block_compress_conversation",
|
||||
"block_virtual_monitor",
|
||||
]
|
||||
|
||||
|
||||
def _ensure_file() -> Path:
|
||||
path = Path(ADMIN_POLICY_FILE).expanduser().resolve()
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
if not path.exists():
|
||||
_save_policy(_default_policy(), path)
|
||||
return path
|
||||
|
||||
|
||||
def _read_policy(path: Path) -> Dict[str, Any]:
|
||||
try:
|
||||
return json.loads(path.read_text(encoding="utf-8"))
|
||||
except Exception:
|
||||
return _default_policy()
|
||||
|
||||
|
||||
def _save_policy(payload: Dict[str, Any], path: Path | None = None) -> None:
|
||||
target = path or _ensure_file()
|
||||
target.write_text(json.dumps(payload, ensure_ascii=False, indent=2), encoding="utf-8")
|
||||
|
||||
|
||||
def _default_policy() -> Dict[str, Any]:
|
||||
return {
|
||||
"updated_at": time.strftime("%Y-%m-%d %H:%M:%S"),
|
||||
"global": _blank_config(),
|
||||
"roles": {},
|
||||
"users": {},
|
||||
"invites": {},
|
||||
}
|
||||
|
||||
|
||||
def _blank_config() -> Dict[str, Any]:
|
||||
return {
|
||||
"category_overrides": {}, # id -> {label, tools, default_enabled?}
|
||||
"remove_categories": [], # ids
|
||||
"forced_category_states": {}, # id -> true/false
|
||||
"disabled_models": [], # list of model keys
|
||||
"ui_blocks": {}, # key -> bool
|
||||
}
|
||||
|
||||
|
||||
def _merge_config(base: Dict[str, Any], override: Dict[str, Any]) -> Dict[str, Any]:
|
||||
"""浅合并策略,数组采取并集/覆盖逻辑。"""
|
||||
merged = _blank_config()
|
||||
base = base or {}
|
||||
override = override or {}
|
||||
|
||||
merged["category_overrides"] = {
|
||||
**(base.get("category_overrides") or {}),
|
||||
**(override.get("category_overrides") or {}),
|
||||
}
|
||||
merged["remove_categories"] = list({
|
||||
*[c for c in base.get("remove_categories") or [] if isinstance(c, str)],
|
||||
*[c for c in override.get("remove_categories") or [] if isinstance(c, str)],
|
||||
})
|
||||
merged["forced_category_states"] = {
|
||||
**(base.get("forced_category_states") or {}),
|
||||
**(override.get("forced_category_states") or {}),
|
||||
}
|
||||
merged["disabled_models"] = list({
|
||||
*[m for m in base.get("disabled_models") or [] if m in ALLOWED_MODELS],
|
||||
*[m for m in override.get("disabled_models") or [] if m in ALLOWED_MODELS],
|
||||
})
|
||||
ui_base = base.get("ui_blocks") or {}
|
||||
ui_override = override.get("ui_blocks") or {}
|
||||
merged["ui_blocks"] = {**ui_base, **ui_override}
|
||||
return merged
|
||||
|
||||
|
||||
def load_policy() -> Dict[str, Any]:
|
||||
path = _ensure_file()
|
||||
payload = _read_policy(path)
|
||||
# 补全缺失字段
|
||||
if not isinstance(payload, dict):
|
||||
payload = _default_policy()
|
||||
payload.setdefault("updated_at", time.strftime("%Y-%m-%d %H:%M:%S"))
|
||||
payload.setdefault("global", _blank_config())
|
||||
payload.setdefault("roles", {})
|
||||
payload.setdefault("users", {})
|
||||
payload.setdefault("invites", {})
|
||||
return payload
|
||||
|
||||
|
||||
def save_scope_policy(target_type: str, target_value: str, config: Dict[str, Any]) -> Dict[str, Any]:
|
||||
"""更新指定作用域的策略并保存,返回最新策略。"""
|
||||
if target_type not in {"global", "role", "user", "invite"}:
|
||||
raise ValueError("invalid target_type")
|
||||
|
||||
policy = load_policy()
|
||||
normalized = _blank_config()
|
||||
normalized = _merge_config(normalized, config or {})
|
||||
|
||||
if target_type == "global":
|
||||
policy["global"] = normalized
|
||||
elif target_type == "role":
|
||||
policy.setdefault("roles", {})[target_value] = normalized
|
||||
elif target_type == "user":
|
||||
policy.setdefault("users", {})[target_value] = normalized
|
||||
else:
|
||||
policy.setdefault("invites", {})[target_value] = normalized
|
||||
|
||||
policy["updated_at"] = time.strftime("%Y-%m-%d %H:%M:%S")
|
||||
_save_policy(policy)
|
||||
return policy
|
||||
|
||||
|
||||
def _collect_categories_with_overrides(overrides: Dict[str, Any]) -> Dict[str, Dict[str, Any]]:
|
||||
"""从 override 字典生成 {id: {label, tools, default_enabled}}"""
|
||||
from core.tool_config import TOOL_CATEGORIES # 延迟导入避免循环
|
||||
|
||||
base: Dict[str, Dict[str, Any]] = {
|
||||
key: {
|
||||
"label": cat.label,
|
||||
"tools": list(cat.tools),
|
||||
"default_enabled": bool(cat.default_enabled),
|
||||
"silent_when_disabled": getattr(cat, "silent_when_disabled", False),
|
||||
}
|
||||
for key, cat in TOOL_CATEGORIES.items()
|
||||
}
|
||||
|
||||
remove_ids = set(overrides.get("remove_categories") or [])
|
||||
for rid in remove_ids:
|
||||
base.pop(rid, None)
|
||||
|
||||
for cid, payload in (overrides.get("category_overrides") or {}).items():
|
||||
if not isinstance(cid, str):
|
||||
continue
|
||||
if not isinstance(payload, dict):
|
||||
continue
|
||||
label = payload.get("label") or cid
|
||||
tools = payload.get("tools") or []
|
||||
if not isinstance(tools, list):
|
||||
continue
|
||||
default_enabled = bool(payload.get("default_enabled", True))
|
||||
base[cid] = {
|
||||
"label": str(label),
|
||||
"tools": [t for t in tools if isinstance(t, str)],
|
||||
"default_enabled": default_enabled,
|
||||
"silent_when_disabled": bool(payload.get("silent_when_disabled", False)),
|
||||
}
|
||||
return base
|
||||
|
||||
|
||||
def get_effective_policy(username: str | None, role: str | None, invite_code: str | None) -> Dict[str, Any]:
|
||||
"""按优先级合并策略,返回生效配置。"""
|
||||
policy = load_policy()
|
||||
scopes: Tuple[Tuple[str, Dict[str, Any]], ...] = (
|
||||
("global", policy.get("global") or _blank_config()),
|
||||
("role", (policy.get("roles") or {}).get(role or "", {})),
|
||||
("invite", (policy.get("invites") or {}).get(invite_code or "", {})),
|
||||
("user", (policy.get("users") or {}).get(username or "", {})),
|
||||
)
|
||||
|
||||
merged = _blank_config()
|
||||
for _, cfg in scopes:
|
||||
merged = _merge_config(merged, cfg or {})
|
||||
|
||||
# 计算最终分类
|
||||
categories = _collect_categories_with_overrides(merged)
|
||||
forced_states = {
|
||||
key: bool(val) if isinstance(val, bool) else None
|
||||
for key, val in (merged.get("forced_category_states") or {}).items()
|
||||
if key
|
||||
}
|
||||
disabled_models = [m for m in merged.get("disabled_models") or [] if m in ALLOWED_MODELS]
|
||||
ui_blocks = {k: bool(v) for k, v in (merged.get("ui_blocks") or {}).items() if k in UI_BLOCK_KEYS}
|
||||
|
||||
return {
|
||||
"categories": categories,
|
||||
"forced_category_states": forced_states,
|
||||
"disabled_models": disabled_models,
|
||||
"ui_blocks": ui_blocks,
|
||||
"updated_at": policy.get("updated_at"),
|
||||
}
|
||||
|
||||
|
||||
def describe_defaults() -> Dict[str, Any]:
|
||||
"""返回默认(未覆盖)工具分类,用于前端渲染。"""
|
||||
from core.tool_config import TOOL_CATEGORIES
|
||||
|
||||
return {
|
||||
"categories": {
|
||||
key: {
|
||||
"label": cat.label,
|
||||
"tools": list(cat.tools),
|
||||
"default_enabled": bool(cat.default_enabled),
|
||||
"silent_when_disabled": getattr(cat, "silent_when_disabled", False),
|
||||
}
|
||||
for key, cat in TOOL_CATEGORIES.items()
|
||||
},
|
||||
"models": sorted(list(ALLOWED_MODELS)),
|
||||
"ui_block_keys": UI_BLOCK_KEYS,
|
||||
}
|
||||
14
static/admin_policy/index.html
Normal file
14
static/admin_policy/index.html
Normal file
@ -0,0 +1,14 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>管理员策略配置</title>
|
||||
<link rel="stylesheet" href="/static/dist/assets/adminPolicy.css" />
|
||||
<script src="/static/security.js"></script>
|
||||
</head>
|
||||
<body>
|
||||
<div id="admin-policy-app"></div>
|
||||
<script type="module" src="/static/dist/assets/adminPolicy.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
@ -70,6 +70,7 @@
|
||||
:is-connected="isConnected"
|
||||
:panel-menu-open="panelMenuOpen"
|
||||
:panel-mode="panelMode"
|
||||
:file-manager-disabled="policyUiBlocks.block_file_manager"
|
||||
@toggle-panel-menu="togglePanelMenu"
|
||||
@select-panel="selectPanelMode"
|
||||
@open-file-manager="openGuiFileManager"
|
||||
@ -187,6 +188,13 @@
|
||||
:icon-style="iconStyle"
|
||||
:tool-category-icon="toolCategoryIcon"
|
||||
:selected-images="selectedImages"
|
||||
:block-upload="policyUiBlocks.block_upload"
|
||||
:block-tool-toggle="policyUiBlocks.block_tool_toggle"
|
||||
:block-realtime-terminal="policyUiBlocks.block_realtime_terminal"
|
||||
:block-focus-panel="policyUiBlocks.block_focus_panel"
|
||||
:block-token-panel="policyUiBlocks.block_token_panel"
|
||||
:block-compress-conversation="policyUiBlocks.block_compress_conversation"
|
||||
:block-conversation-review="policyUiBlocks.block_conversation_review"
|
||||
@update:input-message="inputSetMessage"
|
||||
@input-change="handleInputChange"
|
||||
@input-focus="handleInputFocus"
|
||||
|
||||
@ -16,6 +16,7 @@
|
||||
<button type="button" :disabled="refreshing" @click="handleManualRefresh">
|
||||
{{ refreshing ? '刷新中...' : '立即刷新' }}
|
||||
</button>
|
||||
<a class="link-btn" href="/admin/policy" target="_blank" rel="noopener">策略配置</a>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
@ -557,6 +558,20 @@ const inviteStatus = (remaining: number | null | undefined) => {
|
||||
color: #5b4b35;
|
||||
}
|
||||
|
||||
.link-btn {
|
||||
padding: 10px 14px;
|
||||
border-radius: 12px;
|
||||
border: 1px solid rgba(0, 0, 0, 0.12);
|
||||
background: #fff;
|
||||
color: #2a2013;
|
||||
text-decoration: none;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.link-btn:hover {
|
||||
background: rgba(0, 0, 0, 0.04);
|
||||
}
|
||||
|
||||
button {
|
||||
border: none;
|
||||
border-radius: 999px;
|
||||
|
||||
1074
static/src/admin/PolicyApp.vue
Normal file
1074
static/src/admin/PolicyApp.vue
Normal file
File diff suppressed because it is too large
Load Diff
4
static/src/admin/policyMain.ts
Normal file
4
static/src/admin/policyMain.ts
Normal file
@ -0,0 +1,4 @@
|
||||
import { createApp } from 'vue';
|
||||
import PolicyApp from './PolicyApp.vue';
|
||||
|
||||
createApp(PolicyApp).mount('#admin-policy-app');
|
||||
@ -28,6 +28,7 @@ import { usePersonalizationStore } from './stores/personalization';
|
||||
import { useModelStore } from './stores/model';
|
||||
import { useChatActionStore } from './stores/chatActions';
|
||||
import { useMonitorStore } from './stores/monitor';
|
||||
import { usePolicyStore } from './stores/policy';
|
||||
import { ICONS, TOOL_CATEGORY_ICON_MAP } from './utils/icons';
|
||||
import { initializeLegacySocket } from './composables/useLegacySocket';
|
||||
import { useConnectionStore } from './stores/connection';
|
||||
@ -435,8 +436,21 @@ const appOptions = {
|
||||
}
|
||||
return this.thinkingMode ? 'thinking' : 'fast';
|
||||
},
|
||||
policyUiBlocks() {
|
||||
const store = usePolicyStore();
|
||||
return store.uiBlocks || {};
|
||||
},
|
||||
adminDisabledModels() {
|
||||
const store = usePolicyStore();
|
||||
return store.disabledModelSet;
|
||||
},
|
||||
modelOptions() {
|
||||
return this.models || [];
|
||||
const disabledSet = this.adminDisabledModels || new Set();
|
||||
const options = this.models || [];
|
||||
return options.map((opt) => ({
|
||||
...opt,
|
||||
disabled: disabledSet.has(opt.key)
|
||||
})).filter((opt) => true);
|
||||
},
|
||||
titleRibbonVisible() {
|
||||
return !this.isMobileViewport && this.chatDisplayMode === 'chat';
|
||||
@ -466,7 +480,7 @@ const appOptions = {
|
||||
})
|
||||
,
|
||||
displayModeSwitchDisabled() {
|
||||
return false;
|
||||
return !!this.policyUiBlocks.block_virtual_monitor;
|
||||
},
|
||||
displayLockEngaged() {
|
||||
return false;
|
||||
@ -652,6 +666,28 @@ const appOptions = {
|
||||
}
|
||||
this.uiCloseMobileOverlay();
|
||||
},
|
||||
applyPolicyUiLocks() {
|
||||
const policyStore = usePolicyStore();
|
||||
const blocks = policyStore.uiBlocks;
|
||||
if (blocks.collapse_workspace) {
|
||||
this.uiSetWorkspaceCollapsed(true);
|
||||
}
|
||||
if (blocks.block_virtual_monitor && this.chatDisplayMode === 'monitor') {
|
||||
this.uiSetChatDisplayMode('chat');
|
||||
}
|
||||
},
|
||||
isPolicyBlocked(key: string, message?: string) {
|
||||
const policyStore = usePolicyStore();
|
||||
if (policyStore.uiBlocks[key]) {
|
||||
this.uiPushToast({
|
||||
title: '已被管理员禁用',
|
||||
message: message || '被管理员强制禁用',
|
||||
type: 'warning'
|
||||
});
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
},
|
||||
handleClickOutsideMobileMenu(event) {
|
||||
if (!this.isMobileViewport || !this.mobileOverlayMenuOpen) {
|
||||
return;
|
||||
@ -666,6 +702,10 @@ const appOptions = {
|
||||
if (this.isMobileViewport) {
|
||||
return;
|
||||
}
|
||||
if (this.isPolicyBlocked('collapse_workspace', '工作区已被管理员强制折叠')) {
|
||||
this.uiSetWorkspaceCollapsed(true);
|
||||
return;
|
||||
}
|
||||
const nextState = !this.workspaceCollapsed;
|
||||
this.uiSetWorkspaceCollapsed(nextState);
|
||||
if (nextState) {
|
||||
@ -674,6 +714,11 @@ const appOptions = {
|
||||
},
|
||||
handleDisplayModeToggle() {
|
||||
if (this.displayModeSwitchDisabled) {
|
||||
// 显式提示管理员禁用
|
||||
this.isPolicyBlocked('block_virtual_monitor', '虚拟显示器已被管理员禁用');
|
||||
return;
|
||||
}
|
||||
if (this.chatDisplayMode === 'chat' && this.isPolicyBlocked('block_virtual_monitor', '虚拟显示器已被管理员禁用')) {
|
||||
return;
|
||||
}
|
||||
const next = this.chatDisplayMode === 'chat' ? 'monitor' : 'chat';
|
||||
@ -912,6 +957,9 @@ const appOptions = {
|
||||
},
|
||||
|
||||
openGuiFileManager() {
|
||||
if (this.isPolicyBlocked('block_file_manager', '文件管理器已被管理员禁用')) {
|
||||
return;
|
||||
}
|
||||
window.open('/file-manager', '_blank');
|
||||
},
|
||||
|
||||
@ -1393,6 +1441,10 @@ const appOptions = {
|
||||
this.applyStatusSnapshot(statusData);
|
||||
// 立即更新配额和运行模式,避免等待其他慢接口
|
||||
this.fetchUsageQuota();
|
||||
// 拉取管理员策略
|
||||
const policyStore = usePolicyStore();
|
||||
await policyStore.fetchPolicy();
|
||||
this.applyPolicyUiLocks();
|
||||
|
||||
// 获取当前对话信息
|
||||
const statusConversationId = statusData.conversation && statusData.conversation.current_id;
|
||||
@ -2270,6 +2322,9 @@ const appOptions = {
|
||||
},
|
||||
|
||||
openPersonalPage() {
|
||||
if (this.isPolicyBlocked('block_personal_space', '个人空间已被管理员禁用')) {
|
||||
return;
|
||||
}
|
||||
this.personalizationOpenDrawer();
|
||||
},
|
||||
|
||||
@ -2303,6 +2358,15 @@ const appOptions = {
|
||||
},
|
||||
|
||||
handleFileSelected(files) {
|
||||
const policyStore = usePolicyStore();
|
||||
if (policyStore.uiBlocks?.block_upload) {
|
||||
this.uiPushToast({
|
||||
title: '上传被禁用',
|
||||
message: '已被管理员禁用上传功能',
|
||||
type: 'warning'
|
||||
});
|
||||
return;
|
||||
}
|
||||
this.uploadHandleSelected(files);
|
||||
},
|
||||
|
||||
@ -2528,6 +2592,9 @@ const appOptions = {
|
||||
if (!this.isConnected) {
|
||||
return;
|
||||
}
|
||||
if (this.isPolicyBlocked('block_tool_toggle', '工具启用/禁用已被管理员锁定')) {
|
||||
return;
|
||||
}
|
||||
this.modeMenuOpen = false;
|
||||
this.modelMenuOpen = false;
|
||||
const nextState = this.inputToggleToolMenu();
|
||||
@ -2666,6 +2733,9 @@ const appOptions = {
|
||||
if (this.uploading || !this.isConnected) {
|
||||
return;
|
||||
}
|
||||
if (this.isPolicyBlocked('block_upload', '上传功能已被管理员禁用')) {
|
||||
return;
|
||||
}
|
||||
this.triggerFileUpload();
|
||||
},
|
||||
|
||||
@ -2714,6 +2784,15 @@ const appOptions = {
|
||||
if (!this.isConnected || this.streamingMessage) {
|
||||
return;
|
||||
}
|
||||
const policyStore = usePolicyStore();
|
||||
if (policyStore.isModelDisabled(key)) {
|
||||
this.uiPushToast({
|
||||
title: '模型被禁用',
|
||||
message: '被管理员强制禁用',
|
||||
type: 'warning'
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (this.conversationHasImages && key !== 'qwen3-vl-plus') {
|
||||
this.uiPushToast({
|
||||
title: '切换失败',
|
||||
@ -2869,6 +2948,9 @@ const appOptions = {
|
||||
if (!this.isConnected) {
|
||||
return;
|
||||
}
|
||||
if (this.isPolicyBlocked('block_realtime_terminal', '实时终端已被管理员禁用')) {
|
||||
return;
|
||||
}
|
||||
this.openRealtimeTerminal();
|
||||
},
|
||||
|
||||
@ -2876,6 +2958,9 @@ const appOptions = {
|
||||
if (!this.isConnected) {
|
||||
return;
|
||||
}
|
||||
if (this.isPolicyBlocked('block_focus_panel', '聚焦面板已被管理员禁用')) {
|
||||
return;
|
||||
}
|
||||
this.toggleFocusPanel();
|
||||
},
|
||||
|
||||
@ -2883,6 +2968,9 @@ const appOptions = {
|
||||
if (!this.currentConversationId) {
|
||||
return;
|
||||
}
|
||||
if (this.isPolicyBlocked('block_token_panel', '用量统计已被管理员禁用')) {
|
||||
return;
|
||||
}
|
||||
this.toggleTokenPanel();
|
||||
},
|
||||
|
||||
@ -2890,10 +2978,16 @@ const appOptions = {
|
||||
if (this.compressing || this.streamingMessage || !this.isConnected) {
|
||||
return;
|
||||
}
|
||||
if (this.isPolicyBlocked('block_compress_conversation', '压缩对话已被管理员禁用')) {
|
||||
return;
|
||||
}
|
||||
this.compressConversation();
|
||||
},
|
||||
|
||||
openReviewDialog() {
|
||||
if (this.isPolicyBlocked('block_conversation_review', '对话引用已被管理员禁用')) {
|
||||
return;
|
||||
}
|
||||
if (!this.isConnected) {
|
||||
this.uiPushToast({
|
||||
title: '无法使用',
|
||||
@ -3165,7 +3259,9 @@ const appOptions = {
|
||||
id: item.id,
|
||||
label: item.label || item.id,
|
||||
enabled: !!item.enabled,
|
||||
tools: Array.isArray(item.tools) ? item.tools : []
|
||||
tools: Array.isArray(item.tools) ? item.tools : [],
|
||||
locked: !!item.locked,
|
||||
locked_state: typeof item.locked_state === 'boolean' ? item.locked_state : null
|
||||
}));
|
||||
debugLog('[ToolSettings] Snapshot applied', {
|
||||
received: categories.length,
|
||||
@ -3215,6 +3311,15 @@ const appOptions = {
|
||||
if (this.toolSettingsLoading) {
|
||||
return;
|
||||
}
|
||||
const policyStore = usePolicyStore();
|
||||
if (policyStore.isCategoryLocked(categoryId)) {
|
||||
this.uiPushToast({
|
||||
title: '无法修改',
|
||||
message: '该工具类别被管理员强制设置',
|
||||
type: 'warning'
|
||||
});
|
||||
return;
|
||||
}
|
||||
const previousSnapshot = this.toolSettings.map((item) => ({ ...item }));
|
||||
const updatedSettings = this.toolSettings.map((item) => {
|
||||
if (item.id === categoryId) {
|
||||
@ -3239,6 +3344,13 @@ const appOptions = {
|
||||
this.applyToolSettingsSnapshot(data.categories);
|
||||
} else {
|
||||
console.warn('更新工具设置失败:', data);
|
||||
if (data && (data.message || data.error)) {
|
||||
this.uiPushToast({
|
||||
title: '无法切换工具',
|
||||
message: data.message || data.error,
|
||||
type: 'warning'
|
||||
});
|
||||
}
|
||||
this.toolSetSettings(previousSnapshot);
|
||||
}
|
||||
} catch (error) {
|
||||
|
||||
@ -74,6 +74,13 @@
|
||||
:current-conversation-id="currentConversationId"
|
||||
:icon-style="iconStyle"
|
||||
:tool-category-icon="toolCategoryIcon"
|
||||
:block-upload="blockUpload"
|
||||
:block-tool-toggle="blockToolToggle"
|
||||
:block-realtime-terminal="blockRealtimeTerminal"
|
||||
:block-focus-panel="blockFocusPanel"
|
||||
:block-token-panel="blockTokenPanel"
|
||||
:block-compress-conversation="blockCompressConversation"
|
||||
:block-conversation-review="blockConversationReview"
|
||||
@quick-upload="triggerQuickUpload"
|
||||
@pick-images="$emit('pick-images')"
|
||||
@toggle-tool-menu="$emit('toggle-tool-menu')"
|
||||
@ -147,9 +154,16 @@ const props = defineProps<{
|
||||
currentConversationId: string | null;
|
||||
iconStyle: (key: string) => Record<string, string>;
|
||||
toolCategoryIcon: (categoryId: string) => string;
|
||||
modelOptions: Array<{ key: string; label: string; description: string }>;
|
||||
modelOptions: Array<{ key: string; label: string; description: string; disabled?: boolean }>;
|
||||
currentModelKey: string;
|
||||
selectedImages?: string[];
|
||||
blockUpload?: boolean;
|
||||
blockToolToggle?: boolean;
|
||||
blockRealtimeTerminal?: boolean;
|
||||
blockFocusPanel?: boolean;
|
||||
blockTokenPanel?: boolean;
|
||||
blockCompressConversation?: boolean;
|
||||
blockConversationReview?: boolean;
|
||||
}>();
|
||||
|
||||
const inputStore = useInputStore();
|
||||
|
||||
@ -1,7 +1,12 @@
|
||||
<template>
|
||||
<transition name="quick-menu">
|
||||
<div v-if="open" class="quick-menu" @click.stop>
|
||||
<button type="button" class="menu-entry" @click="$emit('quick-upload')" :disabled="!isConnected || uploading">
|
||||
<button
|
||||
type="button"
|
||||
class="menu-entry"
|
||||
@click="$emit('quick-upload')"
|
||||
:disabled="!isConnected || uploading"
|
||||
>
|
||||
{{ uploading ? '上传中...' : '上传文件' }}
|
||||
</button>
|
||||
<button
|
||||
@ -108,7 +113,7 @@
|
||||
:key="category.id"
|
||||
type="button"
|
||||
class="menu-entry submenu-entry"
|
||||
:class="{ disabled: !category.enabled }"
|
||||
:class="{ disabled: !category.enabled || category.locked }"
|
||||
@click.stop="$emit('update-tool-category', category.id, !category.enabled)"
|
||||
:disabled="streamingMessage || !isConnected || toolSettingsLoading"
|
||||
>
|
||||
@ -116,7 +121,14 @@
|
||||
<span class="icon icon-sm" :style="getIconStyle(toolCategoryIcon(category.id))" aria-hidden="true"></span>
|
||||
<span>{{ category.label }}</span>
|
||||
</span>
|
||||
<span class="entry-arrow">{{ category.enabled ? '禁用' : '启用' }}</span>
|
||||
<span class="entry-arrow">
|
||||
<template v-if="category.locked">
|
||||
被管理员锁定
|
||||
</template>
|
||||
<template v-else>
|
||||
{{ category.enabled ? '禁用' : '启用' }}
|
||||
</template>
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@ -186,8 +198,15 @@ const props = defineProps<{
|
||||
modeMenuOpen: boolean;
|
||||
runMode?: 'fast' | 'thinking' | 'deep';
|
||||
modelMenuOpen: boolean;
|
||||
modelOptions: Array<{ key: string; label: string; description: string }>;
|
||||
modelOptions: Array<{ key: string; label: string; description: string; disabled?: boolean }>;
|
||||
currentModelKey: string;
|
||||
blockUpload?: boolean;
|
||||
blockToolToggle?: boolean;
|
||||
blockRealtimeTerminal?: boolean;
|
||||
blockFocusPanel?: boolean;
|
||||
blockTokenPanel?: boolean;
|
||||
blockCompressConversation?: boolean;
|
||||
blockConversationReview?: boolean;
|
||||
}>();
|
||||
|
||||
defineEmits<{
|
||||
|
||||
@ -68,7 +68,13 @@
|
||||
</div>
|
||||
</transition>
|
||||
</div>
|
||||
<button class="sidebar-manage-btn" @click="$emit('open-file-manager')" title="打开桌面式文件管理器">管理</button>
|
||||
<button
|
||||
class="sidebar-manage-btn"
|
||||
@click="$emit('open-file-manager')"
|
||||
:title="fileManagerDisabled ? '已被管理员禁用' : '打开桌面式文件管理器'"
|
||||
>
|
||||
管理
|
||||
</button>
|
||||
<h3>
|
||||
<span v-if="panelMode === 'files'" class="icon-label">
|
||||
<span class="icon icon-sm" :style="iconStyle('folder')" aria-hidden="true"></span>
|
||||
@ -146,6 +152,7 @@ const props = defineProps<{
|
||||
panelMenuOpen: boolean;
|
||||
panelMode: 'files' | 'todo' | 'subAgents';
|
||||
runMode: 'fast' | 'thinking' | 'deep';
|
||||
fileManagerDisabled?: boolean;
|
||||
}>();
|
||||
|
||||
defineEmits<{
|
||||
|
||||
@ -191,17 +191,19 @@
|
||||
</div>
|
||||
<div class="run-mode-options model-options">
|
||||
<button
|
||||
v-for="option in modelOptions"
|
||||
v-for="option in filteredModelOptions"
|
||||
:key="option.id"
|
||||
type="button"
|
||||
class="run-mode-card"
|
||||
:class="{ active: form.default_model === option.value }"
|
||||
:class="[{ active: form.default_model === option.value }, { disabled: option.disabled }]"
|
||||
:aria-pressed="form.default_model === option.value"
|
||||
@click.prevent="setDefaultModel(option.value)"
|
||||
:disabled="option.disabled"
|
||||
@click.prevent="!option.disabled && setDefaultModel(option.value)"
|
||||
>
|
||||
<div class="run-mode-card-header">
|
||||
<span class="run-mode-title">{{ option.label }}</span>
|
||||
<span v-if="option.badge" class="run-mode-badge">{{ option.badge }}</span>
|
||||
<span v-if="option.disabled" class="run-mode-badge danger">已禁用</span>
|
||||
</div>
|
||||
<p class="run-mode-desc">{{ option.desc }}</p>
|
||||
</button>
|
||||
@ -521,6 +523,7 @@ import { storeToRefs } from 'pinia';
|
||||
import { usePersonalizationStore } from '@/stores/personalization';
|
||||
import { useResourceStore } from '@/stores/resource';
|
||||
import { useUiStore } from '@/stores/ui';
|
||||
import { usePolicyStore } from '@/stores/policy';
|
||||
import { useTheme } from '@/utils/theme';
|
||||
import type { ThemeKey } from '@/utils/theme';
|
||||
|
||||
@ -577,6 +580,8 @@ const runModeOptions: Array<{ id: string; label: string; desc: string; value: Ru
|
||||
{ id: 'deep', label: '深度思考', desc: '整轮对话都使用思考模型', value: 'deep' }
|
||||
];
|
||||
|
||||
const policyStore = usePolicyStore();
|
||||
|
||||
const modelOptions = [
|
||||
{ id: 'deepseek', label: 'DeepSeek', desc: '通用 + 思考强化', value: 'deepseek' },
|
||||
{ id: 'kimi', label: 'Kimi', desc: '默认模型,兼顾通用对话', value: 'kimi' },
|
||||
@ -584,6 +589,13 @@ const modelOptions = [
|
||||
{ id: 'qwen3-vl-plus', label: 'Qwen-VL', desc: '图文多模态,思考/快速均可', value: 'qwen3-vl-plus', badge: '图文' }
|
||||
] as const;
|
||||
|
||||
const filteredModelOptions = computed(() =>
|
||||
modelOptions.map((opt) => ({
|
||||
...opt,
|
||||
disabled: policyStore.disabledModelSet.has(opt.value)
|
||||
}))
|
||||
);
|
||||
|
||||
const thinkingPresets = [
|
||||
{ id: 'low', label: '低', value: 10 },
|
||||
{ id: 'medium', label: '中', value: 5 },
|
||||
@ -645,6 +657,14 @@ const setDefaultRunMode = (value: RunModeValue) => {
|
||||
};
|
||||
|
||||
const setDefaultModel = (value: string) => {
|
||||
if (policyStore.disabledModelSet.has(value)) {
|
||||
uiStore.pushToast({
|
||||
title: '模型被禁用',
|
||||
message: '已被管理员禁用,无法选择',
|
||||
type: 'warning'
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (checkModeModelConflict(form.value.default_run_mode, value)) {
|
||||
return;
|
||||
}
|
||||
|
||||
@ -67,9 +67,9 @@
|
||||
<button
|
||||
type="button"
|
||||
class="collapsed-control-btn monitor-mode-btn"
|
||||
:class="{ active: displayMode === 'monitor' }"
|
||||
:disabled="displayModeDisabled"
|
||||
:class="{ active: displayMode === 'monitor', blocked: displayModeDisabled }"
|
||||
@click="$emit('toggle-display-mode')"
|
||||
:aria-disabled="displayModeDisabled"
|
||||
:title="displayMode === 'monitor' ? '退出显示器' : '虚拟显示器'"
|
||||
>
|
||||
<span class="sr-only">{{ displayMode === 'monitor' ? '退出显示器' : '虚拟显示器' }}</span>
|
||||
|
||||
78
static/src/stores/policy.ts
Normal file
78
static/src/stores/policy.ts
Normal file
@ -0,0 +1,78 @@
|
||||
import { defineStore } from 'pinia';
|
||||
|
||||
export interface EffectivePolicy {
|
||||
categories: Record<string, { label: string; tools: string[]; default_enabled?: boolean; locked?: boolean; locked_state?: boolean | null }>;
|
||||
forced_category_states: Record<string, boolean | null>;
|
||||
disabled_models: string[];
|
||||
ui_blocks: Record<string, boolean>;
|
||||
updated_at?: string;
|
||||
}
|
||||
|
||||
interface PolicyState {
|
||||
loaded: boolean;
|
||||
loading: boolean;
|
||||
error: string;
|
||||
policy: EffectivePolicy | null;
|
||||
}
|
||||
|
||||
const DEFAULT_POLICY: EffectivePolicy = {
|
||||
categories: {},
|
||||
forced_category_states: {},
|
||||
disabled_models: [],
|
||||
ui_blocks: {}
|
||||
};
|
||||
|
||||
export const usePolicyStore = defineStore('policy', {
|
||||
state: (): PolicyState => ({
|
||||
loaded: false,
|
||||
loading: false,
|
||||
error: '',
|
||||
policy: null
|
||||
}),
|
||||
getters: {
|
||||
disabledModelSet(state): Set<string> {
|
||||
return new Set(state.policy?.disabled_models || []);
|
||||
},
|
||||
forcedCategoryStates(state): Record<string, boolean | null> {
|
||||
return state.policy?.forced_category_states || {};
|
||||
},
|
||||
uiBlocks(state): Record<string, boolean> {
|
||||
return state.policy?.ui_blocks || {};
|
||||
}
|
||||
},
|
||||
actions: {
|
||||
async fetchPolicy() {
|
||||
if (this.loading) return;
|
||||
this.loading = true;
|
||||
this.error = '';
|
||||
try {
|
||||
const resp = await fetch('/api/effective-policy');
|
||||
const result = await resp.json();
|
||||
if (!resp.ok || !result.success) {
|
||||
throw new Error(result.error || '加载策略失败');
|
||||
}
|
||||
this.policy = result.data || DEFAULT_POLICY;
|
||||
this.loaded = true;
|
||||
} catch (error: any) {
|
||||
this.error = error?.message || '加载策略失败';
|
||||
} finally {
|
||||
this.loading = false;
|
||||
}
|
||||
},
|
||||
isModelDisabled(key: string): boolean {
|
||||
return this.disabledModelSet.has(key);
|
||||
},
|
||||
isCategoryLocked(id: string): boolean {
|
||||
const states = this.forcedCategoryStates;
|
||||
return typeof states[id] === 'boolean';
|
||||
},
|
||||
categoryLockedState(id: string): boolean | null {
|
||||
const states = this.forcedCategoryStates;
|
||||
if (typeof states[id] === 'boolean') return states[id] as boolean;
|
||||
return null;
|
||||
},
|
||||
isBlocked(action: string): boolean {
|
||||
return !!this.uiBlocks[action];
|
||||
}
|
||||
}
|
||||
});
|
||||
@ -4,6 +4,9 @@ export interface ToolCategory {
|
||||
id: string;
|
||||
label: string;
|
||||
enabled: boolean;
|
||||
locked?: boolean;
|
||||
locked_state?: boolean | null;
|
||||
tools?: string[];
|
||||
}
|
||||
|
||||
interface ToolState {
|
||||
|
||||
@ -1,6 +1,7 @@
|
||||
import { defineStore } from 'pinia';
|
||||
import { useUiStore } from './ui';
|
||||
import { useFileStore } from './file';
|
||||
import { usePolicyStore } from './policy';
|
||||
|
||||
interface UploadState {
|
||||
uploading: boolean;
|
||||
@ -29,6 +30,16 @@ export const useUploadStore = defineStore('upload', {
|
||||
if (!file || this.uploading) {
|
||||
return;
|
||||
}
|
||||
const policyStore = usePolicyStore();
|
||||
if (policyStore.uiBlocks?.block_upload) {
|
||||
const uiStore = useUiStore();
|
||||
uiStore.pushToast({
|
||||
title: '上传被禁用',
|
||||
message: '已被管理员禁用上传功能',
|
||||
type: 'warning'
|
||||
});
|
||||
return;
|
||||
}
|
||||
const uiStore = useUiStore();
|
||||
const fileStore = useFileStore();
|
||||
this.setUploading(true);
|
||||
|
||||
@ -155,6 +155,17 @@
|
||||
color: var(--claude-accent);
|
||||
}
|
||||
|
||||
.monitor-mode-btn.blocked {
|
||||
color: var(--claude-text-tertiary);
|
||||
opacity: 0.55;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.monitor-mode-btn.blocked:hover {
|
||||
color: var(--claude-accent);
|
||||
opacity: 0.75;
|
||||
}
|
||||
|
||||
.conversation-menu-btn {
|
||||
color: #31271d;
|
||||
}
|
||||
|
||||
@ -4,6 +4,7 @@ import vue from '@vitejs/plugin-vue';
|
||||
|
||||
const entry = fileURLToPath(new URL('./static/src/main.ts', import.meta.url));
|
||||
const adminEntry = fileURLToPath(new URL('./static/src/admin/main.ts', import.meta.url));
|
||||
const adminPolicyEntry = fileURLToPath(new URL('./static/src/admin/policyMain.ts', import.meta.url));
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [vue()],
|
||||
@ -13,7 +14,8 @@ export default defineConfig({
|
||||
rollupOptions: {
|
||||
input: {
|
||||
main: entry,
|
||||
admin: adminEntry
|
||||
admin: adminEntry,
|
||||
adminPolicy: adminPolicyEntry
|
||||
},
|
||||
output: {
|
||||
entryFileNames: 'assets/[name].js',
|
||||
|
||||
178
web_server.py
178
web_server.py
@ -21,6 +21,7 @@ import time
|
||||
from datetime import datetime
|
||||
from collections import defaultdict, deque, Counter
|
||||
from config.model_profiles import get_model_profile
|
||||
from modules import admin_policy_manager
|
||||
from werkzeug.utils import secure_filename
|
||||
from werkzeug.routing import BaseConverter
|
||||
import secrets
|
||||
@ -759,6 +760,19 @@ def is_admin_user(record=None) -> bool:
|
||||
role = get_current_user_role(record)
|
||||
return isinstance(role, str) and role.lower() == 'admin'
|
||||
|
||||
def resolve_admin_policy(record=None) -> Dict[str, Any]:
|
||||
"""获取当前用户生效的管理员策略。"""
|
||||
if record is None:
|
||||
record = get_current_user_record()
|
||||
username = record.username if record else None
|
||||
role = get_current_user_role(record)
|
||||
invite_code = getattr(record, "invite_code", None)
|
||||
try:
|
||||
return admin_policy_manager.get_effective_policy(username, role, invite_code)
|
||||
except Exception as exc:
|
||||
debug_log(f"[admin_policy] 加载失败: {exc}")
|
||||
return admin_policy_manager.get_effective_policy(username, role, invite_code)
|
||||
|
||||
|
||||
def admin_required(view_func):
|
||||
@wraps(view_func)
|
||||
@ -850,6 +864,38 @@ def get_user_resources(username: Optional[str] = None) -> Tuple[Optional[WebTerm
|
||||
attach_user_broadcast(terminal, username)
|
||||
terminal.username = username
|
||||
terminal.quota_update_callback = lambda metric=None: emit_user_quota_update(username)
|
||||
|
||||
# 应用管理员策略(工具分类、强制开关、模型禁用)
|
||||
try:
|
||||
from core.tool_config import ToolCategory
|
||||
|
||||
policy = resolve_admin_policy(user_manager.get_user(username))
|
||||
categories_map = {
|
||||
cid: ToolCategory(
|
||||
label=cat.get("label") or cid,
|
||||
tools=list(cat.get("tools") or []),
|
||||
default_enabled=bool(cat.get("default_enabled", True)),
|
||||
silent_when_disabled=bool(cat.get("silent_when_disabled", False)),
|
||||
)
|
||||
for cid, cat in policy.get("categories", {}).items()
|
||||
}
|
||||
forced_states = policy.get("forced_category_states") or {}
|
||||
disabled_models = policy.get("disabled_models") or []
|
||||
terminal.set_admin_policy(categories_map, forced_states, disabled_models)
|
||||
terminal.admin_policy_ui_blocks = policy.get("ui_blocks") or {}
|
||||
terminal.admin_policy_version = policy.get("updated_at")
|
||||
# 若当前模型被禁用,则回退到第一个可用模型
|
||||
if terminal.model_key in disabled_models:
|
||||
for candidate in ["kimi", "deepseek", "qwen3-vl-plus", "qwen3-max"]:
|
||||
if candidate not in disabled_models:
|
||||
try:
|
||||
terminal.set_model(candidate)
|
||||
session["model_key"] = terminal.model_key
|
||||
break
|
||||
except Exception:
|
||||
continue
|
||||
except Exception as exc:
|
||||
debug_log(f"[admin_policy] 应用失败: {exc}")
|
||||
return terminal, workspace
|
||||
|
||||
|
||||
@ -1365,6 +1411,9 @@ def conversation_page(conversation_id):
|
||||
@login_required
|
||||
def terminal_page():
|
||||
"""终端监控页面"""
|
||||
policy = resolve_admin_policy(get_current_user_record())
|
||||
if policy.get("ui_blocks", {}).get("block_realtime_terminal"):
|
||||
return "实时终端已被管理员禁用", 403
|
||||
return app.send_static_file('terminal.html')
|
||||
|
||||
|
||||
@ -1372,6 +1421,9 @@ def terminal_page():
|
||||
@login_required
|
||||
def gui_file_manager_page():
|
||||
"""桌面式文件管理器页面"""
|
||||
policy = resolve_admin_policy(get_current_user_record())
|
||||
if policy.get("ui_blocks", {}).get("block_file_manager"):
|
||||
return "文件管理器已被管理员禁用", 403
|
||||
return send_from_directory(Path(app.static_folder) / 'file_manager', 'index.html')
|
||||
|
||||
|
||||
@ -1407,6 +1459,13 @@ def admin_monitor_page():
|
||||
"""管理员监控页面入口"""
|
||||
return send_from_directory(str(ADMIN_ASSET_DIR), 'index.html')
|
||||
|
||||
@app.route('/admin/policy')
|
||||
@login_required
|
||||
@admin_required
|
||||
def admin_policy_page():
|
||||
"""管理员策略配置页面"""
|
||||
return send_from_directory(Path(app.static_folder) / 'admin_policy', 'index.html')
|
||||
|
||||
|
||||
@app.route('/admin/assets/<path:filename>')
|
||||
@login_required
|
||||
@ -1513,6 +1572,16 @@ def get_status(terminal: WebTerminal, workspace: UserWorkspace, username: str):
|
||||
except Exception as exc:
|
||||
status['container'] = {"success": False, "error": str(exc)}
|
||||
status['version'] = AGENT_VERSION
|
||||
try:
|
||||
policy = resolve_admin_policy(user_manager.get_user(username))
|
||||
status['admin_policy'] = {
|
||||
"ui_blocks": policy.get("ui_blocks") or {},
|
||||
"disabled_models": policy.get("disabled_models") or [],
|
||||
"forced_category_states": policy.get("forced_category_states") or {},
|
||||
"version": policy.get("updated_at"),
|
||||
}
|
||||
except Exception as exc:
|
||||
debug_log(f"[status] 附加管理员策略失败: {exc}")
|
||||
return jsonify(status)
|
||||
|
||||
@app.route('/api/container-status')
|
||||
@ -1568,6 +1637,37 @@ def admin_dashboard_snapshot_api():
|
||||
logging.exception("Failed to build admin dashboard")
|
||||
return jsonify({"success": False, "error": str(exc)}), 500
|
||||
|
||||
@app.route('/api/admin/policy', methods=['GET', 'POST'])
|
||||
@api_login_required
|
||||
@admin_api_required
|
||||
def admin_policy_api():
|
||||
if request.method == 'GET':
|
||||
try:
|
||||
data = admin_policy_manager.load_policy()
|
||||
defaults = admin_policy_manager.describe_defaults()
|
||||
return jsonify({"success": True, "data": data, "defaults": defaults})
|
||||
except Exception as exc:
|
||||
return jsonify({"success": False, "error": str(exc)}), 500
|
||||
# POST 更新
|
||||
payload = request.get_json() or {}
|
||||
target_type = payload.get("target_type")
|
||||
target_value = payload.get("target_value") or ""
|
||||
config = payload.get("config") or {}
|
||||
try:
|
||||
saved = admin_policy_manager.save_scope_policy(target_type, target_value, config)
|
||||
return jsonify({"success": True, "data": saved})
|
||||
except ValueError as exc:
|
||||
return jsonify({"success": False, "error": str(exc)}), 400
|
||||
except Exception as exc:
|
||||
return jsonify({"success": False, "error": str(exc)}), 500
|
||||
|
||||
@app.route('/api/effective-policy', methods=['GET'])
|
||||
@api_login_required
|
||||
def effective_policy_api():
|
||||
record = get_current_user_record()
|
||||
policy = resolve_admin_policy(record)
|
||||
return jsonify({"success": True, "data": policy})
|
||||
|
||||
|
||||
@app.route('/api/usage', methods=['GET'])
|
||||
@api_login_required
|
||||
@ -1652,6 +1752,16 @@ def update_model(terminal: WebTerminal, workspace: UserWorkspace, username: str)
|
||||
if not model_key:
|
||||
return jsonify({"success": False, "error": "缺少 model_key"}), 400
|
||||
|
||||
# 管理员禁用模型校验
|
||||
policy = resolve_admin_policy(get_current_user_record())
|
||||
disabled_models = set(policy.get("disabled_models") or [])
|
||||
if model_key in disabled_models:
|
||||
return jsonify({
|
||||
"success": False,
|
||||
"error": "该模型已被管理员禁用",
|
||||
"message": "被管理员强制禁用"
|
||||
}), 403
|
||||
|
||||
terminal.set_model(model_key)
|
||||
# fast-only 时 run_mode 可能被强制为 fast
|
||||
session["model_key"] = terminal.model_key
|
||||
@ -1698,6 +1808,9 @@ def update_model(terminal: WebTerminal, workspace: UserWorkspace, username: str)
|
||||
def get_personalization_settings(terminal: WebTerminal, workspace: UserWorkspace, username: str):
|
||||
"""获取个性化配置"""
|
||||
try:
|
||||
policy = resolve_admin_policy(get_current_user_record())
|
||||
if policy.get("ui_blocks", {}).get("block_personal_space"):
|
||||
return jsonify({"success": False, "error": "个人空间已被管理员禁用"}), 403
|
||||
data = load_personalization_config(workspace.data_dir)
|
||||
return jsonify({
|
||||
"success": True,
|
||||
@ -1721,6 +1834,9 @@ def update_personalization_settings(terminal: WebTerminal, workspace: UserWorksp
|
||||
"""更新个性化配置"""
|
||||
payload = request.get_json() or {}
|
||||
try:
|
||||
policy = resolve_admin_policy(get_current_user_record())
|
||||
if policy.get("ui_blocks", {}).get("block_personal_space"):
|
||||
return jsonify({"success": False, "error": "个人空间已被管理员禁用"}), 403
|
||||
config = save_personalization_config(workspace.data_dir, payload)
|
||||
try:
|
||||
terminal.apply_personalization_preferences(config)
|
||||
@ -1766,6 +1882,9 @@ def update_personalization_settings(terminal: WebTerminal, workspace: UserWorksp
|
||||
@with_terminal
|
||||
def get_files(terminal: WebTerminal, workspace: UserWorkspace, username: str):
|
||||
"""获取文件树"""
|
||||
policy = resolve_admin_policy(get_current_user_record())
|
||||
if policy.get("ui_blocks", {}).get("collapse_workspace") or policy.get("ui_blocks", {}).get("block_file_manager"):
|
||||
return jsonify({"success": False, "error": "文件浏览已被管理员禁用"}), 403
|
||||
structure = terminal.context_manager.get_project_structure()
|
||||
return jsonify(structure)
|
||||
|
||||
@ -1792,6 +1911,9 @@ def _format_entry(entry) -> Dict[str, Any]:
|
||||
@with_terminal
|
||||
def gui_list_entries(terminal: WebTerminal, workspace: UserWorkspace, username: str):
|
||||
"""列出指定目录内容"""
|
||||
policy = resolve_admin_policy(get_current_user_record())
|
||||
if policy.get("ui_blocks", {}).get("block_file_manager"):
|
||||
return jsonify({"success": False, "error": "文件管理已被管理员禁用"}), 403
|
||||
relative_path = request.args.get('path') or ""
|
||||
manager = get_gui_manager(workspace)
|
||||
try:
|
||||
@ -1821,6 +1943,9 @@ def gui_create_entry(terminal: WebTerminal, workspace: UserWorkspace, username:
|
||||
parent = payload.get('path') or ""
|
||||
name = payload.get('name') or ""
|
||||
entry_type = payload.get('type') or "file"
|
||||
policy = resolve_admin_policy(get_current_user_record())
|
||||
if policy.get("ui_blocks", {}).get("block_file_manager"):
|
||||
return jsonify({"success": False, "error": "文件管理已被管理员禁用"}), 403
|
||||
manager = get_gui_manager(workspace)
|
||||
try:
|
||||
new_path = manager.create_entry(parent, name, entry_type)
|
||||
@ -1842,6 +1967,9 @@ def gui_create_entry(terminal: WebTerminal, workspace: UserWorkspace, username:
|
||||
def gui_delete_entries(terminal: WebTerminal, workspace: UserWorkspace, username: str):
|
||||
payload = request.get_json() or {}
|
||||
paths = payload.get('paths') or []
|
||||
policy = resolve_admin_policy(get_current_user_record())
|
||||
if policy.get("ui_blocks", {}).get("block_file_manager"):
|
||||
return jsonify({"success": False, "error": "文件管理已被管理员禁用"}), 403
|
||||
manager = get_gui_manager(workspace)
|
||||
try:
|
||||
result = manager.delete_entries(paths)
|
||||
@ -1866,6 +1994,9 @@ def gui_rename_entry(terminal: WebTerminal, workspace: UserWorkspace, username:
|
||||
new_name = payload.get('new_name')
|
||||
if not path or not new_name:
|
||||
return jsonify({"success": False, "error": "缺少 path 或 new_name"}), 400
|
||||
policy = resolve_admin_policy(get_current_user_record())
|
||||
if policy.get("ui_blocks", {}).get("block_file_manager"):
|
||||
return jsonify({"success": False, "error": "文件管理已被管理员禁用"}), 403
|
||||
manager = get_gui_manager(workspace)
|
||||
try:
|
||||
new_path = manager.rename_entry(path, new_name)
|
||||
@ -1888,6 +2019,9 @@ def gui_copy_entries(terminal: WebTerminal, workspace: UserWorkspace, username:
|
||||
payload = request.get_json() or {}
|
||||
paths = payload.get('paths') or []
|
||||
target_dir = payload.get('target_dir') or ""
|
||||
policy = resolve_admin_policy(get_current_user_record())
|
||||
if policy.get("ui_blocks", {}).get("block_file_manager"):
|
||||
return jsonify({"success": False, "error": "文件管理已被管理员禁用"}), 403
|
||||
manager = get_gui_manager(workspace)
|
||||
try:
|
||||
result = manager.copy_entries(paths, target_dir)
|
||||
@ -1929,6 +2063,9 @@ def gui_move_entries(terminal: WebTerminal, workspace: UserWorkspace, username:
|
||||
@with_terminal
|
||||
@rate_limited("gui_file_upload", 10, 300, scope="user")
|
||||
def gui_upload_entry(terminal: WebTerminal, workspace: UserWorkspace, username: str):
|
||||
policy = resolve_admin_policy(get_current_user_record())
|
||||
if policy.get("ui_blocks", {}).get("block_upload"):
|
||||
return jsonify({"success": False, "error": "文件上传已被管理员禁用"}), 403
|
||||
if 'file' not in request.files:
|
||||
return jsonify({"success": False, "error": "未找到文件"}), 400
|
||||
file_obj = request.files['file']
|
||||
@ -2135,6 +2272,13 @@ def get_todo_list(terminal: WebTerminal, workspace: UserWorkspace, username: str
|
||||
@rate_limited("legacy_upload", 20, 300, scope="user")
|
||||
def upload_file(terminal: WebTerminal, workspace: UserWorkspace, username: str):
|
||||
"""处理前端文件上传请求"""
|
||||
policy = resolve_admin_policy(get_current_user_record())
|
||||
if policy.get("ui_blocks", {}).get("block_upload"):
|
||||
return jsonify({
|
||||
"success": False,
|
||||
"error": "文件上传已被管理员禁用",
|
||||
"message": "被管理员禁用上传"
|
||||
}), 403
|
||||
if 'file' not in request.files:
|
||||
return jsonify({
|
||||
"success": False,
|
||||
@ -2351,7 +2495,21 @@ def tool_settings(terminal: WebTerminal, workspace: UserWorkspace, username: str
|
||||
}), 400
|
||||
|
||||
try:
|
||||
policy = resolve_admin_policy(get_current_user_record())
|
||||
if policy.get("ui_blocks", {}).get("block_tool_toggle"):
|
||||
return jsonify({
|
||||
"success": False,
|
||||
"error": "工具开关已被管理员禁用",
|
||||
"message": "被管理员强制禁用"
|
||||
}), 403
|
||||
enabled = bool(data['enabled'])
|
||||
forced = getattr(terminal, "admin_forced_category_states", {}) or {}
|
||||
if isinstance(forced.get(category), bool) and forced[category] != enabled:
|
||||
return jsonify({
|
||||
"success": False,
|
||||
"error": "该工具类别已被管理员强制为启用/禁用,无法修改",
|
||||
"message": "被管理员强制启用/禁用"
|
||||
}), 403
|
||||
terminal.set_tool_category_enabled(category, enabled)
|
||||
snapshot = terminal.get_tool_settings_snapshot()
|
||||
socketio.emit('tool_settings_updated', {
|
||||
@ -2372,6 +2530,9 @@ def tool_settings(terminal: WebTerminal, workspace: UserWorkspace, username: str
|
||||
@with_terminal
|
||||
def get_terminals(terminal: WebTerminal, workspace: UserWorkspace, username: str):
|
||||
"""获取终端会话列表"""
|
||||
policy = resolve_admin_policy(get_current_user_record())
|
||||
if policy.get("ui_blocks", {}).get("block_realtime_terminal"):
|
||||
return jsonify({"success": False, "error": "实时终端已被管理员禁用"}), 403
|
||||
if terminal.terminal_manager:
|
||||
result = terminal.terminal_manager.list_terminals()
|
||||
return jsonify(result)
|
||||
@ -2510,6 +2671,10 @@ def handle_terminal_subscribe(data):
|
||||
if not username or not terminal or not terminal.terminal_manager:
|
||||
emit('error', {'message': 'Terminal system not initialized'})
|
||||
return
|
||||
policy = resolve_admin_policy(user_manager.get_user(username))
|
||||
if policy.get("ui_blocks", {}).get("block_realtime_terminal"):
|
||||
emit('error', {'message': '实时终端已被管理员禁用'})
|
||||
return
|
||||
|
||||
if request.sid not in terminal_rooms:
|
||||
terminal_rooms[request.sid] = set()
|
||||
@ -2564,6 +2729,10 @@ def handle_get_terminal_output(data):
|
||||
if not terminal or not terminal.terminal_manager:
|
||||
emit('error', {'message': 'Terminal system not initialized'})
|
||||
return
|
||||
policy = resolve_admin_policy(user_manager.get_user(username))
|
||||
if policy.get("ui_blocks", {}).get("block_realtime_terminal"):
|
||||
emit('error', {'message': '实时终端已被管理员禁用'})
|
||||
return
|
||||
|
||||
result = terminal.terminal_manager.get_terminal_output(session_name, lines)
|
||||
|
||||
@ -2967,6 +3136,9 @@ def get_conversation_messages(conversation_id, terminal: WebTerminal, workspace:
|
||||
def compress_conversation(conversation_id, terminal: WebTerminal, workspace: UserWorkspace, username: str):
|
||||
"""压缩指定对话的大体积消息,生成压缩版新对话"""
|
||||
try:
|
||||
policy = resolve_admin_policy(get_current_user_record())
|
||||
if policy.get("ui_blocks", {}).get("block_compress_conversation"):
|
||||
return jsonify({"success": False, "error": "压缩对话已被管理员禁用"}), 403
|
||||
normalized_id = conversation_id if conversation_id.startswith('conv_') else f"conv_{conversation_id}"
|
||||
result = terminal.context_manager.compress_conversation(normalized_id)
|
||||
|
||||
@ -3081,6 +3253,9 @@ def duplicate_conversation(conversation_id, terminal: WebTerminal, workspace: Us
|
||||
@with_terminal
|
||||
def review_conversation_preview(conversation_id, terminal: WebTerminal, workspace: UserWorkspace, username: str):
|
||||
"""生成对话回顾预览(不落盘,只返回前若干行文本)"""
|
||||
policy = resolve_admin_policy(get_current_user_record())
|
||||
if policy.get("ui_blocks", {}).get("block_conversation_review"):
|
||||
return jsonify({"success": False, "error": "对话引用已被管理员禁用"}), 403
|
||||
try:
|
||||
current_id = terminal.context_manager.current_conversation_id
|
||||
if conversation_id == current_id:
|
||||
@ -3121,6 +3296,9 @@ def review_conversation_preview(conversation_id, terminal: WebTerminal, workspac
|
||||
@with_terminal
|
||||
def review_conversation(conversation_id, terminal: WebTerminal, workspace: UserWorkspace, username: str):
|
||||
"""生成完整对话回顾 Markdown 文件"""
|
||||
policy = resolve_admin_policy(get_current_user_record())
|
||||
if policy.get("ui_blocks", {}).get("block_conversation_review"):
|
||||
return jsonify({"success": False, "error": "对话引用已被管理员禁用"}), 403
|
||||
try:
|
||||
current_id = terminal.context_manager.current_conversation_id
|
||||
if conversation_id == current_id:
|
||||
|
||||
Loading…
Reference in New Issue
Block a user