feat(sidebar): 侧边栏按工作区/项目分组显示对话
- 新增个人化设置 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
This commit is contained in:
parent
6b431ed51a
commit
5244768494
@ -34,7 +34,7 @@ def _parse_paths(raw_value: str):
|
|||||||
|
|
||||||
|
|
||||||
_env_prefix = "TERMINAL_SANDBOX_ENV_"
|
_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_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_MOUNT_PATH = os.environ.get("TERMINAL_SANDBOX_MOUNT_PATH", "/workspace")
|
||||||
TERMINAL_SANDBOX_SHELL = os.environ.get("TERMINAL_SANDBOX_SHELL", "/bin/bash")
|
TERMINAL_SANDBOX_SHELL = os.environ.get("TERMINAL_SANDBOX_SHELL", "/bin/bash")
|
||||||
|
|||||||
@ -103,6 +103,9 @@ DEFAULT_PERSONALIZATION_CONFIG: Dict[str, Any] = {
|
|||||||
"agents_md_auto_inject": False, # AGENTS.md 自动注入开关
|
"agents_md_auto_inject": False, # AGENTS.md 自动注入开关
|
||||||
"allow_root_file_creation": False, # 允许在根目录创建文件开关
|
"allow_root_file_creation": False, # 允许在根目录创建文件开关
|
||||||
"default_hide_workspace": False, # 默认隐藏工作区
|
"default_hide_workspace": False, # 默认隐藏工作区
|
||||||
|
"group_sidebar_by_workspace": False, # 侧边栏按工作区/项目分组显示对话
|
||||||
|
"sidebar_pinned_workspaces": [], # 分组侧边栏中永久置顶的工作区ID列表
|
||||||
|
"sidebar_workspace_order": [], # 分组侧边栏中非置顶工作区的显示顺序
|
||||||
"theme": "classic", # 主题配色: classic-经典/light-明亮/dark-暗黑
|
"theme": "classic", # 主题配色: classic-经典/light-明亮/dark-暗黑
|
||||||
# 目标模式(Goal Mode)
|
# 目标模式(Goal Mode)
|
||||||
"goal_review_mode": "readonly", # readonly-仅读对话判断 / active-允许审核智能体跑只读命令取证
|
"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)
|
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(
|
def sanitize_personalization_payload(
|
||||||
payload: Optional[Dict[str, Any]],
|
payload: Optional[Dict[str, Any]],
|
||||||
fallback: Optional[Dict[str, Any]] = None
|
fallback: Optional[Dict[str, Any]] = None
|
||||||
@ -485,6 +507,32 @@ def sanitize_personalization_payload(
|
|||||||
else:
|
else:
|
||||||
base["default_hide_workspace"] = bool(base.get("default_hide_workspace", False))
|
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"))
|
theme_value = data.get("theme", base.get("theme"))
|
||||||
if isinstance(theme_value, str) and theme_value in ALLOWED_THEMES:
|
if isinstance(theme_value, str) and theme_value in ALLOWED_THEMES:
|
||||||
|
|||||||
@ -63,7 +63,7 @@ def _set_terminal_workspace_label(terminal: WebTerminal, label: Optional[str]) -
|
|||||||
pass
|
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."""
|
"""Apply persisted workspace personalization after policy/workspace resolution."""
|
||||||
try:
|
try:
|
||||||
config = load_personalization_config(workspace.data_dir)
|
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
|
terminal._workspace_default_model_applied = True
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
if has_request_context():
|
if has_request_context() and update_session:
|
||||||
session["run_mode"] = getattr(terminal, "run_mode", session.get("run_mode"))
|
session["run_mode"] = getattr(terminal, "run_mode", session.get("run_mode"))
|
||||||
session["thinking_mode"] = getattr(terminal, "thinking_mode", session.get("thinking_mode"))
|
session["thinking_mode"] = getattr(terminal, "thinking_mode", session.get("thinking_mode"))
|
||||||
session["model_key"] = getattr(terminal, "model_key", session.get("model_key"))
|
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}")
|
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
|
from modules.user_manager import UserWorkspace
|
||||||
username = (username or get_current_username())
|
username = (username or get_current_username())
|
||||||
if not username:
|
if not username:
|
||||||
@ -277,7 +281,7 @@ def get_user_resources(username: Optional[str] = None, workspace_id: Optional[st
|
|||||||
terminal.username = "host"
|
terminal.username = "host"
|
||||||
terminal.user_role = "admin"
|
terminal.user_role = "admin"
|
||||||
terminal.quota_update_callback = None
|
terminal.quota_update_callback = None
|
||||||
if has_request_context():
|
if has_request_context() and update_session:
|
||||||
session['run_mode'] = terminal.run_mode
|
session['run_mode'] = terminal.run_mode
|
||||||
session['thinking_mode'] = terminal.thinking_mode
|
session['thinking_mode'] = terminal.thinking_mode
|
||||||
session['workspace_id'] = getattr(workspace, "workspace_id", None)
|
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")
|
attach_user_broadcast(terminal, "host")
|
||||||
terminal.username = "host"
|
terminal.username = "host"
|
||||||
terminal.user_role = "admin"
|
terminal.user_role = "admin"
|
||||||
if has_request_context():
|
if has_request_context() and update_session:
|
||||||
session['workspace_id'] = getattr(workspace, "workspace_id", None)
|
session['workspace_id'] = getattr(workspace, "workspace_id", None)
|
||||||
session['host_workspace_id'] = getattr(workspace, "workspace_id", None)
|
session['host_workspace_id'] = getattr(workspace, "workspace_id", None)
|
||||||
_set_terminal_workspace_label(
|
_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:
|
if candidate not in disabled_models:
|
||||||
try:
|
try:
|
||||||
terminal.set_model(candidate)
|
terminal.set_model(candidate)
|
||||||
if has_request_context():
|
if has_request_context() and update_session:
|
||||||
session["model_key"] = terminal.model_key
|
session["model_key"] = terminal.model_key
|
||||||
break
|
break
|
||||||
except Exception:
|
except Exception:
|
||||||
@ -410,7 +414,7 @@ def get_user_resources(username: Optional[str] = None, workspace_id: Optional[st
|
|||||||
terminal.username = username
|
terminal.username = username
|
||||||
terminal.user_role = "api" if is_api_user else get_current_user_role(record)
|
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
|
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['run_mode'] = terminal.run_mode
|
||||||
session['thinking_mode'] = terminal.thinking_mode
|
session['thinking_mode'] = terminal.thinking_mode
|
||||||
session['model_key'] = getattr(terminal, "model_key", None)
|
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.username = username
|
||||||
terminal.user_role = "api" if is_api_user else get_current_user_role(record)
|
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
|
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)
|
session['workspace_id'] = getattr(workspace, "workspace_id", None)
|
||||||
|
|
||||||
if is_api_user:
|
if is_api_user:
|
||||||
@ -466,6 +470,7 @@ def get_user_resources(username: Optional[str] = None, workspace_id: Optional[st
|
|||||||
if candidate not in disabled_models:
|
if candidate not in disabled_models:
|
||||||
try:
|
try:
|
||||||
terminal.set_model(candidate)
|
terminal.set_model(candidate)
|
||||||
|
if update_session:
|
||||||
session["model_key"] = terminal.model_key
|
session["model_key"] = terminal.model_key
|
||||||
break
|
break
|
||||||
except Exception:
|
except Exception:
|
||||||
@ -473,7 +478,7 @@ def get_user_resources(username: Optional[str] = None, workspace_id: Optional[st
|
|||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
debug_log(f"[admin_policy] 应用失败: {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)
|
_ensure_workspace_skills_synced(terminal, workspace)
|
||||||
return terminal, workspace
|
return terminal, workspace
|
||||||
|
|
||||||
|
|||||||
@ -28,6 +28,7 @@ from config import (
|
|||||||
DEFAULT_CONVERSATIONS_LIMIT,
|
DEFAULT_CONVERSATIONS_LIMIT,
|
||||||
MAX_CONVERSATIONS_LIMIT,
|
MAX_CONVERSATIONS_LIMIT,
|
||||||
CONVERSATIONS_DIR,
|
CONVERSATIONS_DIR,
|
||||||
|
DATA_DIR,
|
||||||
DEFAULT_RESPONSE_MAX_TOKENS,
|
DEFAULT_RESPONSE_MAX_TOKENS,
|
||||||
DEFAULT_PROJECT_PATH,
|
DEFAULT_PROJECT_PATH,
|
||||||
LOGS_DIR,
|
LOGS_DIR,
|
||||||
@ -49,6 +50,8 @@ from modules.usage_tracker import QUOTA_DEFAULTS
|
|||||||
from modules.sub_agent import TERMINAL_STATUSES
|
from modules.sub_agent import TERMINAL_STATUSES
|
||||||
from modules.versioning_manager import ConversationVersioningManager, VersioningError
|
from modules.versioning_manager import ConversationVersioningManager, VersioningError
|
||||||
from modules.shallow_versioning import ShallowVersioningManager
|
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 utils.perf_log import perf_log, PerfTimer
|
||||||
from core.web_terminal import WebTerminal
|
from core.web_terminal import WebTerminal
|
||||||
from utils.tool_result_formatter import format_tool_result_for_context
|
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
|
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 拆分) ===
|
# === 背景生成对话标题(从 app_legacy 拆分) ===
|
||||||
@conversation_bp.route('/api/conversations', methods=['GET'])
|
@conversation_bp.route('/api/conversations', methods=['GET'])
|
||||||
@api_login_required
|
@api_login_required
|
||||||
@with_terminal
|
@with_terminal
|
||||||
def get_conversations(terminal: WebTerminal, workspace: UserWorkspace, username: str):
|
def get_conversations(terminal: WebTerminal, workspace: UserWorkspace, username: str):
|
||||||
"""获取对话列表"""
|
"""获取对话列表,支持通过 workspace_id 查询指定工作区/项目。"""
|
||||||
try:
|
try:
|
||||||
# 获取查询参数
|
# 获取查询参数
|
||||||
limit = request.args.get('limit', 20, type=int)
|
limit = request.args.get('limit', 20, type=int)
|
||||||
offset = request.args.get('offset', 0, type=int)
|
offset = request.args.get('offset', 0, type=int)
|
||||||
non_empty = request.args.get('non_empty', '0') in ('1', 'true', 'True')
|
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之间
|
limit = max(1, min(limit, 10000)) # 限制在1-10000之间
|
||||||
offset = max(0, offset)
|
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)
|
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"]:
|
if result["success"]:
|
||||||
return jsonify({
|
return jsonify({
|
||||||
@ -511,7 +579,7 @@ def get_conversations(terminal: WebTerminal, workspace: UserWorkspace, username:
|
|||||||
@api_login_required
|
@api_login_required
|
||||||
@with_terminal
|
@with_terminal
|
||||||
def create_conversation(terminal: WebTerminal, workspace: UserWorkspace, username: str):
|
def create_conversation(terminal: WebTerminal, workspace: UserWorkspace, username: str):
|
||||||
"""创建新对话"""
|
"""创建新对话,支持通过 workspace_id 在指定工作区/项目中创建。"""
|
||||||
perf_log("create_conversation route enter", extra={"username": username})
|
perf_log("create_conversation route enter", extra={"username": username})
|
||||||
t0 = time.perf_counter()
|
t0 = time.perf_counter()
|
||||||
try:
|
try:
|
||||||
@ -521,11 +589,22 @@ def create_conversation(terminal: WebTerminal, workspace: UserWorkspace, usernam
|
|||||||
preserve_mode = bool(data.get('preserve_mode'))
|
preserve_mode = bool(data.get('preserve_mode'))
|
||||||
thinking_mode = data.get('thinking_mode') if preserve_mode and 'thinking_mode' in data else None
|
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
|
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 创建任务时兜底限制;输入框禁用由前端根据任务状态处理。
|
# 同工作区单任务互斥由 /api/tasks 创建任务时兜底限制;输入框禁用由前端根据任务状态处理。
|
||||||
workspace_id = session.get("workspace_id") or "default"
|
effective_workspace_id = target_workspace_id or session.get("workspace_id") or "default"
|
||||||
active_task = _get_active_workspace_task(username=username, workspace_id=workspace_id)
|
active_task = _get_active_workspace_task(username=username, workspace_id=effective_workspace_id)
|
||||||
if active_task:
|
if active_task:
|
||||||
# 运行中时只能创建“视图用”的新对话文件,不能调用 terminal.create_new_conversation。
|
# 运行中时只能创建“视图用”的新对话文件,不能调用 terminal.create_new_conversation。
|
||||||
# 后者会切换 context_manager.current_conversation_id,导致运行任务后续内容串写到新对话。
|
# 后者会切换 context_manager.current_conversation_id,导致运行任务后续内容串写到新对话。
|
||||||
@ -570,6 +649,8 @@ def create_conversation(terminal: WebTerminal, workspace: UserWorkspace, usernam
|
|||||||
result = terminal.create_new_conversation(thinking_mode=thinking_mode, run_mode=run_mode)
|
result = terminal.create_new_conversation(thinking_mode=thinking_mode, run_mode=run_mode)
|
||||||
|
|
||||||
if result["success"]:
|
if result["success"]:
|
||||||
|
# 仅在当前工作区创建时更新 session 模式;指定其他工作区时由前端切换后自动同步。
|
||||||
|
if not target_workspace_id:
|
||||||
session['run_mode'] = terminal.run_mode
|
session['run_mode'] = terminal.run_mode
|
||||||
session['thinking_mode'] = terminal.thinking_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")})
|
perf_log("create_conversation before default versioning", elapsed_ms=(time.perf_counter() - t0) * 1000, extra={"conv_id": result.get("conversation_id")})
|
||||||
@ -651,9 +732,31 @@ def get_conversation_info(terminal: WebTerminal, workspace: UserWorkspace, usern
|
|||||||
@api_login_required
|
@api_login_required
|
||||||
@with_terminal
|
@with_terminal
|
||||||
def load_conversation(conversation_id, terminal: WebTerminal, workspace: UserWorkspace, username: str):
|
def load_conversation(conversation_id, terminal: WebTerminal, workspace: UserWorkspace, username: str):
|
||||||
"""加载特定对话"""
|
"""加载特定对话,支持通过 workspace_id 指定目标工作区/项目。"""
|
||||||
try:
|
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)
|
active_task = _get_active_workspace_task(username=username, workspace_id=workspace_id)
|
||||||
if active_task:
|
if active_task:
|
||||||
# 同工作区有任务运行时,加载/查看其他对话不能改后端 terminal 当前上下文。
|
# 同工作区有任务运行时,加载/查看其他对话不能改后端 terminal 当前上下文。
|
||||||
@ -712,10 +815,23 @@ def load_conversation(conversation_id, terminal: WebTerminal, workspace: UserWor
|
|||||||
|
|
||||||
return jsonify(result)
|
return jsonify(result)
|
||||||
else:
|
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
|
return jsonify(result), 404 if "不存在" in result.get("message", "") else 500
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
|
import traceback
|
||||||
print(f"[API] 加载对话错误: {e}")
|
print(f"[API] 加载对话错误: {e}")
|
||||||
|
traceback.print_exc()
|
||||||
return jsonify({
|
return jsonify({
|
||||||
"success": False,
|
"success": False,
|
||||||
"error": str(e),
|
"error": str(e),
|
||||||
@ -726,8 +842,19 @@ def load_conversation(conversation_id, terminal: WebTerminal, workspace: UserWor
|
|||||||
@api_login_required
|
@api_login_required
|
||||||
@with_terminal
|
@with_terminal
|
||||||
def delete_conversation(conversation_id, terminal: WebTerminal, workspace: UserWorkspace, username: str):
|
def delete_conversation(conversation_id, terminal: WebTerminal, workspace: UserWorkspace, username: str):
|
||||||
"""删除特定对话"""
|
"""删除特定对话,支持通过 workspace_id 指定目标工作区/项目。"""
|
||||||
try:
|
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)
|
is_current = (terminal.context_manager.current_conversation_id == conversation_id)
|
||||||
|
|
||||||
|
|||||||
@ -325,7 +325,28 @@ def _open_file_with_app(file_path: Path, app_id: str) -> bool:
|
|||||||
@api_login_required
|
@api_login_required
|
||||||
@with_terminal
|
@with_terminal
|
||||||
def open_project_in_file_manager(terminal, workspace, username):
|
def open_project_in_file_manager(terminal, workspace, username):
|
||||||
|
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()
|
project_path = Path(getattr(workspace, "project_path", "") or "").expanduser().resolve()
|
||||||
|
|
||||||
ok, root_text = _run_project_git(project_path, ["rev-parse", "--show-toplevel"])
|
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
|
target = Path(root_text).expanduser().resolve() if ok and root_text else project_path
|
||||||
if _open_path_in_file_manager(target):
|
if _open_path_in_file_manager(target):
|
||||||
|
|||||||
@ -66,6 +66,11 @@
|
|||||||
:current-workspace-id="currentHostWorkspaceId"
|
:current-workspace-id="currentHostWorkspaceId"
|
||||||
:display-mode="chatDisplayMode"
|
:display-mode="chatDisplayMode"
|
||||||
:display-mode-disabled="displayModeSwitchDisabled"
|
: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"
|
@toggle="toggleSidebar"
|
||||||
@create="createNewConversation"
|
@create="createNewConversation"
|
||||||
@search="handleSidebarSearchInput"
|
@search="handleSidebarSearchInput"
|
||||||
@ -79,6 +84,11 @@
|
|||||||
@duplicate="duplicateConversation"
|
@duplicate="duplicateConversation"
|
||||||
@toggle-workspace="handleWorkspaceToggle"
|
@toggle-workspace="handleWorkspaceToggle"
|
||||||
@toggle-display-mode="handleDisplayModeToggle"
|
@toggle-display-mode="handleDisplayModeToggle"
|
||||||
|
@select-workspace-conversation="handleSelectWorkspaceConversation"
|
||||||
|
@create-workspace-conversation="createWorkspaceConversation"
|
||||||
|
@reveal-workspace="revealHostWorkspace"
|
||||||
|
@rename-workspace="handleRenameWorkspaceFromSidebar"
|
||||||
|
@pin-workspace="handlePinWorkspaceFromSidebar"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<div v-if="!isMobileViewport" class="workspace-region">
|
<div v-if="!isMobileViewport" class="workspace-region">
|
||||||
@ -752,6 +762,11 @@
|
|||||||
:current-workspace-id="currentHostWorkspaceId"
|
:current-workspace-id="currentHostWorkspaceId"
|
||||||
:display-mode="chatDisplayMode"
|
:display-mode="chatDisplayMode"
|
||||||
:display-mode-disabled="displayModeSwitchDisabled"
|
: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"
|
:show-collapse-button="true"
|
||||||
collapse-button-variant="close"
|
collapse-button-variant="close"
|
||||||
@toggle="closeMobileOverlay"
|
@toggle="closeMobileOverlay"
|
||||||
@ -766,6 +781,11 @@
|
|||||||
@delete="deleteConversation"
|
@delete="deleteConversation"
|
||||||
@duplicate="duplicateConversation"
|
@duplicate="duplicateConversation"
|
||||||
@toggle-display-mode="handleDisplayModeToggle"
|
@toggle-display-mode="handleDisplayModeToggle"
|
||||||
|
@select-workspace-conversation="handleSelectWorkspaceConversation"
|
||||||
|
@create-workspace-conversation="createWorkspaceConversation"
|
||||||
|
@reveal-workspace="revealHostWorkspace"
|
||||||
|
@rename-workspace="handleRenameWorkspaceFromSidebar"
|
||||||
|
@pin-workspace="handlePinWorkspaceFromSidebar"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -846,6 +866,7 @@ import { defineAsyncComponent, onMounted, ref } from 'vue';
|
|||||||
import appOptions from './app';
|
import appOptions from './app';
|
||||||
import VideoPicker from './components/overlay/VideoPicker.vue';
|
import VideoPicker from './components/overlay/VideoPicker.vue';
|
||||||
import { useTutorialStore } from './stores/tutorial';
|
import { useTutorialStore } from './stores/tutorial';
|
||||||
|
import { usePersonalizationStore } from './stores/personalization';
|
||||||
|
|
||||||
const VirtualMonitorSurface = defineAsyncComponent(
|
const VirtualMonitorSurface = defineAsyncComponent(
|
||||||
() => import('./components/chat/VirtualMonitorSurface.vue')
|
() => import('./components/chat/VirtualMonitorSurface.vue')
|
||||||
@ -855,6 +876,7 @@ const PathAuthorizationDialog = defineAsyncComponent(
|
|||||||
);
|
);
|
||||||
|
|
||||||
const tutorialStore = useTutorialStore();
|
const tutorialStore = useTutorialStore();
|
||||||
|
const personalizationStore = usePersonalizationStore();
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@ -106,7 +106,8 @@ export const computed = {
|
|||||||
'conversationsOffset',
|
'conversationsOffset',
|
||||||
'conversationsLimit',
|
'conversationsLimit',
|
||||||
'runningWorkspaceTasks',
|
'runningWorkspaceTasks',
|
||||||
'acknowledgedCompletedTaskIds'
|
'acknowledgedCompletedTaskIds',
|
||||||
|
'workspaceGroups'
|
||||||
]),
|
]),
|
||||||
...mapWritableState(useModelStore, ['currentModelKey']),
|
...mapWritableState(useModelStore, ['currentModelKey']),
|
||||||
...mapState(useModelStore, ['models']),
|
...mapState(useModelStore, ['models']),
|
||||||
|
|||||||
@ -161,6 +161,30 @@ export const actionMethods = {
|
|||||||
placeholder,
|
placeholder,
|
||||||
...this.conversations.filter((conv) => conv && conv.id !== newConversationId)
|
...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(() => {
|
window.setTimeout(() => {
|
||||||
const rest = { ...this.conversationInsertAnimations };
|
const rest = { ...this.conversationInsertAnimations };
|
||||||
delete rest[newConversationId];
|
delete rest[newConversationId];
|
||||||
@ -168,6 +192,11 @@ export const actionMethods = {
|
|||||||
this.conversationListAnimationMode = 'idle';
|
this.conversationListAnimationMode = 'idle';
|
||||||
}, 700);
|
}, 700);
|
||||||
|
|
||||||
|
// 等待插入动画先完成一帧,避免立即加载对话导致列表闪烁/卡顿。
|
||||||
|
// 工作区旁新建不切换对话,所以不会触发此处的状态风暴;顶部新建需要切对话,
|
||||||
|
// 但把 loadConversation 放到入场动画基本结束后再执行,动画就和工作区旁一致。
|
||||||
|
await waitForAnimation(540);
|
||||||
|
|
||||||
// 直接加载新对话,确保状态一致
|
// 直接加载新对话,确保状态一致
|
||||||
// 如果 socket 事件已把 currentConversationId 设置为新ID,则强制加载一次以同步状态
|
// 如果 socket 事件已把 currentConversationId 设置为新ID,则强制加载一次以同步状态
|
||||||
await this.loadConversation(newConversationId, { force: true });
|
await this.loadConversation(newConversationId, { force: true });
|
||||||
@ -176,9 +205,6 @@ export const actionMethods = {
|
|||||||
currentConversationId: this.currentConversationId
|
currentConversationId: this.currentConversationId
|
||||||
});
|
});
|
||||||
|
|
||||||
// 等待顶部插入动画完成,避免刷新列表时打断“整体下移 + 从左到右出现”。
|
|
||||||
await waitForAnimation(540);
|
|
||||||
|
|
||||||
// 刷新对话列表获取最新统计
|
// 刷新对话列表获取最新统计
|
||||||
this.conversationsOffset = 0;
|
this.conversationsOffset = 0;
|
||||||
await this.loadConversationsList();
|
await this.loadConversationsList();
|
||||||
@ -187,6 +213,18 @@ export const actionMethods = {
|
|||||||
conversationsLen: Array.isArray(this.conversations) ? this.conversations.length : 'n/a'
|
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?.();
|
await this.refreshRunningWorkspaceTasks?.();
|
||||||
} else {
|
} else {
|
||||||
console.error('创建对话失败:', result.message);
|
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({
|
const confirmed = await this.confirmAction({
|
||||||
title: '删除对话',
|
title: '删除对话',
|
||||||
message: '确定要删除这个对话吗?删除后无法恢复。',
|
message: '确定要删除这个对话吗?删除后无法恢复。',
|
||||||
@ -233,7 +346,10 @@ export const actionMethods = {
|
|||||||
this.logMessageState('deleteConversation:start', { conversationId });
|
this.logMessageState('deleteConversation:start', { conversationId });
|
||||||
|
|
||||||
try {
|
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'
|
method: 'DELETE'
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -264,6 +380,20 @@ export const actionMethods = {
|
|||||||
this.pendingDeletingConversationIds = [...deletingIds, conversationId];
|
this.pendingDeletingConversationIds = [...deletingIds, conversationId];
|
||||||
}
|
}
|
||||||
await this.$nextTick();
|
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);
|
await waitForAnimation(430);
|
||||||
|
|
||||||
this.conversations = this.conversations.filter(
|
this.conversations = this.conversations.filter(
|
||||||
@ -365,6 +495,31 @@ export const actionMethods = {
|
|||||||
this.conversationListAnimationMode = 'idle';
|
this.conversationListAnimationMode = 'idle';
|
||||||
}, 860);
|
}, 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 waitForAnimation(860);
|
||||||
await this.loadConversation(newId, { force: true, preserveListPosition: true });
|
await this.loadConversation(newId, { force: true, preserveListPosition: true });
|
||||||
|
|||||||
@ -76,6 +76,8 @@ export const loadMethods = {
|
|||||||
async loadConversation(conversationId, options = {}) {
|
async loadConversation(conversationId, options = {}) {
|
||||||
const force = Boolean(options.force);
|
const force = Boolean(options.force);
|
||||||
const preserveListPosition = Boolean(options.preserveListPosition);
|
const preserveListPosition = Boolean(options.preserveListPosition);
|
||||||
|
const workspaceId = options.workspaceId ? String(options.workspaceId) : '';
|
||||||
|
const isHostLikeMode = Boolean(this.versioningHostMode || this.dockerProjectMode);
|
||||||
debugLog('加载对话:', conversationId);
|
debugLog('加载对话:', conversationId);
|
||||||
traceLog('loadConversation:start', {
|
traceLog('loadConversation:start', {
|
||||||
conversationId,
|
conversationId,
|
||||||
@ -151,8 +153,11 @@ export const loadMethods = {
|
|||||||
}).catch(() => {});
|
}).catch(() => {});
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// 1. 调用加载API
|
// 1. 调用加载API(多工作区模式下携带目标 workspace_id,避免并发/session 漂移导致加载错误工作区)
|
||||||
const response = await fetch(`/api/conversations/${conversationId}/load`, {
|
const loadUrl = workspaceId && isHostLikeMode
|
||||||
|
? `/api/conversations/${conversationId}/load?workspace_id=${encodeURIComponent(workspaceId)}`
|
||||||
|
: `/api/conversations/${conversationId}/load`;
|
||||||
|
const response = await fetch(loadUrl, {
|
||||||
method: 'PUT'
|
method: 'PUT'
|
||||||
});
|
});
|
||||||
const result = await response.json();
|
const result = await response.json();
|
||||||
@ -251,5 +256,68 @@ export const loadMethods = {
|
|||||||
type: 'error'
|
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;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|||||||
@ -227,16 +227,36 @@ export const sendMethods = {
|
|||||||
this.skipConversationHistoryReload = true;
|
this.skipConversationHistoryReload = true;
|
||||||
this.currentConversationId = targetConversationId;
|
this.currentConversationId = targetConversationId;
|
||||||
this.currentConversationTitle = '新对话';
|
this.currentConversationTitle = '新对话';
|
||||||
this.conversations = [
|
const newPlaceholder = {
|
||||||
{
|
|
||||||
id: targetConversationId,
|
id: targetConversationId,
|
||||||
title: '新对话',
|
title: '新对话',
|
||||||
updated_at: new Date().toISOString(),
|
updated_at: new Date().toISOString(),
|
||||||
total_messages: 0,
|
total_messages: 0,
|
||||||
total_tools: 0
|
total_tools: 0
|
||||||
},
|
};
|
||||||
...this.conversations.filter((conv) => conv && conv.id !== targetConversationId)
|
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);
|
const pathFragment = this.stripConversationPrefix(targetConversationId);
|
||||||
history.replaceState({ conversationId: targetConversationId }, '', `/${pathFragment}`);
|
history.replaceState({ conversationId: targetConversationId }, '', `/${pathFragment}`);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|||||||
@ -350,6 +350,57 @@ export const hostWorkspaceMethods = {
|
|||||||
this.hostWorkspaceManageSubmitting = false;
|
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 = {}) {
|
async setDefaultHostWorkspace(payload = {}) {
|
||||||
if (!(this.versioningHostMode || this.dockerProjectMode)) {
|
if (!(this.versioningHostMode || this.dockerProjectMode)) {
|
||||||
return;
|
return;
|
||||||
|
|||||||
@ -611,6 +611,28 @@
|
|||||||
class="fancy-path"
|
class="fancy-path"
|
||||||
></path></svg></span
|
></path></svg></span
|
||||||
></label>
|
></label>
|
||||||
|
<label class="settings-toggle-row"
|
||||||
|
><span class="settings-row-copy"
|
||||||
|
><span class="settings-row-title">按项目分组对话</span
|
||||||
|
><span class="settings-row-desc"
|
||||||
|
>侧边栏对话记录按工作区/项目折叠展示</span
|
||||||
|
></span
|
||||||
|
><input
|
||||||
|
type="checkbox"
|
||||||
|
:checked="form.group_sidebar_by_workspace"
|
||||||
|
@change="
|
||||||
|
personalization.updateField({
|
||||||
|
key: 'group_sidebar_by_workspace',
|
||||||
|
value: $event.target.checked
|
||||||
|
})
|
||||||
|
" /><span class="fancy-check" aria-hidden="true"
|
||||||
|
><svg viewBox="0 0 64 64">
|
||||||
|
<path
|
||||||
|
d="M 0 16 V 56 A 8 8 90 0 0 8 64 H 56 A 8 8 90 0 0 64 56 V 8 A 8 8 90 0 0 56 0 H 8 A 8 8 90 0 0 0 8 V 16 L 32 48 L 64 16 V 8 A 8 8 90 0 0 56 0 H 8 A 8 8 90 0 0 0 8 V 56 A 8 8 90 0 0 8 64 H 56 A 8 8 90 0 0 64 56 V 16"
|
||||||
|
pathLength="575.0541381835938"
|
||||||
|
class="fancy-path"
|
||||||
|
></path></svg></span
|
||||||
|
></label>
|
||||||
<label class="settings-toggle-row"
|
<label class="settings-toggle-row"
|
||||||
><span class="settings-row-copy"
|
><span class="settings-row-copy"
|
||||||
><span class="settings-row-title">使用自定义称呼</span
|
><span class="settings-row-title">使用自定义称呼</span
|
||||||
|
|||||||
@ -104,7 +104,7 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="conversation-list">
|
<div class="conversation-list">
|
||||||
<div v-if="runningTaskItems.length && !searchActive" class="running-task-section">
|
<div v-if="runningTaskItems.length && !searchActive && !isGroupByWorkspaceActive" class="running-task-section">
|
||||||
<div class="running-task-section-title">运行中的任务</div>
|
<div class="running-task-section-title">运行中的任务</div>
|
||||||
<button
|
<button
|
||||||
v-for="task in runningTaskItems"
|
v-for="task in runningTaskItems"
|
||||||
@ -128,6 +128,218 @@
|
|||||||
</span>
|
</span>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
<template v-if="isGroupByWorkspaceActive && !searchActive">
|
||||||
|
<div class="workspace-groups">
|
||||||
|
<div
|
||||||
|
v-for="ws in sortedWorkspaces"
|
||||||
|
:key="String(ws.workspace_id || ws.label)"
|
||||||
|
class="workspace-group"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
class="workspace-group-header"
|
||||||
|
:class="{ active: String(ws.workspace_id || '') === currentWorkspaceId }"
|
||||||
|
@mouseenter="hoverWorkspaceId = String(ws.workspace_id || '')"
|
||||||
|
@mouseleave="hoverWorkspaceId = null"
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="workspace-group-toggle"
|
||||||
|
@click="toggleWorkspaceExpanded(String(ws.workspace_id || ''))"
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
class="icon icon-sm workspace-folder-icon"
|
||||||
|
:style="iconStyle(isWorkspaceExpanded(String(ws.workspace_id || '')) ? 'folderOpen' : 'folderClosed')"
|
||||||
|
aria-hidden="true"
|
||||||
|
></span>
|
||||||
|
<span class="workspace-group-label">{{ ws.label || ws.workspace_id }}</span>
|
||||||
|
<span
|
||||||
|
v-if="String(ws.workspace_id || '') === currentWorkspaceId"
|
||||||
|
class="workspace-current-dot"
|
||||||
|
aria-label="当前工作区"
|
||||||
|
></span>
|
||||||
|
<span
|
||||||
|
v-if="
|
||||||
|
isWorkspaceRunning(String(ws.workspace_id || '')) &&
|
||||||
|
!isWorkspaceExpanded(String(ws.workspace_id || '')) &&
|
||||||
|
hoverWorkspaceId !== String(ws.workspace_id || '') &&
|
||||||
|
openWorkspaceMenuId !== String(ws.workspace_id || '')
|
||||||
|
"
|
||||||
|
class="workspace-running-loader"
|
||||||
|
aria-label="运行中"
|
||||||
|
></span>
|
||||||
|
</button>
|
||||||
|
<div
|
||||||
|
class="workspace-group-actions"
|
||||||
|
:class="{ visible: hoverWorkspaceId === String(ws.workspace_id || '') || openWorkspaceMenuId === String(ws.workspace_id || '') }"
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="workspace-new-btn"
|
||||||
|
title="新建对话"
|
||||||
|
aria-label="新建对话"
|
||||||
|
@click="handleCreateWorkspaceConversation(String(ws.workspace_id || ''))"
|
||||||
|
>
|
||||||
|
<span class="icon icon-sm" :style="iconStyle('pencil')" aria-hidden="true"></span>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="workspace-more-btn"
|
||||||
|
title="更多操作"
|
||||||
|
aria-label="更多操作"
|
||||||
|
:aria-expanded="openWorkspaceMenuId === String(ws.workspace_id || '')"
|
||||||
|
@click.stop="toggleWorkspaceMenu(String(ws.workspace_id || ''))"
|
||||||
|
>
|
||||||
|
<span aria-hidden="true"></span>
|
||||||
|
</button>
|
||||||
|
<div
|
||||||
|
class="workspace-actions-menu"
|
||||||
|
:class="{ open: openWorkspaceMenuId === String(ws.workspace_id || '') }"
|
||||||
|
>
|
||||||
|
<button type="button" @click="handlePinWorkspace(String(ws.workspace_id || ''))">
|
||||||
|
<span>{{ pinnedWorkspaceIds.has(String(ws.workspace_id || '')) ? '取消置顶' : '置顶' }}</span>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
v-if="props.versioningHostMode"
|
||||||
|
type="button"
|
||||||
|
@click="handleRevealWorkspace(String(ws.workspace_id || ''))"
|
||||||
|
>
|
||||||
|
<span>在文件夹中打开</span>
|
||||||
|
</button>
|
||||||
|
<button type="button" @click="startRenameWorkspace(ws)">
|
||||||
|
<span>重命名</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
class="workspace-group-children"
|
||||||
|
:class="{ expanded: isWorkspaceExpanded(String(ws.workspace_id || '')) }"
|
||||||
|
>
|
||||||
|
<div class="workspace-group-children-inner">
|
||||||
|
<div
|
||||||
|
v-if="getWorkspaceGroup(String(ws.workspace_id || ''))?.loading && !getWorkspaceGroup(String(ws.workspace_id || ''))?.conversations?.length"
|
||||||
|
class="workspace-conversations-loading"
|
||||||
|
>
|
||||||
|
正在加载...
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
v-else-if="!getWorkspaceGroup(String(ws.workspace_id || ''))?.conversations?.length"
|
||||||
|
class="workspace-no-conversations"
|
||||||
|
>
|
||||||
|
暂无对话
|
||||||
|
</div>
|
||||||
|
<template v-else>
|
||||||
|
<div
|
||||||
|
class="workspace-conversations-viewport"
|
||||||
|
:style="{
|
||||||
|
'--workspace-visible-count': getWorkspaceGroup(String(ws.workspace_id || ''))?.visibleLimit || 5
|
||||||
|
}"
|
||||||
|
>
|
||||||
|
<transition-group
|
||||||
|
name="conversation-delete"
|
||||||
|
tag="div"
|
||||||
|
class="workspace-conversations-list"
|
||||||
|
:class="`animation-mode-${conversationListAnimationMode}`"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
v-for="conv in getWorkspaceVisibleAndBufferConversations(String(ws.workspace_id || ''))"
|
||||||
|
:key="conv.id"
|
||||||
|
class="conversation-item workspace-conversation-item"
|
||||||
|
:class="{
|
||||||
|
active: conv.id === currentConversationId,
|
||||||
|
running: isConversationActive(conv.id) || isConversationCompleted(conv.id),
|
||||||
|
'insert-from-left': conversationInsertAnimations[conv.id] === 'create',
|
||||||
|
'insert-from-under': conversationInsertAnimations[conv.id] === 'duplicate',
|
||||||
|
'duplicate-source-mask': conversationInsertAnimations[conv.id] === 'duplicateSource'
|
||||||
|
}"
|
||||||
|
@click="handleWorkspaceConversationClick(conv.id, String(ws.workspace_id || ''))"
|
||||||
|
>
|
||||||
|
<div class="conversation-title">{{ conv.title }}</div>
|
||||||
|
<div
|
||||||
|
class="conversation-action-wrap"
|
||||||
|
:class="{ 'menu-open': openActionMenuId === conv.id }"
|
||||||
|
@click.stop
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
v-if="isConversationActive(conv.id)"
|
||||||
|
class="conversation-running-loader"
|
||||||
|
aria-label="运行中"
|
||||||
|
title="运行中"
|
||||||
|
></span>
|
||||||
|
<span
|
||||||
|
v-else-if="isConversationCompleted(conv.id)"
|
||||||
|
class="conversation-complete-check"
|
||||||
|
aria-label="已完成"
|
||||||
|
title="已完成"
|
||||||
|
></span>
|
||||||
|
<button
|
||||||
|
v-else
|
||||||
|
type="button"
|
||||||
|
class="conversation-more-btn"
|
||||||
|
title="更多操作"
|
||||||
|
aria-label="更多操作"
|
||||||
|
:data-action-trigger="conv.id"
|
||||||
|
:aria-expanded="openActionMenuId === conv.id"
|
||||||
|
@click="toggleActionMenu(conv.id)"
|
||||||
|
>
|
||||||
|
<span aria-hidden="true"></span>
|
||||||
|
</button>
|
||||||
|
<div
|
||||||
|
v-if="shouldShowActionMenu(conv.id)"
|
||||||
|
class="conversation-actions-menu conversation-actions-menu--fixed"
|
||||||
|
:class="{ open: openActionMenuId === conv.id }"
|
||||||
|
:style="{
|
||||||
|
top: actionMenuPosition.top + 'px',
|
||||||
|
right: actionMenuPosition.right + 'px'
|
||||||
|
}"
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
@click="
|
||||||
|
openActionMenuId = null;
|
||||||
|
$emit('duplicate', conv.id);
|
||||||
|
"
|
||||||
|
>
|
||||||
|
<span class="icon icon-sm" :style="iconStyle('copy')" aria-hidden="true"></span>
|
||||||
|
<span>复制</span>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="danger"
|
||||||
|
@click="
|
||||||
|
openActionMenuId = null;
|
||||||
|
$emit('delete', { id: conv.id, workspaceId: String(ws.workspace_id || '') });
|
||||||
|
"
|
||||||
|
>
|
||||||
|
<span class="icon icon-sm" :style="iconStyle('trash')" aria-hidden="true"></span>
|
||||||
|
<span>删除</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</transition-group>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
v-if="getWorkspaceHasMore(String(ws.workspace_id || ''))"
|
||||||
|
class="load-more workspace-load-more"
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
class="load-more-btn"
|
||||||
|
type="button"
|
||||||
|
:disabled="getWorkspaceGroup(String(ws.workspace_id || ''))?.loadingMore"
|
||||||
|
@click="loadMoreWorkspaceConversations(String(ws.workspace_id || ''))"
|
||||||
|
>
|
||||||
|
{{ getWorkspaceGroup(String(ws.workspace_id || ''))?.loadingMore ? '载入中...' : '加载更多' }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<template v-else>
|
||||||
<div
|
<div
|
||||||
v-if="loading && !displayConversations.length && !searchActive && !isDeletingConversation"
|
v-if="loading && !displayConversations.length && !searchActive && !isDeletingConversation"
|
||||||
class="loading-conversations"
|
class="loading-conversations"
|
||||||
@ -185,15 +397,20 @@
|
|||||||
class="conversation-more-btn"
|
class="conversation-more-btn"
|
||||||
title="更多操作"
|
title="更多操作"
|
||||||
aria-label="更多操作"
|
aria-label="更多操作"
|
||||||
|
:data-action-trigger="conv.id"
|
||||||
:aria-expanded="openActionMenuId === conv.id"
|
:aria-expanded="openActionMenuId === conv.id"
|
||||||
@click="toggleActionMenu(conv.id)"
|
@click="toggleActionMenu(conv.id)"
|
||||||
>
|
>
|
||||||
<span aria-hidden="true"></span>
|
<span aria-hidden="true"></span>
|
||||||
</button>
|
</button>
|
||||||
<div
|
<div
|
||||||
v-if="!isConversationActive(conv.id) && !isConversationCompleted(conv.id)"
|
v-if="!isConversationActive(conv.id) && !isConversationCompleted(conv.id) && openActionMenuId === conv.id"
|
||||||
class="conversation-actions-menu"
|
class="conversation-actions-menu conversation-actions-menu--fixed"
|
||||||
:class="{ open: openActionMenuId === conv.id }"
|
:class="{ open: openActionMenuId === conv.id }"
|
||||||
|
:style="{
|
||||||
|
top: actionMenuPosition.top + 'px',
|
||||||
|
right: actionMenuPosition.right + 'px'
|
||||||
|
}"
|
||||||
>
|
>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@ -220,7 +437,9 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</transition-group>
|
</transition-group>
|
||||||
<div v-if="!searchActive && hasMore" class="load-more">
|
</template>
|
||||||
|
|
||||||
|
<div v-if="!searchActive && !isGroupByWorkspaceActive && hasMore" class="load-more">
|
||||||
<button
|
<button
|
||||||
class="load-more-btn"
|
class="load-more-btn"
|
||||||
type="button"
|
type="button"
|
||||||
@ -251,6 +470,35 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
v-if="renameWorkspaceId"
|
||||||
|
class="workspace-rename-overlay"
|
||||||
|
@click.self="cancelRenameWorkspace"
|
||||||
|
>
|
||||||
|
<div class="workspace-rename-card">
|
||||||
|
<div class="workspace-rename-title">
|
||||||
|
重命名{{ workspaceKind === 'workspace' ? '工作区' : '项目' }}
|
||||||
|
</div>
|
||||||
|
<input
|
||||||
|
ref="renameInput"
|
||||||
|
v-model="renameWorkspaceLabel"
|
||||||
|
type="text"
|
||||||
|
class="workspace-rename-input"
|
||||||
|
placeholder="请输入名称"
|
||||||
|
@keydown.enter.prevent="submitRenameWorkspace"
|
||||||
|
@keydown.esc.prevent="cancelRenameWorkspace"
|
||||||
|
/>
|
||||||
|
<div class="workspace-rename-actions">
|
||||||
|
<button type="button" class="workspace-rename-cancel" @click="cancelRenameWorkspace">
|
||||||
|
取消
|
||||||
|
</button>
|
||||||
|
<button type="button" class="workspace-rename-confirm" @click="submitRenameWorkspace">
|
||||||
|
确认
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="conversation-personal-entry" :class="{ active: personalPageVisible }">
|
<div class="conversation-personal-entry" :class="{ active: personalPageVisible }">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@ -278,7 +526,7 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
defineOptions({ name: 'ConversationSidebar' });
|
defineOptions({ name: 'ConversationSidebar' });
|
||||||
|
|
||||||
import { computed, onBeforeUnmount, onMounted, ref } from 'vue';
|
import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue';
|
||||||
import { storeToRefs } from 'pinia';
|
import { storeToRefs } from 'pinia';
|
||||||
import { useUiStore } from '@/stores/ui';
|
import { useUiStore } from '@/stores/ui';
|
||||||
import { useConversationStore } from '@/stores/conversation';
|
import { useConversationStore } from '@/stores/conversation';
|
||||||
@ -294,6 +542,11 @@ const props = withDefaults(
|
|||||||
displayModeDisabled?: boolean;
|
displayModeDisabled?: boolean;
|
||||||
runningTasks?: any[];
|
runningTasks?: any[];
|
||||||
currentWorkspaceId?: string;
|
currentWorkspaceId?: string;
|
||||||
|
workspaces?: any[];
|
||||||
|
workspaceKind?: 'workspace' | 'project';
|
||||||
|
hostWorkspaceEnabled?: boolean;
|
||||||
|
groupByWorkspace?: boolean;
|
||||||
|
versioningHostMode?: boolean;
|
||||||
}>(),
|
}>(),
|
||||||
{
|
{
|
||||||
showCollapseButton: true,
|
showCollapseButton: true,
|
||||||
@ -301,11 +554,16 @@ const props = withDefaults(
|
|||||||
displayMode: 'chat',
|
displayMode: 'chat',
|
||||||
displayModeDisabled: false,
|
displayModeDisabled: false,
|
||||||
runningTasks: () => [],
|
runningTasks: () => [],
|
||||||
currentWorkspaceId: ''
|
currentWorkspaceId: '',
|
||||||
|
workspaces: () => [],
|
||||||
|
workspaceKind: 'workspace',
|
||||||
|
hostWorkspaceEnabled: false,
|
||||||
|
groupByWorkspace: false,
|
||||||
|
versioningHostMode: false
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
defineEmits<{
|
const emit = defineEmits<{
|
||||||
(event: 'toggle'): void;
|
(event: 'toggle'): void;
|
||||||
(event: 'create'): void;
|
(event: 'create'): void;
|
||||||
(event: 'search', value: string): void;
|
(event: 'search', value: string): void;
|
||||||
@ -319,6 +577,11 @@ defineEmits<{
|
|||||||
(event: 'duplicate', id: string): void;
|
(event: 'duplicate', id: string): void;
|
||||||
(event: 'toggle-workspace'): void;
|
(event: 'toggle-workspace'): void;
|
||||||
(event: 'toggle-display-mode'): void;
|
(event: 'toggle-display-mode'): void;
|
||||||
|
(event: 'select-workspace-conversation', payload: { conversationId: string; workspaceId: string }): void;
|
||||||
|
(event: 'create-workspace-conversation', workspaceId: string): void;
|
||||||
|
(event: 'reveal-workspace', workspaceId: string): void;
|
||||||
|
(event: 'rename-workspace', payload: { workspaceId: string; label: string }): void;
|
||||||
|
(event: 'pin-workspace', workspaceId: string): void;
|
||||||
}>();
|
}>();
|
||||||
|
|
||||||
const uiStore = useUiStore();
|
const uiStore = useUiStore();
|
||||||
@ -354,21 +617,236 @@ const monitorModeActive = computed(
|
|||||||
);
|
);
|
||||||
const displayModeDisabled = computed(() => props.displayModeDisabled);
|
const displayModeDisabled = computed(() => props.displayModeDisabled);
|
||||||
const openActionMenuId = ref<string | null>(null);
|
const openActionMenuId = ref<string | null>(null);
|
||||||
|
const actionMenuPosition = ref<{ top: number; right: number }>({ top: 0, right: 0 });
|
||||||
|
const hoverWorkspaceId = ref<string | null>(null);
|
||||||
|
const openWorkspaceMenuId = ref<string | null>(null);
|
||||||
|
const renameWorkspaceId = ref<string | null>(null);
|
||||||
|
const renameWorkspaceLabel = ref('');
|
||||||
|
const renameInput = ref<HTMLInputElement | null>(null);
|
||||||
|
|
||||||
|
watch(renameWorkspaceId, async (id) => {
|
||||||
|
if (id) {
|
||||||
|
await nextTick();
|
||||||
|
renameInput.value?.focus();
|
||||||
|
renameInput.value?.select();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const updateActionMenuPosition = (conversationId: string) => {
|
||||||
|
if (typeof document === 'undefined') return;
|
||||||
|
const trigger = document.querySelector(`[data-action-trigger="${conversationId}"]`) as HTMLElement | null;
|
||||||
|
if (!trigger) return;
|
||||||
|
const rect = trigger.getBoundingClientRect();
|
||||||
|
actionMenuPosition.value = {
|
||||||
|
top: rect.bottom + 6,
|
||||||
|
right: (typeof window !== 'undefined' ? window.innerWidth : 0) - rect.right
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
const toggleActionMenu = (conversationId: string) => {
|
const toggleActionMenu = (conversationId: string) => {
|
||||||
openActionMenuId.value = openActionMenuId.value === conversationId ? null : conversationId;
|
if (openActionMenuId.value === conversationId) {
|
||||||
|
openActionMenuId.value = null;
|
||||||
|
} else {
|
||||||
|
updateActionMenuPosition(conversationId);
|
||||||
|
openActionMenuId.value = conversationId;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
watch(openActionMenuId, async (id) => {
|
||||||
|
if (id) {
|
||||||
|
await nextTick();
|
||||||
|
updateActionMenuPosition(id);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const shouldShowActionMenu = (conversationId: string) => {
|
||||||
|
return (
|
||||||
|
openActionMenuId.value === conversationId &&
|
||||||
|
!isConversationActive(conversationId) &&
|
||||||
|
!isConversationCompleted(conversationId)
|
||||||
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
const closeActionMenu = () => {
|
const closeActionMenu = () => {
|
||||||
openActionMenuId.value = null;
|
openActionMenuId.value = null;
|
||||||
};
|
};
|
||||||
|
|
||||||
onMounted(() => {
|
const pinnedWorkspaceIds = ref<Set<string>>(new Set());
|
||||||
document.addEventListener('click', closeActionMenu);
|
const workspaceOrder = ref<string[]>([]);
|
||||||
});
|
const sidebarSortLoaded = ref(false);
|
||||||
|
|
||||||
onBeforeUnmount(() => {
|
const syncPinnedFromStore = () => {
|
||||||
document.removeEventListener('click', closeActionMenu);
|
pinnedWorkspaceIds.value = new Set(
|
||||||
|
Array.isArray(personalizationStore.form.sidebar_pinned_workspaces)
|
||||||
|
? personalizationStore.form.sidebar_pinned_workspaces
|
||||||
|
: []
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const syncOrderFromStore = () => {
|
||||||
|
workspaceOrder.value = Array.isArray(personalizationStore.form.sidebar_workspace_order)
|
||||||
|
? [...personalizationStore.form.sidebar_workspace_order]
|
||||||
|
: [];
|
||||||
|
};
|
||||||
|
|
||||||
|
const persistPinnedWorkspaces = () => {
|
||||||
|
if (!sidebarSortLoaded.value) return;
|
||||||
|
const next = Array.from(pinnedWorkspaceIds.value);
|
||||||
|
const current = personalizationStore.form.sidebar_pinned_workspaces || [];
|
||||||
|
if (JSON.stringify(next) === JSON.stringify(current)) return;
|
||||||
|
console.log('[sidebar-debug] persistPinnedWorkspaces', {
|
||||||
|
next: JSON.parse(JSON.stringify(next)),
|
||||||
|
current: JSON.parse(JSON.stringify(current))
|
||||||
|
});
|
||||||
|
personalizationStore.updateField({
|
||||||
|
key: 'sidebar_pinned_workspaces',
|
||||||
|
value: next
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const persistWorkspaceOrder = () => {
|
||||||
|
if (!sidebarSortLoaded.value) return;
|
||||||
|
const next = Array.from(workspaceOrder.value);
|
||||||
|
const current = personalizationStore.form.sidebar_workspace_order || [];
|
||||||
|
if (JSON.stringify(next) === JSON.stringify(current)) return;
|
||||||
|
console.log('[sidebar-debug] persistWorkspaceOrder', {
|
||||||
|
next: JSON.parse(JSON.stringify(next)),
|
||||||
|
current: JSON.parse(JSON.stringify(current))
|
||||||
|
});
|
||||||
|
personalizationStore.updateField({
|
||||||
|
key: 'sidebar_workspace_order',
|
||||||
|
value: next
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const bumpCurrentWorkspaceToTop = () => {
|
||||||
|
if (!sidebarSortLoaded.value) return;
|
||||||
|
const currentId = String(props.currentWorkspaceId || '');
|
||||||
|
console.log('[sidebar-debug] bumpCurrentWorkspaceToTop', {
|
||||||
|
currentId,
|
||||||
|
orderBefore: JSON.parse(JSON.stringify(workspaceOrder.value))
|
||||||
|
});
|
||||||
|
if (!currentId || pinnedWorkspaceIds.value.has(currentId)) return;
|
||||||
|
if (workspaceOrder.value[0] === currentId) return;
|
||||||
|
const order = workspaceOrder.value.filter((item) => item !== currentId);
|
||||||
|
order.unshift(currentId);
|
||||||
|
workspaceOrder.value = order;
|
||||||
|
persistWorkspaceOrder();
|
||||||
|
console.log('[sidebar-debug] bumpCurrentWorkspaceToTop after', {
|
||||||
|
orderAfter: JSON.parse(JSON.stringify(workspaceOrder.value))
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const syncWorkspaceOrderWithWorkspaces = (workspaces: any[]) => {
|
||||||
|
if (!sidebarSortLoaded.value || !Array.isArray(workspaces)) return;
|
||||||
|
const ids = workspaces
|
||||||
|
.map((ws) => String(ws?.workspace_id || ''))
|
||||||
|
.filter(Boolean);
|
||||||
|
const existing = new Set(workspaceOrder.value);
|
||||||
|
const pinnedIds = new Set(
|
||||||
|
ids.filter((id) => pinnedWorkspaceIds.value.has(id))
|
||||||
|
);
|
||||||
|
const newIds = ids.filter((id) => !existing.has(id) && !pinnedIds.has(id));
|
||||||
|
const removedIds = new Set(
|
||||||
|
workspaceOrder.value.filter((id) => !ids.includes(id))
|
||||||
|
);
|
||||||
|
console.log('[sidebar-debug] syncWorkspaceOrderWithWorkspaces', {
|
||||||
|
ids,
|
||||||
|
newIds,
|
||||||
|
removedIds: Array.from(removedIds),
|
||||||
|
orderBefore: JSON.parse(JSON.stringify(workspaceOrder.value))
|
||||||
|
});
|
||||||
|
if (newIds.length || removedIds.size) {
|
||||||
|
workspaceOrder.value = workspaceOrder.value
|
||||||
|
.filter((id) => !removedIds.has(id))
|
||||||
|
.concat(newIds);
|
||||||
|
persistWorkspaceOrder();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const loadSidebarSortFromStore = () => {
|
||||||
|
if (!personalizationStore.loaded || sidebarSortLoaded.value) return;
|
||||||
|
console.log('[sidebar-debug] loadSidebarSortFromStore start', {
|
||||||
|
pinned: JSON.parse(JSON.stringify(personalizationStore.form.sidebar_pinned_workspaces)),
|
||||||
|
order: JSON.parse(JSON.stringify(personalizationStore.form.sidebar_workspace_order))
|
||||||
|
});
|
||||||
|
syncPinnedFromStore();
|
||||||
|
syncOrderFromStore();
|
||||||
|
sidebarSortLoaded.value = true;
|
||||||
|
if (Array.isArray(props.workspaces) && props.workspaces.length > 0) {
|
||||||
|
syncWorkspaceOrderWithWorkspaces(props.workspaces);
|
||||||
|
}
|
||||||
|
bumpCurrentWorkspaceToTop();
|
||||||
|
console.log('[sidebar-debug] loadSidebarSortFromStore end', {
|
||||||
|
pinned: Array.from(pinnedWorkspaceIds.value),
|
||||||
|
order: JSON.parse(JSON.stringify(workspaceOrder.value))
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => personalizationStore.loaded,
|
||||||
|
(loaded) => {
|
||||||
|
if (loaded) loadSidebarSortFromStore();
|
||||||
|
},
|
||||||
|
{ immediate: true }
|
||||||
|
);
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => personalizationStore.form.sidebar_pinned_workspaces,
|
||||||
|
() => {
|
||||||
|
if (!sidebarSortLoaded.value) return;
|
||||||
|
syncPinnedFromStore();
|
||||||
|
},
|
||||||
|
{ deep: true }
|
||||||
|
);
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => personalizationStore.form.sidebar_workspace_order,
|
||||||
|
() => {
|
||||||
|
if (!sidebarSortLoaded.value) return;
|
||||||
|
syncOrderFromStore();
|
||||||
|
if (Array.isArray(props.workspaces) && props.workspaces.length > 0) {
|
||||||
|
syncWorkspaceOrderWithWorkspaces(props.workspaces);
|
||||||
|
}
|
||||||
|
bumpCurrentWorkspaceToTop();
|
||||||
|
},
|
||||||
|
{ deep: true }
|
||||||
|
);
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => props.workspaces,
|
||||||
|
(list) => {
|
||||||
|
if (Array.isArray(list) && list.length > 0) {
|
||||||
|
syncWorkspaceOrderWithWorkspaces(list);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{ immediate: true, deep: true }
|
||||||
|
);
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => props.currentWorkspaceId,
|
||||||
|
() => bumpCurrentWorkspaceToTop(),
|
||||||
|
{ immediate: true }
|
||||||
|
);
|
||||||
|
|
||||||
|
const isGroupByWorkspaceActive = computed(
|
||||||
|
() => props.groupByWorkspace && props.hostWorkspaceEnabled && Array.isArray(props.workspaces) && props.workspaces.length > 0
|
||||||
|
);
|
||||||
|
const sortedWorkspaces = computed(() => {
|
||||||
|
const list = Array.isArray(props.workspaces) ? [...props.workspaces] : [];
|
||||||
|
return list.sort((a: any, b: any) => {
|
||||||
|
const aId = String(a?.workspace_id || '');
|
||||||
|
const bId = String(b?.workspace_id || '');
|
||||||
|
const aPinned = pinnedWorkspaceIds.value.has(aId) ? 1 : 0;
|
||||||
|
const bPinned = pinnedWorkspaceIds.value.has(bId) ? 1 : 0;
|
||||||
|
if (aPinned !== bPinned) return bPinned - aPinned;
|
||||||
|
const aIndex = workspaceOrder.value.indexOf(aId);
|
||||||
|
const bIndex = workspaceOrder.value.indexOf(bId);
|
||||||
|
if (aIndex !== -1 && bIndex !== -1) return aIndex - bIndex;
|
||||||
|
if (aIndex !== -1) return -1;
|
||||||
|
if (bIndex !== -1) return 1;
|
||||||
|
return String(a?.label || aId).localeCompare(String(b?.label || bId));
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
const displayConversations = computed(() =>
|
const displayConversations = computed(() =>
|
||||||
@ -416,4 +894,146 @@ const isConversationActive = (conversationId: string) =>
|
|||||||
activeConversationIds.value.has(String(conversationId || ''));
|
activeConversationIds.value.has(String(conversationId || ''));
|
||||||
const isConversationCompleted = (conversationId: string) =>
|
const isConversationCompleted = (conversationId: string) =>
|
||||||
completedConversationIds.value.has(String(conversationId || ''));
|
completedConversationIds.value.has(String(conversationId || ''));
|
||||||
|
const isWorkspaceRunning = (workspaceId: string) =>
|
||||||
|
(Array.isArray(props.runningTasks) ? props.runningTasks : []).some(
|
||||||
|
(task) => isTaskActive(task) && String(task?.workspace_id || '') === workspaceId
|
||||||
|
);
|
||||||
|
|
||||||
|
const getWorkspaceGroup = (workspaceId: string) => {
|
||||||
|
return conversationStore.workspaceGroups.find((g) => g.workspaceId === workspaceId);
|
||||||
|
};
|
||||||
|
|
||||||
|
const getWorkspaceVisibleAndBufferConversations = (workspaceId: string) => {
|
||||||
|
const group = getWorkspaceGroup(workspaceId);
|
||||||
|
if (!group) return [];
|
||||||
|
const end = group.visibleLimit + group.bufferLimit;
|
||||||
|
return group.conversations.slice(0, end);
|
||||||
|
};
|
||||||
|
|
||||||
|
const getWorkspaceHasMore = (workspaceId: string) => {
|
||||||
|
const group = getWorkspaceGroup(workspaceId);
|
||||||
|
if (!group) return false;
|
||||||
|
return group.hasMore || group.conversations.length > group.visibleLimit;
|
||||||
|
};
|
||||||
|
|
||||||
|
const isWorkspaceExpanded = (workspaceId: string) => {
|
||||||
|
const group = getWorkspaceGroup(workspaceId);
|
||||||
|
return group ? group.expanded : true;
|
||||||
|
};
|
||||||
|
|
||||||
|
const toggleWorkspaceExpanded = (workspaceId: string) => {
|
||||||
|
const group = getWorkspaceGroup(workspaceId);
|
||||||
|
if (!group) {
|
||||||
|
conversationStore.ensureWorkspaceGroup(workspaceId);
|
||||||
|
void conversationStore.loadWorkspaceConversations(workspaceId);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
conversationStore.setWorkspaceGroupExpanded(workspaceId, !group.expanded);
|
||||||
|
if (!group.expanded && group.conversations.length === 0 && !group.loading) {
|
||||||
|
void conversationStore.loadWorkspaceConversations(workspaceId);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const ensureWorkspaceGroup = (workspaceId: string) => {
|
||||||
|
const group = getWorkspaceGroup(workspaceId);
|
||||||
|
if (!group) {
|
||||||
|
conversationStore.ensureWorkspaceGroup(workspaceId);
|
||||||
|
void conversationStore.loadWorkspaceConversations(workspaceId);
|
||||||
|
} else if (group.conversations.length === 0 && !group.loading && !group.loadingMore) {
|
||||||
|
void conversationStore.loadWorkspaceConversations(workspaceId);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
watch(
|
||||||
|
[isGroupByWorkspaceActive, sortedWorkspaces],
|
||||||
|
([active, list]) => {
|
||||||
|
if (active && list.length > 0) {
|
||||||
|
list.forEach((ws: any) => ensureWorkspaceGroup(String(ws?.workspace_id || '')));
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{ immediate: true }
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleWorkspaceConversationClick = (conversationId: string, workspaceId: string) => {
|
||||||
|
console.log('[sidebar-debug] handleWorkspaceConversationClick', { conversationId, workspaceId });
|
||||||
|
openWorkspaceMenuId.value = null;
|
||||||
|
emit('select-workspace-conversation', { conversationId, workspaceId });
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCreateWorkspaceConversation = (workspaceId: string) => {
|
||||||
|
openWorkspaceMenuId.value = null;
|
||||||
|
emit('create-workspace-conversation', workspaceId);
|
||||||
|
};
|
||||||
|
|
||||||
|
const toggleWorkspaceMenu = (workspaceId: string) => {
|
||||||
|
openWorkspaceMenuId.value = openWorkspaceMenuId.value === workspaceId ? null : workspaceId;
|
||||||
|
};
|
||||||
|
|
||||||
|
const closeWorkspaceMenu = () => {
|
||||||
|
openWorkspaceMenuId.value = null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const handlePinWorkspace = (workspaceId: string) => {
|
||||||
|
openWorkspaceMenuId.value = null;
|
||||||
|
const order = [...workspaceOrder.value];
|
||||||
|
const orderIndex = order.indexOf(workspaceId);
|
||||||
|
if (pinnedWorkspaceIds.value.has(workspaceId)) {
|
||||||
|
pinnedWorkspaceIds.value.delete(workspaceId);
|
||||||
|
if (orderIndex !== -1) order.splice(orderIndex, 1);
|
||||||
|
const currentId = String(props.currentWorkspaceId || '');
|
||||||
|
const currentIndex = order.indexOf(currentId);
|
||||||
|
const insertIndex = currentIndex !== -1 ? currentIndex + 1 : 0;
|
||||||
|
order.splice(insertIndex, 0, workspaceId);
|
||||||
|
} else {
|
||||||
|
pinnedWorkspaceIds.value.add(workspaceId);
|
||||||
|
if (orderIndex !== -1) order.splice(orderIndex, 1);
|
||||||
|
}
|
||||||
|
workspaceOrder.value = order;
|
||||||
|
persistPinnedWorkspaces();
|
||||||
|
persistWorkspaceOrder();
|
||||||
|
emit('pin-workspace', workspaceId);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleRevealWorkspace = (workspaceId: string) => {
|
||||||
|
openWorkspaceMenuId.value = null;
|
||||||
|
emit('reveal-workspace', workspaceId);
|
||||||
|
};
|
||||||
|
|
||||||
|
const startRenameWorkspace = (workspace: any) => {
|
||||||
|
openWorkspaceMenuId.value = null;
|
||||||
|
renameWorkspaceId.value = String(workspace?.workspace_id || '');
|
||||||
|
renameWorkspaceLabel.value = String(workspace?.label || workspace?.workspace_id || '');
|
||||||
|
};
|
||||||
|
|
||||||
|
const submitRenameWorkspace = () => {
|
||||||
|
const workspaceId = renameWorkspaceId.value;
|
||||||
|
const label = renameWorkspaceLabel.value.trim();
|
||||||
|
if (workspaceId && label) {
|
||||||
|
emit('rename-workspace', { workspaceId, label });
|
||||||
|
}
|
||||||
|
renameWorkspaceId.value = null;
|
||||||
|
renameWorkspaceLabel.value = '';
|
||||||
|
};
|
||||||
|
|
||||||
|
const cancelRenameWorkspace = () => {
|
||||||
|
renameWorkspaceId.value = null;
|
||||||
|
renameWorkspaceLabel.value = '';
|
||||||
|
};
|
||||||
|
|
||||||
|
const loadMoreWorkspaceConversations = (workspaceId: string) => {
|
||||||
|
void conversationStore.loadMoreWorkspaceConversations(workspaceId);
|
||||||
|
};
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
document.addEventListener('click', closeActionMenu);
|
||||||
|
document.addEventListener('click', closeWorkspaceMenu);
|
||||||
|
if (isGroupByWorkspaceActive.value) {
|
||||||
|
sortedWorkspaces.value.forEach((ws: any) => ensureWorkspaceGroup(String(ws?.workspace_id || '')));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
onBeforeUnmount(() => {
|
||||||
|
document.removeEventListener('click', closeActionMenu);
|
||||||
|
document.removeEventListener('click', closeWorkspaceMenu);
|
||||||
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@ -875,6 +875,16 @@ export async function initializeLegacySocket(ctx: any) {
|
|||||||
ctx.hasMoreConversations = false;
|
ctx.hasMoreConversations = false;
|
||||||
ctx.loadingMoreConversations = false;
|
ctx.loadingMoreConversations = false;
|
||||||
ctx.loadConversationsList();
|
ctx.loadConversationsList();
|
||||||
|
// 同时刷新分组侧边栏中已加载的工作区
|
||||||
|
try {
|
||||||
|
const { useConversationStore } = require('../stores/conversation');
|
||||||
|
const conversationStore = useConversationStore();
|
||||||
|
conversationStore.workspaceGroups.forEach((group: any) => {
|
||||||
|
conversationStore.loadWorkspaceConversations(group.workspaceId, { refresh: true });
|
||||||
|
});
|
||||||
|
} catch (_err) {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// 监听状态更新事件
|
// 监听状态更新事件
|
||||||
|
|||||||
@ -8,6 +8,20 @@ export interface ConversationSummary {
|
|||||||
total_tools?: number;
|
total_tools?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface WorkspaceConversationGroup {
|
||||||
|
workspaceId: string;
|
||||||
|
conversations: ConversationSummary[];
|
||||||
|
loading: boolean;
|
||||||
|
hasMore: boolean;
|
||||||
|
loadingMore: boolean;
|
||||||
|
offset: number;
|
||||||
|
visibleOffset: number;
|
||||||
|
visibleLimit: number;
|
||||||
|
bufferLimit: number;
|
||||||
|
fetchLimit: number;
|
||||||
|
expanded: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
interface ConversationState {
|
interface ConversationState {
|
||||||
conversations: ConversationSummary[];
|
conversations: ConversationSummary[];
|
||||||
searchResults: ConversationSummary[];
|
searchResults: ConversationSummary[];
|
||||||
@ -30,6 +44,7 @@ interface ConversationState {
|
|||||||
conversationsLimit: number;
|
conversationsLimit: number;
|
||||||
runningWorkspaceTasks: any[];
|
runningWorkspaceTasks: any[];
|
||||||
acknowledgedCompletedTaskIds: string[];
|
acknowledgedCompletedTaskIds: string[];
|
||||||
|
workspaceGroups: WorkspaceConversationGroup[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export const useConversationStore = defineStore('conversation', {
|
export const useConversationStore = defineStore('conversation', {
|
||||||
@ -54,7 +69,8 @@ export const useConversationStore = defineStore('conversation', {
|
|||||||
conversationsOffset: 0,
|
conversationsOffset: 0,
|
||||||
conversationsLimit: 20,
|
conversationsLimit: 20,
|
||||||
runningWorkspaceTasks: [],
|
runningWorkspaceTasks: [],
|
||||||
acknowledgedCompletedTaskIds: []
|
acknowledgedCompletedTaskIds: [],
|
||||||
|
workspaceGroups: []
|
||||||
}),
|
}),
|
||||||
actions: {
|
actions: {
|
||||||
resetConversations() {
|
resetConversations() {
|
||||||
@ -79,6 +95,171 @@ export const useConversationStore = defineStore('conversation', {
|
|||||||
clearTimeout(this.searchTimer);
|
clearTimeout(this.searchTimer);
|
||||||
this.searchTimer = null;
|
this.searchTimer = null;
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
setWorkspaceGroups(groups: WorkspaceConversationGroup[]) {
|
||||||
|
this.workspaceGroups = groups;
|
||||||
|
},
|
||||||
|
setWorkspaceGroupExpanded(workspaceId: string, expanded: boolean) {
|
||||||
|
const group = this.workspaceGroups.find((g) => g.workspaceId === workspaceId);
|
||||||
|
if (group) {
|
||||||
|
group.expanded = expanded;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
setWorkspaceGroupConversations(workspaceId: string, conversations: ConversationSummary[]) {
|
||||||
|
const group = this.workspaceGroups.find((g) => g.workspaceId === workspaceId);
|
||||||
|
if (group) {
|
||||||
|
group.conversations = conversations;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
appendWorkspaceGroupConversations(workspaceId: string, conversations: ConversationSummary[]) {
|
||||||
|
const group = this.workspaceGroups.find((g) => g.workspaceId === workspaceId);
|
||||||
|
if (group) {
|
||||||
|
group.conversations.push(...conversations);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
setWorkspaceGroupLoading(workspaceId: string, loading: boolean) {
|
||||||
|
const group = this.workspaceGroups.find((g) => g.workspaceId === workspaceId);
|
||||||
|
if (group) {
|
||||||
|
group.loading = loading;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
setWorkspaceGroupLoadingMore(workspaceId: string, loadingMore: boolean) {
|
||||||
|
const group = this.workspaceGroups.find((g) => g.workspaceId === workspaceId);
|
||||||
|
if (group) {
|
||||||
|
group.loadingMore = loadingMore;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
setWorkspaceGroupHasMore(workspaceId: string, hasMore: boolean) {
|
||||||
|
const group = this.workspaceGroups.find((g) => g.workspaceId === workspaceId);
|
||||||
|
if (group) {
|
||||||
|
group.hasMore = hasMore;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
setWorkspaceGroupOffset(workspaceId: string, offset: number) {
|
||||||
|
const group = this.workspaceGroups.find((g) => g.workspaceId === workspaceId);
|
||||||
|
if (group) {
|
||||||
|
group.offset = offset;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
setWorkspaceGroupVisibleOffset(workspaceId: string, visibleOffset: number) {
|
||||||
|
const group = this.workspaceGroups.find((g) => g.workspaceId === workspaceId);
|
||||||
|
if (group) {
|
||||||
|
group.visibleOffset = Math.max(0, visibleOffset);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
ensureWorkspaceGroup(workspaceId: string) {
|
||||||
|
if (!workspaceId) return;
|
||||||
|
const exists = this.workspaceGroups.some((g) => g.workspaceId === workspaceId);
|
||||||
|
if (!exists) {
|
||||||
|
this.workspaceGroups.push({
|
||||||
|
workspaceId,
|
||||||
|
conversations: [],
|
||||||
|
loading: false,
|
||||||
|
hasMore: false,
|
||||||
|
loadingMore: false,
|
||||||
|
offset: 0,
|
||||||
|
visibleOffset: 0,
|
||||||
|
visibleLimit: 5,
|
||||||
|
bufferLimit: 20,
|
||||||
|
fetchLimit: 25,
|
||||||
|
expanded: true
|
||||||
|
});
|
||||||
|
}
|
||||||
|
},
|
||||||
|
async loadWorkspaceConversations(
|
||||||
|
workspaceId: string,
|
||||||
|
{ reset = false, refresh = false } = {}
|
||||||
|
) {
|
||||||
|
if (!workspaceId) return;
|
||||||
|
console.log('[sidebar-debug] loadWorkspaceConversations', {
|
||||||
|
workspaceId,
|
||||||
|
reset,
|
||||||
|
refresh
|
||||||
|
});
|
||||||
|
let index = this.workspaceGroups.findIndex((g) => g.workspaceId === workspaceId);
|
||||||
|
if (index === -1) {
|
||||||
|
this.ensureWorkspaceGroup(workspaceId);
|
||||||
|
index = this.workspaceGroups.length - 1;
|
||||||
|
}
|
||||||
|
const group = this.workspaceGroups[index];
|
||||||
|
if (reset) {
|
||||||
|
group.conversations = [];
|
||||||
|
group.offset = 0;
|
||||||
|
group.visibleOffset = 0;
|
||||||
|
group.hasMore = false;
|
||||||
|
}
|
||||||
|
if (group.loading || group.loadingMore) return;
|
||||||
|
const fetchOffset = refresh ? 0 : group.offset;
|
||||||
|
group.loading = true;
|
||||||
|
try {
|
||||||
|
const response = await fetch(
|
||||||
|
`/api/conversations?workspace_id=${encodeURIComponent(workspaceId)}&limit=${group.fetchLimit}&offset=${fetchOffset}`
|
||||||
|
);
|
||||||
|
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 (refresh) {
|
||||||
|
const tail = group.conversations.slice(items.length);
|
||||||
|
group.conversations = [...items, ...tail];
|
||||||
|
} else if (fetchOffset === 0) {
|
||||||
|
group.conversations = items;
|
||||||
|
} else {
|
||||||
|
group.conversations.push(...items);
|
||||||
|
}
|
||||||
|
group.hasMore = !!data.data?.has_more;
|
||||||
|
if (!refresh) {
|
||||||
|
group.offset = fetchOffset + items.length;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
console.error('加载工作区对话失败:', data.error);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('加载工作区对话异常:', error);
|
||||||
|
} finally {
|
||||||
|
group.loading = false;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
async loadMoreWorkspaceConversations(workspaceId: string) {
|
||||||
|
const index = this.workspaceGroups.findIndex((g) => g.workspaceId === workspaceId);
|
||||||
|
if (index === -1) return;
|
||||||
|
const group = this.workspaceGroups[index];
|
||||||
|
if (group.loadingMore) return;
|
||||||
|
const hasHidden = group.conversations.length > group.visibleLimit;
|
||||||
|
if (!hasHidden && !group.hasMore) return;
|
||||||
|
|
||||||
|
group.loadingMore = true;
|
||||||
|
// 先尝试从后端再加载 20 条作为新的缓冲
|
||||||
|
if (group.hasMore) {
|
||||||
|
try {
|
||||||
|
const fetchOffset = group.conversations.length;
|
||||||
|
const response = await fetch(
|
||||||
|
`/api/conversations?workspace_id=${encodeURIComponent(workspaceId)}&limit=${group.bufferLimit}&offset=${fetchOffset}`
|
||||||
|
);
|
||||||
|
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
|
||||||
|
}));
|
||||||
|
group.conversations.push(...items);
|
||||||
|
group.hasMore = !!data.data?.has_more;
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('加载更多工作区对话异常:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// 把可见区扩大 20 条,让用户直接看到更多对话
|
||||||
|
group.visibleLimit += group.bufferLimit;
|
||||||
|
group.loadingMore = false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
@ -59,6 +59,9 @@ interface PersonalForm {
|
|||||||
agents_md_auto_inject: boolean;
|
agents_md_auto_inject: boolean;
|
||||||
allow_root_file_creation: boolean;
|
allow_root_file_creation: boolean;
|
||||||
default_hide_workspace: boolean;
|
default_hide_workspace: boolean;
|
||||||
|
group_sidebar_by_workspace: boolean;
|
||||||
|
sidebar_pinned_workspaces: string[];
|
||||||
|
sidebar_workspace_order: string[];
|
||||||
theme: 'classic' | 'light' | 'dark';
|
theme: 'classic' | 'light' | 'dark';
|
||||||
goal_review_mode: 'readonly' | 'active';
|
goal_review_mode: 'readonly' | 'active';
|
||||||
goal_end_conditions: string[];
|
goal_end_conditions: string[];
|
||||||
@ -176,6 +179,9 @@ const defaultForm = (): PersonalForm => ({
|
|||||||
agents_md_auto_inject: false,
|
agents_md_auto_inject: false,
|
||||||
allow_root_file_creation: false,
|
allow_root_file_creation: false,
|
||||||
default_hide_workspace: false,
|
default_hide_workspace: false,
|
||||||
|
group_sidebar_by_workspace: false,
|
||||||
|
sidebar_pinned_workspaces: [],
|
||||||
|
sidebar_workspace_order: [],
|
||||||
theme: loadCachedTheme(),
|
theme: loadCachedTheme(),
|
||||||
goal_review_mode: 'readonly',
|
goal_review_mode: 'readonly',
|
||||||
goal_end_conditions: ['max_turns'],
|
goal_end_conditions: ['max_turns'],
|
||||||
@ -402,6 +408,13 @@ export const usePersonalizationStore = defineStore('personalization', {
|
|||||||
agents_md_auto_inject: !!data.agents_md_auto_inject,
|
agents_md_auto_inject: !!data.agents_md_auto_inject,
|
||||||
allow_root_file_creation: !!data.allow_root_file_creation,
|
allow_root_file_creation: !!data.allow_root_file_creation,
|
||||||
default_hide_workspace: !!data.default_hide_workspace,
|
default_hide_workspace: !!data.default_hide_workspace,
|
||||||
|
group_sidebar_by_workspace: !!data.group_sidebar_by_workspace,
|
||||||
|
sidebar_pinned_workspaces: Array.isArray(data.sidebar_pinned_workspaces)
|
||||||
|
? data.sidebar_pinned_workspaces.filter((item: unknown) => typeof item === 'string')
|
||||||
|
: [],
|
||||||
|
sidebar_workspace_order: Array.isArray(data.sidebar_workspace_order)
|
||||||
|
? data.sidebar_workspace_order.filter((item: unknown) => typeof item === 'string')
|
||||||
|
: [],
|
||||||
theme: ['classic', 'light', 'dark'].includes(data.theme) ? data.theme : fallbackTheme,
|
theme: ['classic', 'light', 'dark'].includes(data.theme) ? data.theme : fallbackTheme,
|
||||||
goal_review_mode: data.goal_review_mode === 'active' ? 'active' : 'readonly',
|
goal_review_mode: data.goal_review_mode === 'active' ? 'active' : 'readonly',
|
||||||
goal_end_conditions: Array.isArray(data.goal_end_conditions)
|
goal_end_conditions: Array.isArray(data.goal_end_conditions)
|
||||||
|
|||||||
@ -534,9 +534,6 @@ body[data-theme='dark'] .conversation-sidebar .conversation-item.active:focus-wi
|
|||||||
}
|
}
|
||||||
|
|
||||||
.conversation-sidebar .conversation-actions-menu {
|
.conversation-sidebar .conversation-actions-menu {
|
||||||
position: absolute;
|
|
||||||
right: 0;
|
|
||||||
top: calc(100% + 6px);
|
|
||||||
z-index: 140;
|
z-index: 140;
|
||||||
width: max-content;
|
width: max-content;
|
||||||
min-width: 92px;
|
min-width: 92px;
|
||||||
@ -553,6 +550,17 @@ body[data-theme='dark'] .conversation-sidebar .conversation-item.active:focus-wi
|
|||||||
transform 150ms cubic-bezier(0.22, 1, 0.36, 1);
|
transform 150ms cubic-bezier(0.22, 1, 0.36, 1);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.conversation-sidebar .conversation-actions-menu--absolute {
|
||||||
|
position: absolute;
|
||||||
|
right: 0;
|
||||||
|
top: calc(100% + 6px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.conversation-sidebar .conversation-actions-menu--fixed {
|
||||||
|
position: fixed;
|
||||||
|
z-index: 1000;
|
||||||
|
}
|
||||||
|
|
||||||
.conversation-sidebar .conversation-actions-menu.open {
|
.conversation-sidebar .conversation-actions-menu.open {
|
||||||
opacity: 1;
|
opacity: 1;
|
||||||
transform: translateY(0) scale(1);
|
transform: translateY(0) scale(1);
|
||||||
@ -680,6 +688,309 @@ body[data-theme='dark'] .conversation-sidebar .conversation-item.active:focus-wi
|
|||||||
border: 0;
|
border: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Workspace groups */
|
||||||
|
.workspace-groups {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 2px;
|
||||||
|
padding: 0 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.workspace-group {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.workspace-group-header {
|
||||||
|
position: relative;
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(0, 1fr) auto;
|
||||||
|
align-items: center;
|
||||||
|
height: var(--conversation-row-height);
|
||||||
|
border-radius: 12px;
|
||||||
|
transition: background 140ms ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.workspace-group-header:hover {
|
||||||
|
background: var(--theme-tab-active);
|
||||||
|
}
|
||||||
|
|
||||||
|
:root[data-theme='dark'] .workspace-group-header:hover,
|
||||||
|
body[data-theme='dark'] .workspace-group-header:hover {
|
||||||
|
background: var(--hover-bg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.workspace-group-toggle {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 24px minmax(0, 1fr) auto;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
height: 100%;
|
||||||
|
padding: 0 10px;
|
||||||
|
border: 0;
|
||||||
|
border-radius: 12px;
|
||||||
|
background: transparent;
|
||||||
|
color: var(--claude-text);
|
||||||
|
text-align: left;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.workspace-folder-icon {
|
||||||
|
color: var(--claude-text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.workspace-group-label {
|
||||||
|
min-width: 0;
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 600;
|
||||||
|
letter-spacing: -0.01em;
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
|
||||||
|
.workspace-current-dot {
|
||||||
|
width: 6px;
|
||||||
|
height: 6px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: var(--accent-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.workspace-running-loader {
|
||||||
|
width: 14px;
|
||||||
|
height: 14px;
|
||||||
|
border: 2px solid var(--claude-text-secondary);
|
||||||
|
border-bottom-color: transparent;
|
||||||
|
border-radius: 50%;
|
||||||
|
display: inline-block;
|
||||||
|
box-sizing: border-box;
|
||||||
|
animation: conversation-running-rotation 1s linear infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
.workspace-group-actions {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 2px;
|
||||||
|
padding-right: 6px;
|
||||||
|
opacity: 0;
|
||||||
|
transition: opacity 160ms ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.workspace-group-actions.visible {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.workspace-new-btn,
|
||||||
|
.workspace-more-btn {
|
||||||
|
width: 28px;
|
||||||
|
height: 28px;
|
||||||
|
border: 0;
|
||||||
|
border-radius: 8px;
|
||||||
|
background: transparent;
|
||||||
|
color: var(--claude-text-secondary);
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
cursor: pointer;
|
||||||
|
transition:
|
||||||
|
background 140ms ease,
|
||||||
|
color 140ms ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.workspace-new-btn:hover,
|
||||||
|
.workspace-more-btn:hover,
|
||||||
|
.workspace-group-actions.visible .workspace-more-btn[aria-expanded='true'] {
|
||||||
|
background: var(--theme-surface-strong);
|
||||||
|
color: var(--claude-text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.workspace-more-btn span {
|
||||||
|
position: relative;
|
||||||
|
display: inline-block;
|
||||||
|
width: 18px;
|
||||||
|
height: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.workspace-more-btn span::before {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
left: 3px;
|
||||||
|
top: 8px;
|
||||||
|
width: 3px;
|
||||||
|
height: 3px;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: currentColor;
|
||||||
|
box-shadow:
|
||||||
|
4.5px 0 0 currentColor,
|
||||||
|
9px 0 0 currentColor;
|
||||||
|
}
|
||||||
|
|
||||||
|
.workspace-actions-menu {
|
||||||
|
position: absolute;
|
||||||
|
right: 6px;
|
||||||
|
top: calc(100% + 4px);
|
||||||
|
z-index: 140;
|
||||||
|
width: max-content;
|
||||||
|
min-width: 120px;
|
||||||
|
padding: 6px;
|
||||||
|
border: 1px solid var(--claude-border);
|
||||||
|
border-radius: 14px;
|
||||||
|
background: var(--theme-surface-strong);
|
||||||
|
box-shadow: none;
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateY(-4px) scale(0.98);
|
||||||
|
pointer-events: none;
|
||||||
|
transition:
|
||||||
|
opacity 120ms ease,
|
||||||
|
transform 150ms cubic-bezier(0.22, 1, 0.36, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.workspace-actions-menu.open {
|
||||||
|
opacity: 1;
|
||||||
|
transform: translateY(0) scale(1);
|
||||||
|
pointer-events: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.workspace-actions-menu button {
|
||||||
|
width: 100%;
|
||||||
|
min-width: 80px;
|
||||||
|
height: 34px;
|
||||||
|
border: 0;
|
||||||
|
border-radius: 10px;
|
||||||
|
background: transparent;
|
||||||
|
color: var(--claude-text);
|
||||||
|
display: grid;
|
||||||
|
align-items: center;
|
||||||
|
padding: 0 10px;
|
||||||
|
text-align: left;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 14px;
|
||||||
|
transition: background 140ms ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.workspace-actions-menu button:hover {
|
||||||
|
background: var(--theme-tab-active);
|
||||||
|
}
|
||||||
|
|
||||||
|
.workspace-group-children {
|
||||||
|
display: grid;
|
||||||
|
grid-template-rows: 0fr;
|
||||||
|
transition: grid-template-rows 260ms cubic-bezier(0.22, 1, 0.36, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.workspace-group-children.expanded {
|
||||||
|
grid-template-rows: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.workspace-group-children-inner {
|
||||||
|
overflow: hidden;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.workspace-conversations-viewport {
|
||||||
|
position: relative;
|
||||||
|
max-height: calc((var(--conversation-row-height) + 2px) * var(--workspace-visible-count, 5));
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.workspace-conversations-list {
|
||||||
|
position: relative;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.workspace-conversation-item {
|
||||||
|
grid-template-columns: minmax(0, 1fr) 40px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.workspace-no-conversations,
|
||||||
|
.workspace-conversations-loading {
|
||||||
|
color: var(--claude-text-secondary);
|
||||||
|
font-size: 13px;
|
||||||
|
padding: 10px 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.workspace-load-more {
|
||||||
|
padding: 4px 0 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Workspace rename dialog */
|
||||||
|
.workspace-rename-overlay {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
z-index: 200;
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
background: var(--overlay-scrim);
|
||||||
|
}
|
||||||
|
|
||||||
|
.workspace-rename-card {
|
||||||
|
width: min(calc(100% - 32px), 320px);
|
||||||
|
padding: 16px;
|
||||||
|
border-radius: 16px;
|
||||||
|
background: var(--theme-surface-strong);
|
||||||
|
border: 1px solid var(--claude-border);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.workspace-rename-title {
|
||||||
|
font-size: 15px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--claude-text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.workspace-rename-input {
|
||||||
|
width: 100%;
|
||||||
|
height: 40px;
|
||||||
|
padding: 0 12px;
|
||||||
|
border: 1px solid var(--claude-border);
|
||||||
|
border-radius: 10px;
|
||||||
|
background: var(--surface-base);
|
||||||
|
color: var(--claude-text);
|
||||||
|
font-size: 14px;
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.workspace-rename-input:focus {
|
||||||
|
border-color: var(--accent-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.workspace-rename-actions {
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.workspace-rename-actions button {
|
||||||
|
height: 34px;
|
||||||
|
padding: 0 14px;
|
||||||
|
border: 0;
|
||||||
|
border-radius: 10px;
|
||||||
|
font-size: 14px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background 140ms ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.workspace-rename-cancel {
|
||||||
|
background: transparent;
|
||||||
|
color: var(--claude-text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.workspace-rename-cancel:hover {
|
||||||
|
background: var(--theme-tab-active);
|
||||||
|
}
|
||||||
|
|
||||||
|
.workspace-rename-confirm {
|
||||||
|
background: var(--accent-primary);
|
||||||
|
color: var(--accent-on-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.workspace-rename-confirm:hover {
|
||||||
|
background: var(--accent-primary-hover);
|
||||||
|
}
|
||||||
|
|
||||||
/* Final sidebar-specific guards against broad legacy rules. */
|
/* Final sidebar-specific guards against broad legacy rules. */
|
||||||
.conversation-sidebar .search-input {
|
.conversation-sidebar .search-input {
|
||||||
background: transparent !important;
|
background: transparent !important;
|
||||||
|
|||||||
@ -17,6 +17,7 @@ export const ICONS = Object.freeze({
|
|||||||
file: '/static/icons/file.svg',
|
file: '/static/icons/file.svg',
|
||||||
flag: '/static/icons/flag.svg',
|
flag: '/static/icons/flag.svg',
|
||||||
folder: '/static/icons/folder.svg',
|
folder: '/static/icons/folder.svg',
|
||||||
|
folderClosed: '/static/icons/folder-closed.svg',
|
||||||
folderOpen: '/static/icons/folder-open.svg',
|
folderOpen: '/static/icons/folder-open.svg',
|
||||||
globe: '/static/icons/globe.svg',
|
globe: '/static/icons/globe.svg',
|
||||||
hammer: '/static/icons/hammer.svg',
|
hammer: '/static/icons/hammer.svg',
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user