From 5244768494bbcea99803652eff4a23137f3407f8 Mon Sep 17 00:00:00 2001 From: JOJO <1498581755@qq.com> Date: Sat, 11 Jul 2026 01:51:01 +0800 Subject: [PATCH] =?UTF-8?q?feat(sidebar):=20=E4=BE=A7=E8=BE=B9=E6=A0=8F?= =?UTF-8?q?=E6=8C=89=E5=B7=A5=E4=BD=9C=E5=8C=BA/=E9=A1=B9=E7=9B=AE?= =?UTF-8?q?=E5=88=86=E7=BB=84=E6=98=BE=E7=A4=BA=E5=AF=B9=E8=AF=9D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增个人化设置 group_sidebar_by_workspace / sidebar_pinned_workspaces / sidebar_workspace_order,支持分组视图、置顶、排序 - 后端 conversation API 支持 workspace_id 参数,用于获取/创建/加载/删除 指定工作区的对话,避免切换视图时 session 漂移 - server/context.py 新增 update_session 参数,临时解析目标 terminal 时不污染 当前会话 - 前端 ConversationSidebar 实现分组折叠、运行中指示器、新建/更多操作菜单、 动画同步 - config/terminal.py 默认 TERMINAL_SANDBOX_MODE 由 docker 改为 host --- config/terminal.py | 2 +- modules/personalization_manager.py | 48 ++ server/context.py | 25 +- server/conversation.py | 147 +++- server/status/file_open.py | 23 +- static/src/App.vue | 22 + static/src/app/computed.ts | 3 +- static/src/app/methods/conversation/action.ts | 165 +++- static/src/app/methods/conversation/load.ts | 72 +- static/src/app/methods/message/send.ts | 40 +- static/src/app/methods/ui/hostWorkspace.ts | 51 ++ .../personalization/PersonalizationDrawer.vue | 22 + .../sidebar/ConversationSidebar.vue | 802 ++++++++++++++++-- static/src/composables/useLegacySocket.ts | 10 + static/src/stores/conversation.ts | 183 +++- static/src/stores/personalization.ts | 13 + .../components/sidebar/_conversation.scss | 317 ++++++- static/src/utils/icons.ts | 1 + 18 files changed, 1811 insertions(+), 135 deletions(-) diff --git a/config/terminal.py b/config/terminal.py index afc5fb9..7f3e359 100644 --- a/config/terminal.py +++ b/config/terminal.py @@ -34,7 +34,7 @@ def _parse_paths(raw_value: str): _env_prefix = "TERMINAL_SANDBOX_ENV_" -TERMINAL_SANDBOX_MODE = os.environ.get("TERMINAL_SANDBOX_MODE", "docker").lower() +TERMINAL_SANDBOX_MODE = os.environ.get("TERMINAL_SANDBOX_MODE", "host").lower() TERMINAL_SANDBOX_IMAGE = os.environ.get("TERMINAL_SANDBOX_IMAGE", "python:3.11-slim") TERMINAL_SANDBOX_MOUNT_PATH = os.environ.get("TERMINAL_SANDBOX_MOUNT_PATH", "/workspace") TERMINAL_SANDBOX_SHELL = os.environ.get("TERMINAL_SANDBOX_SHELL", "/bin/bash") diff --git a/modules/personalization_manager.py b/modules/personalization_manager.py index 0603180..e1ac920 100644 --- a/modules/personalization_manager.py +++ b/modules/personalization_manager.py @@ -103,6 +103,9 @@ DEFAULT_PERSONALIZATION_CONFIG: Dict[str, Any] = { "agents_md_auto_inject": False, # AGENTS.md 自动注入开关 "allow_root_file_creation": False, # 允许在根目录创建文件开关 "default_hide_workspace": False, # 默认隐藏工作区 + "group_sidebar_by_workspace": False, # 侧边栏按工作区/项目分组显示对话 + "sidebar_pinned_workspaces": [], # 分组侧边栏中永久置顶的工作区ID列表 + "sidebar_workspace_order": [], # 分组侧边栏中非置顶工作区的显示顺序 "theme": "classic", # 主题配色: classic-经典/light-明亮/dark-暗黑 # 目标模式(Goal Mode) "goal_review_mode": "readonly", # readonly-仅读对话判断 / active-允许审核智能体跑只读命令取证 @@ -177,6 +180,25 @@ def load_personalization_config(base_dir: PathLike) -> Dict[str, Any]: return deepcopy(DEFAULT_PERSONALIZATION_CONFIG) +def _sanitize_string_list(value: Any, max_length: int = 100) -> list: + """Sanitize a list of non-empty strings, deduplicating and limiting length.""" + if not isinstance(value, list): + return [] + cleaned: list = [] + seen = set() + for item in value: + if not isinstance(item, str): + continue + s = item.strip() + if not s or s in seen: + continue + seen.add(s) + cleaned.append(s) + if len(cleaned) >= max_length: + break + return cleaned + + def sanitize_personalization_payload( payload: Optional[Dict[str, Any]], fallback: Optional[Dict[str, Any]] = None @@ -485,6 +507,32 @@ def sanitize_personalization_payload( else: base["default_hide_workspace"] = bool(base.get("default_hide_workspace", False)) + # 侧边栏按工作区/项目分组显示对话 + if "group_sidebar_by_workspace" in data: + base["group_sidebar_by_workspace"] = bool(data.get("group_sidebar_by_workspace")) + else: + base["group_sidebar_by_workspace"] = bool(base.get("group_sidebar_by_workspace", False)) + + # 分组侧边栏置顶工作区ID列表 + if "sidebar_pinned_workspaces" in data: + base["sidebar_pinned_workspaces"] = _sanitize_string_list( + data.get("sidebar_pinned_workspaces"), max_length=100 + ) + else: + base["sidebar_pinned_workspaces"] = _sanitize_string_list( + base.get("sidebar_pinned_workspaces"), max_length=100 + ) + + # 分组侧边栏工作区显示顺序 + if "sidebar_workspace_order" in data: + base["sidebar_workspace_order"] = _sanitize_string_list( + data.get("sidebar_workspace_order"), max_length=200 + ) + else: + base["sidebar_workspace_order"] = _sanitize_string_list( + base.get("sidebar_workspace_order"), max_length=200 + ) + # 主题配色 theme_value = data.get("theme", base.get("theme")) if isinstance(theme_value, str) and theme_value in ALLOWED_THEMES: diff --git a/server/context.py b/server/context.py index b38d4ef..aa3c67f 100644 --- a/server/context.py +++ b/server/context.py @@ -63,7 +63,7 @@ def _set_terminal_workspace_label(terminal: WebTerminal, label: Optional[str]) - pass -def _apply_workspace_personalization_preferences(terminal: WebTerminal, workspace) -> None: +def _apply_workspace_personalization_preferences(terminal: WebTerminal, workspace, update_session: bool = True) -> None: """Apply persisted workspace personalization after policy/workspace resolution.""" try: config = load_personalization_config(workspace.data_dir) @@ -90,7 +90,7 @@ def _apply_workspace_personalization_preferences(terminal: WebTerminal, workspac terminal._workspace_default_model_applied = True except Exception: pass - if has_request_context(): + if has_request_context() and update_session: session["run_mode"] = getattr(terminal, "run_mode", session.get("run_mode")) session["thinking_mode"] = getattr(terminal, "thinking_mode", session.get("thinking_mode")) session["model_key"] = getattr(terminal, "model_key", session.get("model_key")) @@ -128,7 +128,11 @@ def _ensure_workspace_skills_synced(terminal: WebTerminal, workspace) -> None: debug_log(f"[Skills] 工作区同步异常: {exc}") -def get_user_resources(username: Optional[str] = None, workspace_id: Optional[str] = None) -> Tuple[Optional[WebTerminal], Optional['modules.user_manager.UserWorkspace']]: +def get_user_resources( + username: Optional[str] = None, + workspace_id: Optional[str] = None, + update_session: bool = True, +) -> Tuple[Optional[WebTerminal], Optional['modules.user_manager.UserWorkspace']]: from modules.user_manager import UserWorkspace username = (username or get_current_username()) if not username: @@ -277,7 +281,7 @@ def get_user_resources(username: Optional[str] = None, workspace_id: Optional[st terminal.username = "host" terminal.user_role = "admin" terminal.quota_update_callback = None - if has_request_context(): + if has_request_context() and update_session: session['run_mode'] = terminal.run_mode session['thinking_mode'] = terminal.thinking_mode session['workspace_id'] = getattr(workspace, "workspace_id", None) @@ -287,7 +291,7 @@ def get_user_resources(username: Optional[str] = None, workspace_id: Optional[st attach_user_broadcast(terminal, "host") terminal.username = "host" terminal.user_role = "admin" - if has_request_context(): + if has_request_context() and update_session: session['workspace_id'] = getattr(workspace, "workspace_id", None) session['host_workspace_id'] = getattr(workspace, "workspace_id", None) _set_terminal_workspace_label( @@ -327,7 +331,7 @@ def get_user_resources(username: Optional[str] = None, workspace_id: Optional[st if candidate not in disabled_models: try: terminal.set_model(candidate) - if has_request_context(): + if has_request_context() and update_session: session["model_key"] = terminal.model_key break except Exception: @@ -410,7 +414,7 @@ def get_user_resources(username: Optional[str] = None, workspace_id: Optional[st terminal.username = username terminal.user_role = "api" if is_api_user else get_current_user_role(record) terminal.quota_update_callback = (lambda metric=None: emit_user_quota_update(username)) if not is_api_user else None - if has_request_context(): + if has_request_context() and update_session: session['run_mode'] = terminal.run_mode session['thinking_mode'] = terminal.thinking_mode session['model_key'] = getattr(terminal, "model_key", None) @@ -421,7 +425,7 @@ def get_user_resources(username: Optional[str] = None, workspace_id: Optional[st terminal.username = username terminal.user_role = "api" if is_api_user else get_current_user_role(record) terminal.quota_update_callback = (lambda metric=None: emit_user_quota_update(username)) if not is_api_user else None - if has_request_context(): + if has_request_context() and update_session: session['workspace_id'] = getattr(workspace, "workspace_id", None) if is_api_user: @@ -466,14 +470,15 @@ def get_user_resources(username: Optional[str] = None, workspace_id: Optional[st if candidate not in disabled_models: try: terminal.set_model(candidate) - session["model_key"] = terminal.model_key + if update_session: + session["model_key"] = terminal.model_key break except Exception: continue except Exception as exc: debug_log(f"[admin_policy] 应用失败: {exc}") - _apply_workspace_personalization_preferences(terminal, workspace) + _apply_workspace_personalization_preferences(terminal, workspace, update_session=update_session) _ensure_workspace_skills_synced(terminal, workspace) return terminal, workspace diff --git a/server/conversation.py b/server/conversation.py index 44826b9..4d97d5d 100644 --- a/server/conversation.py +++ b/server/conversation.py @@ -28,6 +28,7 @@ from config import ( DEFAULT_CONVERSATIONS_LIMIT, MAX_CONVERSATIONS_LIMIT, CONVERSATIONS_DIR, + DATA_DIR, DEFAULT_RESPONSE_MAX_TOKENS, DEFAULT_PROJECT_PATH, LOGS_DIR, @@ -49,6 +50,8 @@ from modules.usage_tracker import QUOTA_DEFAULTS from modules.sub_agent import TERMINAL_STATUSES from modules.versioning_manager import ConversationVersioningManager, VersioningError from modules.shallow_versioning import ShallowVersioningManager +from modules.host_workspace_manager import resolve_host_workspace +from utils.host_workspace_debug import write_host_workspace_debug from utils.perf_log import perf_log, PerfTimer from core.web_terminal import WebTerminal from utils.tool_result_formatter import format_tool_result_for_context @@ -469,23 +472,88 @@ def upsert_input_draft(terminal: WebTerminal, workspace: UserWorkspace, username return jsonify({"success": False, "error": f"保存输入草稿失败: {exc}"}), 500 +# === 背景生成对话标题(从 app_legacy 拆分) === +def _resolve_target_terminal_for_workspace( + username: str, + workspace_id: str, + current_terminal: WebTerminal, + current_workspace: UserWorkspace, +) -> tuple[WebTerminal, UserWorkspace]: + """当请求指定了非当前 session 工作区时,解析并返回目标工作区的 terminal 与 workspace。""" + write_host_workspace_debug( + "sidebar-debug-api", + api="resolve_target_terminal", + requested_workspace_id=workspace_id, + session_workspace_id=session.get("workspace_id"), + current_terminal_project_path=str(getattr(current_terminal, "project_path", None)), + ) + if _is_host_mode_request(username): + catalog, _ = resolve_host_workspace() + if not any(item.get("workspace_id") == workspace_id for item in (catalog.get("workspaces") or [])): + raise ValueError("工作区不存在") + else: + if workspace_id not in user_manager.list_user_workspaces(username): + raise ValueError("项目不存在") + try: + target_terminal, target_workspace = get_user_resources( + username, workspace_id=workspace_id, update_session=False + ) + except RuntimeError as exc: + raise RuntimeError(str(exc)) from exc + if not target_terminal or not target_workspace: + raise RuntimeError("系统未初始化") + target_cm = getattr(getattr(target_terminal, "context_manager", None), "conversation_manager", None) + write_host_workspace_debug( + "sidebar-debug-api", + api="resolve_target_terminal_result", + requested_workspace_id=workspace_id, + target_terminal_project_path=str(getattr(target_terminal, "project_path", None)), + target_cm_workspace_id=getattr(target_cm, "current_workspace_id", None), + target_cm_conversations_dir=str(getattr(target_cm, "conversations_dir", None)), + ) + return target_terminal, target_workspace + + # === 背景生成对话标题(从 app_legacy 拆分) === @conversation_bp.route('/api/conversations', methods=['GET']) @api_login_required @with_terminal def get_conversations(terminal: WebTerminal, workspace: UserWorkspace, username: str): - """获取对话列表""" + """获取对话列表,支持通过 workspace_id 查询指定工作区/项目。""" try: # 获取查询参数 limit = request.args.get('limit', 20, type=int) offset = request.args.get('offset', 0, type=int) non_empty = request.args.get('non_empty', '0') in ('1', 'true', 'True') + target_workspace_id = request.args.get('workspace_id', '', type=str).strip() # 限制参数范围 limit = max(1, min(limit, 10000)) # 限制在1-10000之间 offset = max(0, offset) + if target_workspace_id: + try: + terminal, workspace = _resolve_target_terminal_for_workspace( + username, target_workspace_id, terminal, workspace + ) + except ValueError as exc: + return jsonify({"success": False, "error": str(exc)}), 404 + except RuntimeError as exc: + return jsonify({"success": False, "error": str(exc)}), 503 + result = terminal.get_conversations_list(limit=limit, offset=offset, non_empty=non_empty) + cm = getattr(getattr(terminal, "context_manager", None), "conversation_manager", None) + write_host_workspace_debug( + "sidebar-debug-api", + api="GET /api/conversations", + workspace_id=target_workspace_id or None, + session_workspace_id=session.get("workspace_id"), + terminal_project_path=str(getattr(terminal, "project_path", None)), + cm_workspace_id=getattr(cm, "current_workspace_id", None), + cm_conversations_dir=str(getattr(cm, "conversations_dir", None)), + result_success=bool(result.get("success")), + result_count=len((result.get("data") or {}).get("conversations", [])), + ) if result["success"]: return jsonify({ @@ -511,7 +579,7 @@ def get_conversations(terminal: WebTerminal, workspace: UserWorkspace, username: @api_login_required @with_terminal def create_conversation(terminal: WebTerminal, workspace: UserWorkspace, username: str): - """创建新对话""" + """创建新对话,支持通过 workspace_id 在指定工作区/项目中创建。""" perf_log("create_conversation route enter", extra={"username": username}) t0 = time.perf_counter() try: @@ -521,11 +589,22 @@ def create_conversation(terminal: WebTerminal, workspace: UserWorkspace, usernam preserve_mode = bool(data.get('preserve_mode')) thinking_mode = data.get('thinking_mode') if preserve_mode and 'thinking_mode' in data else None run_mode = data.get('mode') if preserve_mode and 'mode' in data else None + target_workspace_id = (data.get('workspace_id') or '').strip() + + if target_workspace_id: + try: + terminal, workspace = _resolve_target_terminal_for_workspace( + username, target_workspace_id, terminal, workspace + ) + except ValueError as exc: + return jsonify({"success": False, "error": str(exc)}), 404 + except RuntimeError as exc: + return jsonify({"success": False, "error": str(exc)}), 503 # 多工作区并行后,新建/切换对话只是前端视图导航,不应停止同工作区正在运行的主任务。 # 同工作区单任务互斥由 /api/tasks 创建任务时兜底限制;输入框禁用由前端根据任务状态处理。 - workspace_id = session.get("workspace_id") or "default" - active_task = _get_active_workspace_task(username=username, workspace_id=workspace_id) + effective_workspace_id = target_workspace_id or session.get("workspace_id") or "default" + active_task = _get_active_workspace_task(username=username, workspace_id=effective_workspace_id) if active_task: # 运行中时只能创建“视图用”的新对话文件,不能调用 terminal.create_new_conversation。 # 后者会切换 context_manager.current_conversation_id,导致运行任务后续内容串写到新对话。 @@ -570,8 +649,10 @@ def create_conversation(terminal: WebTerminal, workspace: UserWorkspace, usernam result = terminal.create_new_conversation(thinking_mode=thinking_mode, run_mode=run_mode) if result["success"]: - session['run_mode'] = terminal.run_mode - session['thinking_mode'] = terminal.thinking_mode + # 仅在当前工作区创建时更新 session 模式;指定其他工作区时由前端切换后自动同步。 + if not target_workspace_id: + session['run_mode'] = terminal.run_mode + session['thinking_mode'] = terminal.thinking_mode perf_log("create_conversation before default versioning", elapsed_ms=(time.perf_counter() - t0) * 1000, extra={"conv_id": result.get("conversation_id")}) # 根据个性化设置,为新对话默认开启版本控制(安全导航路径也需要)。 try: @@ -651,9 +732,31 @@ def get_conversation_info(terminal: WebTerminal, workspace: UserWorkspace, usern @api_login_required @with_terminal def load_conversation(conversation_id, terminal: WebTerminal, workspace: UserWorkspace, username: str): - """加载特定对话""" + """加载特定对话,支持通过 workspace_id 指定目标工作区/项目。""" try: - workspace_id = session.get("workspace_id") or "default" + target_workspace_id = request.args.get('workspace_id', '', type=str).strip() + if target_workspace_id: + try: + terminal, workspace = _resolve_target_terminal_for_workspace( + username, target_workspace_id, terminal, workspace + ) + except ValueError as exc: + return jsonify({"success": False, "error": str(exc)}), 404 + except RuntimeError as exc: + return jsonify({"success": False, "error": str(exc)}), 503 + + cm = getattr(getattr(terminal, "context_manager", None), "conversation_manager", None) + write_host_workspace_debug( + "sidebar-debug-api", + api="PUT /api/conversations/load", + conversation_id=conversation_id, + target_workspace_id=target_workspace_id or None, + session_workspace_id=session.get("workspace_id"), + terminal_project_path=str(getattr(terminal, "project_path", None)), + cm_workspace_id=getattr(cm, "current_workspace_id", None), + cm_conversations_dir=str(getattr(cm, "conversations_dir", None)), + ) + workspace_id = target_workspace_id or session.get("workspace_id") or "default" active_task = _get_active_workspace_task(username=username, workspace_id=workspace_id) if active_task: # 同工作区有任务运行时,加载/查看其他对话不能改后端 terminal 当前上下文。 @@ -712,10 +815,23 @@ def load_conversation(conversation_id, terminal: WebTerminal, workspace: UserWor return jsonify(result) else: + write_host_workspace_debug( + "sidebar-debug-api", + api="PUT /api/conversations/load", + conversation_id=conversation_id, + session_workspace_id=session.get("workspace_id"), + terminal_project_path=str(getattr(terminal, "project_path", None)), + cm_workspace_id=getattr(cm, "current_workspace_id", None), + cm_conversations_dir=str(getattr(cm, "conversations_dir", None)), + result_message=result.get("message", ""), + not_found=True, + ) return jsonify(result), 404 if "不存在" in result.get("message", "") else 500 except Exception as e: + import traceback print(f"[API] 加载对话错误: {e}") + traceback.print_exc() return jsonify({ "success": False, "error": str(e), @@ -726,11 +842,22 @@ def load_conversation(conversation_id, terminal: WebTerminal, workspace: UserWor @api_login_required @with_terminal def delete_conversation(conversation_id, terminal: WebTerminal, workspace: UserWorkspace, username: str): - """删除特定对话""" + """删除特定对话,支持通过 workspace_id 指定目标工作区/项目。""" try: + target_workspace_id = request.args.get('workspace_id', '', type=str).strip() + if target_workspace_id: + try: + terminal, workspace = _resolve_target_terminal_for_workspace( + username, target_workspace_id, terminal, workspace + ) + except ValueError as exc: + return jsonify({"success": False, "error": str(exc)}), 404 + except RuntimeError as exc: + return jsonify({"success": False, "error": str(exc)}), 503 + # 检查是否是当前对话 is_current = (terminal.context_manager.current_conversation_id == conversation_id) - + result = terminal.delete_conversation(conversation_id) if result["success"]: diff --git a/server/status/file_open.py b/server/status/file_open.py index 30a5997..99fe050 100644 --- a/server/status/file_open.py +++ b/server/status/file_open.py @@ -325,7 +325,28 @@ def _open_file_with_app(file_path: Path, app_id: str) -> bool: @api_login_required @with_terminal def open_project_in_file_manager(terminal, workspace, username): - project_path = Path(getattr(workspace, "project_path", "") or "").expanduser().resolve() + data = request.get_json(silent=True) or {} + target_workspace_id = (data.get("workspace_id") or "").strip() + + if target_workspace_id: + if _is_host_mode_request(): + catalog, _ = resolve_host_workspace() + target = next( + (item for item in (catalog.get("workspaces") or []) + if item.get("workspace_id") == target_workspace_id), + None, + ) + if not target: + return jsonify({"success": False, "error": "工作区不存在"}), 404 + project_path = Path(target.get("path") or "").expanduser().resolve() + else: + if target_workspace_id not in user_manager.list_user_workspaces(username): + return jsonify({"success": False, "error": "项目不存在"}), 404 + ws_obj = user_manager.ensure_user_workspace(username, target_workspace_id) + project_path = Path(getattr(ws_obj, "project_path", "") or "").expanduser().resolve() + else: + project_path = Path(getattr(workspace, "project_path", "") or "").expanduser().resolve() + ok, root_text = _run_project_git(project_path, ["rev-parse", "--show-toplevel"]) target = Path(root_text).expanduser().resolve() if ok and root_text else project_path if _open_path_in_file_manager(target): diff --git a/static/src/App.vue b/static/src/App.vue index 42ca021..2321e2b 100644 --- a/static/src/App.vue +++ b/static/src/App.vue @@ -66,6 +66,11 @@ :current-workspace-id="currentHostWorkspaceId" :display-mode="chatDisplayMode" :display-mode-disabled="displayModeSwitchDisabled" + :workspaces="hostWorkspaces" + :workspace-kind="versioningHostMode ? 'workspace' : 'project'" + :host-workspace-enabled="versioningHostMode || dockerProjectMode" + :group-by-workspace="personalizationStore?.form?.group_sidebar_by_workspace" + :versioning-host-mode="versioningHostMode" @toggle="toggleSidebar" @create="createNewConversation" @search="handleSidebarSearchInput" @@ -79,6 +84,11 @@ @duplicate="duplicateConversation" @toggle-workspace="handleWorkspaceToggle" @toggle-display-mode="handleDisplayModeToggle" + @select-workspace-conversation="handleSelectWorkspaceConversation" + @create-workspace-conversation="createWorkspaceConversation" + @reveal-workspace="revealHostWorkspace" + @rename-workspace="handleRenameWorkspaceFromSidebar" + @pin-workspace="handlePinWorkspaceFromSidebar" />
@@ -752,6 +762,11 @@ :current-workspace-id="currentHostWorkspaceId" :display-mode="chatDisplayMode" :display-mode-disabled="displayModeSwitchDisabled" + :workspaces="hostWorkspaces" + :workspace-kind="versioningHostMode ? 'workspace' : 'project'" + :host-workspace-enabled="versioningHostMode || dockerProjectMode" + :group-by-workspace="personalizationStore?.form?.group_sidebar_by_workspace" + :versioning-host-mode="versioningHostMode" :show-collapse-button="true" collapse-button-variant="close" @toggle="closeMobileOverlay" @@ -766,6 +781,11 @@ @delete="deleteConversation" @duplicate="duplicateConversation" @toggle-display-mode="handleDisplayModeToggle" + @select-workspace-conversation="handleSelectWorkspaceConversation" + @create-workspace-conversation="createWorkspaceConversation" + @reveal-workspace="revealHostWorkspace" + @rename-workspace="handleRenameWorkspaceFromSidebar" + @pin-workspace="handlePinWorkspaceFromSidebar" />
@@ -846,6 +866,7 @@ import { defineAsyncComponent, onMounted, ref } from 'vue'; import appOptions from './app'; import VideoPicker from './components/overlay/VideoPicker.vue'; import { useTutorialStore } from './stores/tutorial'; +import { usePersonalizationStore } from './stores/personalization'; const VirtualMonitorSurface = defineAsyncComponent( () => import('./components/chat/VirtualMonitorSurface.vue') @@ -855,6 +876,7 @@ const PathAuthorizationDialog = defineAsyncComponent( ); const tutorialStore = useTutorialStore(); +const personalizationStore = usePersonalizationStore(); diff --git a/static/src/app/computed.ts b/static/src/app/computed.ts index 55fde2e..f5834e8 100644 --- a/static/src/app/computed.ts +++ b/static/src/app/computed.ts @@ -106,7 +106,8 @@ export const computed = { 'conversationsOffset', 'conversationsLimit', 'runningWorkspaceTasks', - 'acknowledgedCompletedTaskIds' + 'acknowledgedCompletedTaskIds', + 'workspaceGroups' ]), ...mapWritableState(useModelStore, ['currentModelKey']), ...mapState(useModelStore, ['models']), diff --git a/static/src/app/methods/conversation/action.ts b/static/src/app/methods/conversation/action.ts index 80cdbdf..5b58af8 100644 --- a/static/src/app/methods/conversation/action.ts +++ b/static/src/app/methods/conversation/action.ts @@ -161,6 +161,30 @@ export const actionMethods = { placeholder, ...this.conversations.filter((conv) => conv && conv.id !== newConversationId) ]; + + // 分组视图下同步在当前工作区分组顶部插入占位 + try { + const { useConversationStore } = await import('../../../stores/conversation'); + const conversationStore = useConversationStore(); + const currentWorkspaceId = this.currentHostWorkspaceId; + if (currentWorkspaceId) { + conversationStore.ensureWorkspaceGroup(currentWorkspaceId); + const group = conversationStore.workspaceGroups.find( + (g: any) => g.workspaceId === currentWorkspaceId + ); + if (group) { + group.conversations = [ + placeholder, + ...group.conversations.filter((conv: any) => conv.id !== newConversationId) + ]; + group.expanded = true; + group.visibleOffset = 0; + } + } + } catch (_err) { + // ignore + } + window.setTimeout(() => { const rest = { ...this.conversationInsertAnimations }; delete rest[newConversationId]; @@ -168,6 +192,11 @@ export const actionMethods = { this.conversationListAnimationMode = 'idle'; }, 700); + // 等待插入动画先完成一帧,避免立即加载对话导致列表闪烁/卡顿。 + // 工作区旁新建不切换对话,所以不会触发此处的状态风暴;顶部新建需要切对话, + // 但把 loadConversation 放到入场动画基本结束后再执行,动画就和工作区旁一致。 + await waitForAnimation(540); + // 直接加载新对话,确保状态一致 // 如果 socket 事件已把 currentConversationId 设置为新ID,则强制加载一次以同步状态 await this.loadConversation(newConversationId, { force: true }); @@ -176,9 +205,6 @@ export const actionMethods = { currentConversationId: this.currentConversationId }); - // 等待顶部插入动画完成,避免刷新列表时打断“整体下移 + 从左到右出现”。 - await waitForAnimation(540); - // 刷新对话列表获取最新统计 this.conversationsOffset = 0; await this.loadConversationsList(); @@ -187,6 +213,18 @@ export const actionMethods = { conversationsLen: Array.isArray(this.conversations) ? this.conversations.length : 'n/a' }); + // 分组视图下刷新当前工作区数据,用 refresh 模式避免清空已加载内容导致闪烁 + try { + const { useConversationStore } = await import('../../../stores/conversation'); + const conversationStore = useConversationStore(); + const currentWorkspaceId = this.currentHostWorkspaceId; + if (currentWorkspaceId) { + await conversationStore.loadWorkspaceConversations(currentWorkspaceId, { refresh: true }); + } + } catch (_err) { + // ignore + } + await this.refreshRunningWorkspaceTasks?.(); } else { console.error('创建对话失败:', result.message); @@ -210,7 +248,82 @@ export const actionMethods = { } } }, - async deleteConversation(conversationId) { + async createWorkspaceConversation(workspaceId: string) { + if (!workspaceId) return; + debugLog('在指定工作区创建新对话:', workspaceId); + try { + const response = await fetch('/api/conversations', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ workspace_id: workspaceId }) + }); + const result = await response.json(); + if (result.success) { + const newConversationId = result.conversation_id; + debugLog('工作区新对话创建成功:', newConversationId); + const placeholder = { + id: newConversationId, + title: '新对话', + updated_at: new Date().toISOString(), + total_messages: 0, + total_tools: 0 + }; + + // 统一播放“整体下移 + 新项从左侧滑入”的动画 + this.conversationInsertAnimations = { + ...this.conversationInsertAnimations, + [newConversationId]: 'create' + }; + this.conversationListAnimationMode = 'create'; + window.setTimeout(() => { + const rest = { ...this.conversationInsertAnimations }; + delete rest[newConversationId]; + this.conversationInsertAnimations = rest; + this.conversationListAnimationMode = 'idle'; + }, 700); + + // 立即在分组侧边栏插入占位,保证用户能立刻看到 + try { + const { useConversationStore } = await import('../../../stores/conversation'); + const conversationStore = useConversationStore(); + conversationStore.ensureWorkspaceGroup(workspaceId); + const group = conversationStore.workspaceGroups.find((g: any) => g.workspaceId === workspaceId); + if (group) { + group.conversations = [ + placeholder, + ...group.conversations.filter((conv: any) => conv.id !== newConversationId) + ]; + group.expanded = true; + group.visibleOffset = 0; + } + // 延迟刷新该工作区列表以获取真实数据,用 refresh 模式保留占位避免闪烁 + window.setTimeout(() => { + conversationStore.loadWorkspaceConversations(workspaceId, { refresh: true }).catch(() => {}); + }, 300); + } catch (_err) { + // ignore + } + } else { + console.error('创建对话失败:', result.message); + this.uiPushToast({ + title: '创建对话失败', + message: result.message || '服务器未返回成功状态', + type: 'error' + }); + } + } catch (error) { + console.error('创建工作区对话异常:', error); + this.uiPushToast({ + title: '创建工作区对话异常', + message: error.message || String(error), + type: 'error' + }); + } + }, + async deleteConversation(payload) { + const conversationId = typeof payload === 'string' ? payload : payload?.id; + const workspaceId = typeof payload === 'object' ? payload?.workspaceId : undefined; + if (!conversationId) return; const confirmed = await this.confirmAction({ title: '删除对话', message: '确定要删除这个对话吗?删除后无法恢复。', @@ -233,7 +346,10 @@ export const actionMethods = { this.logMessageState('deleteConversation:start', { conversationId }); try { - const response = await fetch(`/api/conversations/${conversationId}`, { + const deleteUrl = workspaceId + ? `/api/conversations/${conversationId}?workspace_id=${encodeURIComponent(workspaceId)}` + : `/api/conversations/${conversationId}`; + const response = await fetch(deleteUrl, { method: 'DELETE' }); @@ -264,6 +380,20 @@ export const actionMethods = { this.pendingDeletingConversationIds = [...deletingIds, conversationId]; } await this.$nextTick(); + // 分组侧边栏使用 transition-group 离场动画,立即从列表移除 + try { + const { useConversationStore } = await import('../../../stores/conversation'); + const conversationStore = useConversationStore(); + conversationStore.workspaceGroups.forEach((group: any) => { + group.conversations = group.conversations.filter((conv: any) => conv.id !== conversationId); + const maxVisibleOffset = Math.max(0, group.conversations.length - group.visibleLimit); + if (group.visibleOffset > maxVisibleOffset) { + group.visibleOffset = maxVisibleOffset; + } + }); + } catch (_err) { + // ignore + } await waitForAnimation(430); this.conversations = this.conversations.filter( @@ -365,6 +495,31 @@ export const actionMethods = { this.conversationListAnimationMode = 'idle'; }, 860); + // 同步插入分组侧边栏对应工作区 + try { + const { useConversationStore } = await import('../../../stores/conversation'); + const conversationStore = useConversationStore(); + const group = conversationStore.workspaceGroups.find((g: any) => + g.conversations.some((conv: any) => conv.id === conversationId) + ); + if (group) { + const sourceGroupIndex = group.conversations.findIndex((conv: any) => conv.id === conversationId); + const withoutDuplicate = group.conversations.filter((conv: any) => conv.id !== newId); + if (sourceGroupIndex >= 0) { + group.conversations = [ + ...withoutDuplicate.slice(0, sourceGroupIndex + 1), + duplicatePlaceholder, + ...withoutDuplicate.slice(sourceGroupIndex + 1) + ]; + } else { + group.conversations = [duplicatePlaceholder, ...withoutDuplicate]; + } + group.expanded = true; + } + } catch (_err) { + // ignore + } + // 先让“目标下方钻出 + 下方整体下移”动画播完,再加载复制出的对话。 await waitForAnimation(860); await this.loadConversation(newId, { force: true, preserveListPosition: true }); diff --git a/static/src/app/methods/conversation/load.ts b/static/src/app/methods/conversation/load.ts index 7736aaf..50c172f 100644 --- a/static/src/app/methods/conversation/load.ts +++ b/static/src/app/methods/conversation/load.ts @@ -76,6 +76,8 @@ export const loadMethods = { async loadConversation(conversationId, options = {}) { const force = Boolean(options.force); const preserveListPosition = Boolean(options.preserveListPosition); + const workspaceId = options.workspaceId ? String(options.workspaceId) : ''; + const isHostLikeMode = Boolean(this.versioningHostMode || this.dockerProjectMode); debugLog('加载对话:', conversationId); traceLog('loadConversation:start', { conversationId, @@ -151,8 +153,11 @@ export const loadMethods = { }).catch(() => {}); try { - // 1. 调用加载API - const response = await fetch(`/api/conversations/${conversationId}/load`, { + // 1. 调用加载API(多工作区模式下携带目标 workspace_id,避免并发/session 漂移导致加载错误工作区) + const loadUrl = workspaceId && isHostLikeMode + ? `/api/conversations/${conversationId}/load?workspace_id=${encodeURIComponent(workspaceId)}` + : `/api/conversations/${conversationId}/load`; + const response = await fetch(loadUrl, { method: 'PUT' }); const result = await response.json(); @@ -251,5 +256,68 @@ export const loadMethods = { type: 'error' }); } + }, + + async loadWorkspaceConversations(workspaceId: string, { reset = false } = {}) { + if (!workspaceId) return; + const { useConversationStore } = await import('../../../stores/conversation'); + const conversationStore = useConversationStore(); + let group = conversationStore.workspaceGroups.find((g: any) => g.workspaceId === workspaceId); + if (!group) { + group = { + workspaceId, + conversations: [], + loading: true, + hasMore: false, + loadingMore: false, + offset: 0, + limit: 20, + expanded: true + }; + conversationStore.workspaceGroups.push(group); + } + if (reset) { + group.conversations = []; + group.offset = 0; + group.hasMore = false; + } + if (group.loading || group.loadingMore) return; + group.loading = true; + try { + const response = await fetch(`/api/conversations?workspace_id=${encodeURIComponent(workspaceId)}&limit=${group.limit}&offset=${group.offset}`); + const data = await response.json(); + if (data.success) { + const items = (data.data?.conversations || []).map((conv: any) => ({ + id: conv.id, + title: conv.title, + updated_at: conv.updated_at, + total_messages: conv.total_messages, + total_tools: conv.total_tools + })); + if (group.offset === 0) { + group.conversations = items; + } else { + group.conversations.push(...items); + } + group.hasMore = !!data.data?.has_more; + } else { + console.error('加载工作区对话失败:', data.error); + } + } catch (error) { + console.error('加载工作区对话异常:', error); + } finally { + group.loading = false; + } + }, + + async loadMoreWorkspaceConversations(workspaceId: string) { + const { useConversationStore } = await import('../../../stores/conversation'); + const conversationStore = useConversationStore(); + const group = conversationStore.workspaceGroups.find((g: any) => g.workspaceId === workspaceId); + if (!group || group.loadingMore || !group.hasMore) return; + group.loadingMore = true; + group.offset += group.limit; + await this.loadWorkspaceConversations(workspaceId); + group.loadingMore = false; } }; diff --git a/static/src/app/methods/message/send.ts b/static/src/app/methods/message/send.ts index e8fdc76..c065805 100644 --- a/static/src/app/methods/message/send.ts +++ b/static/src/app/methods/message/send.ts @@ -227,16 +227,36 @@ export const sendMethods = { this.skipConversationHistoryReload = true; this.currentConversationId = targetConversationId; this.currentConversationTitle = '新对话'; - this.conversations = [ - { - id: targetConversationId, - title: '新对话', - updated_at: new Date().toISOString(), - total_messages: 0, - total_tools: 0 - }, - ...this.conversations.filter((conv) => conv && conv.id !== targetConversationId) - ]; + const newPlaceholder = { + id: targetConversationId, + title: '新对话', + updated_at: new Date().toISOString(), + total_messages: 0, + total_tools: 0 + }; + this.conversations = [newPlaceholder, ...this.conversations.filter((conv) => conv && conv.id !== targetConversationId)]; + + // 分组视图下同步到当前工作区 + try { + const { useConversationStore } = await import('../../../stores/conversation'); + const conversationStore = useConversationStore(); + const currentWorkspaceId = this.currentHostWorkspaceId; + if (currentWorkspaceId) { + conversationStore.ensureWorkspaceGroup(currentWorkspaceId); + const group = conversationStore.workspaceGroups.find( + (g: any) => g.workspaceId === currentWorkspaceId + ); + if (group) { + group.conversations = [newPlaceholder, ...group.conversations.filter((conv: any) => conv.id !== targetConversationId)]; + group.expanded = true; + group.visibleOffset = 0; + group.visibleLimit = 5; + } + } + } catch (_err) { + // ignore + } + const pathFragment = this.stripConversationPrefix(targetConversationId); history.replaceState({ conversationId: targetConversationId }, '', `/${pathFragment}`); } catch (error) { diff --git a/static/src/app/methods/ui/hostWorkspace.ts b/static/src/app/methods/ui/hostWorkspace.ts index 1081605..94e4e21 100644 --- a/static/src/app/methods/ui/hostWorkspace.ts +++ b/static/src/app/methods/ui/hostWorkspace.ts @@ -350,6 +350,57 @@ export const hostWorkspaceMethods = { this.hostWorkspaceManageSubmitting = false; } }, + async handleSelectWorkspaceConversation(payload: { conversationId: string; workspaceId: string }) { + const { conversationId, workspaceId } = payload || {}; + console.log('[sidebar-debug] handleSelectWorkspaceConversation', { + conversationId, + workspaceId, + currentHostWorkspaceId: this.currentHostWorkspaceId, + versioningHostMode: this.versioningHostMode, + dockerProjectMode: this.dockerProjectMode + }); + if (!conversationId || !workspaceId) return; + if ((this.versioningHostMode || this.dockerProjectMode) && workspaceId !== this.currentHostWorkspaceId) { + console.log('[sidebar-debug] switching workspace', { from: this.currentHostWorkspaceId, to: workspaceId }); + await this.handleHostWorkspaceSwitch(workspaceId); + } + console.log('[sidebar-debug] loading conversation', { conversationId, currentHostWorkspaceIdAfterSwitch: this.currentHostWorkspaceId }); + await this.loadConversation(conversationId, { force: true, workspaceId }); + }, + async handleRenameWorkspaceFromSidebar(payload: { workspaceId: string; label: string }) { + const workspaceId = String(payload?.workspace_id || payload?.workspaceId || '').trim(); + const label = String(payload?.label || '').trim(); + if (!workspaceId || !label) return; + await this.renameHostWorkspace({ workspace_id: workspaceId, label }); + }, + async handlePinWorkspaceFromSidebar(_workspaceId: string) { + // 置顶状态由侧边栏组件本地维护并持久化到 localStorage,此处无需额外操作。 + }, + async revealHostWorkspace(workspaceId: string) { + if (!(this.versioningHostMode || this.dockerProjectMode)) { + return; + } + const targetId = String(workspaceId || '').trim(); + if (!targetId) return; + try { + const resp = await fetch('/api/project/open-in-file-manager', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ workspace_id: targetId }) + }); + const result = await resp.json().catch(() => ({})); + if (!resp.ok || !result?.success) { + throw new Error(result?.error || '打开失败'); + } + } catch (error) { + const message = error instanceof Error ? error.message : String(error || '打开失败'); + this.uiPushToast({ + title: this.versioningHostMode ? '打开工作区失败' : '打开项目失败', + message, + type: 'error' + }); + } + }, async setDefaultHostWorkspace(payload = {}) { if (!(this.versioningHostMode || this.dockerProjectMode)) { return; diff --git a/static/src/components/personalization/PersonalizationDrawer.vue b/static/src/components/personalization/PersonalizationDrawer.vue index 67afeab..c6ec2e5 100644 --- a/static/src/components/personalization/PersonalizationDrawer.vue +++ b/static/src/components/personalization/PersonalizationDrawer.vue @@ -611,6 +611,28 @@ class="fancy-path" > +