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