feat: polish admin policy UI and tool selection

This commit is contained in:
JOJO 2026-01-05 13:34:00 +08:00
parent 68b2ace43f
commit 99cbea30da
21 changed files with 1877 additions and 29 deletions

BIN
.DS_Store vendored

Binary file not shown.

View File

@ -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",
]

View 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)

View 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,
}

View 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>

View File

@ -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"

View File

@ -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;

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,4 @@
import { createApp } from 'vue';
import PolicyApp from './PolicyApp.vue';
createApp(PolicyApp).mount('#admin-policy-app');

View File

@ -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) {

View File

@ -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();

View File

@ -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<{

View File

@ -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<{

View File

@ -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;
}

View File

@ -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>

View 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];
}
}
});

View File

@ -4,6 +4,9 @@ export interface ToolCategory {
id: string;
label: string;
enabled: boolean;
locked?: boolean;
locked_state?: boolean | null;
tools?: string[];
}
interface ToolState {

View File

@ -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);

View File

@ -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;
}

View File

@ -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',

View File

@ -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: