Compare commits

...

6 Commits

Author SHA1 Message Date
6618ab03dc feat(personalization): 将堆叠块显示模式保存到后端 personalization.json 2026-07-11 11:14:48 +08:00
5244768494 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
2026-07-11 01:51:01 +08:00
6b431ed51a fix(config): 修复 .env 中 ASTRION_DATA_ROOT 的 ~ 未展开导致 settings.json 未加载的问题
config/__init__.py 读取 .env 后,ASTRION_DATA_ROOT 可能为 ~/.astrion/astrion。
原代码直接用 Path(data_root) 构造 settings.json 路径,Path 不会展开 ~,
导致 settings.json 被判定不存在,env_vars(API key 等)未注入,
进而使 model_profiles 解析所有模型失败,抛出未配置可用模型。
2026-07-11 01:50:08 +08:00
3f3662d3a5 fix(chat): 修复极简模式摘要行运行动画右侧被截断的问题 2026-07-10 16:21:52 +08:00
8594b43d8a feat(avatar): 增大工具 SVG 显示尺寸到 66px 2026-07-10 15:58:45 +08:00
19746e67d3 feat(personalization): 将回答时必须考虑的信息从条列式改为大段文字输入 2026-07-10 15:40:18 +08:00
25 changed files with 2226 additions and 482 deletions

View File

@ -42,7 +42,7 @@ def _load_dotenv():
# 2) settings.json数据根下的统一配置
data_root = os.environ.get("ASTRION_DATA_ROOT", str(Path.home() / ".astrion" / "astrion"))
settings_path = Path(data_root) / "settings.json"
settings_path = Path(data_root).expanduser() / "settings.json"
if settings_path.exists():
try:
settings = json.loads(settings_path.read_text(encoding="utf-8"))

View File

@ -34,7 +34,7 @@ def _parse_paths(raw_value: str):
_env_prefix = "TERMINAL_SANDBOX_ENV_"
TERMINAL_SANDBOX_MODE = os.environ.get("TERMINAL_SANDBOX_MODE", "docker").lower()
TERMINAL_SANDBOX_MODE = os.environ.get("TERMINAL_SANDBOX_MODE", "host").lower()
TERMINAL_SANDBOX_IMAGE = os.environ.get("TERMINAL_SANDBOX_IMAGE", "python:3.11-slim")
TERMINAL_SANDBOX_MOUNT_PATH = os.environ.get("TERMINAL_SANDBOX_MOUNT_PATH", "/workspace")
TERMINAL_SANDBOX_SHELL = os.environ.get("TERMINAL_SANDBOX_SHELL", "/bin/bash")

View File

@ -252,7 +252,7 @@ class ToolsDefinitionContextToolsMixin:
"type": "function",
"function": {
"name": "manage_personalization",
"description": "管理用户个性化设置。支持读取所有配置或更新单个字段。可修改字段self_identifyAI自称最多20字、user_nameAI如何称呼用户最多20字、profession用户职业最多20字、tone交流语气最多20字、considerations注意事项列表字符串数组最多10项每项最多50字、theme主题配色classic-经典/light-明亮/dark-暗黑、communication_style交流风格default-标准AI风格/human_like-拟人聊天风格/auto-自动选择交流风格、conversation_continuity对话连续性high-高/medium-中/low-低)。更新时会自动验证格式,验证通过后立即保存并生效,新主题会立即应用到界面。",
"description": "管理用户个性化设置。支持读取所有配置或更新单个字段。可修改字段self_identifyAI自称最多20字、user_nameAI如何称呼用户最多20字、profession用户职业最多20字、tone交流语气最多20字、considerations回答时必须考虑的信息字符串最多2000字、theme主题配色classic-经典/light-明亮/dark-暗黑、communication_style交流风格default-标准AI风格/human_like-拟人聊天风格/auto-自动选择交流风格、conversation_continuity对话连续性high-高/medium-中/low-低)。更新时会自动验证格式,验证通过后立即保存并生效,新主题会立即应用到界面。",
"parameters": {
"type": "object",
"properties": self._inject_intent({
@ -267,7 +267,7 @@ class ToolsDefinitionContextToolsMixin:
"description": "要更新的字段名仅action=update时需要"
},
"value": {
"description": "新值仅action=update时需要。注意事项提供字符串数组,其他字段提供字符串"
"description": "新值仅action=update时需要。注意事项提供字符串,其他字段提供字符串"
}
}),
"required": ["action"]

View File

@ -72,8 +72,7 @@ from modules.personalization_manager import (
save_personalization_config,
build_personalization_prompt,
MAX_SHORT_FIELD_LENGTH,
MAX_CONSIDERATION_LENGTH,
MAX_CONSIDERATION_ITEMS,
MAX_CONSIDERATION_TEXT_LENGTH,
ALLOWED_THEMES,
ALLOWED_COMMUNICATION_STYLES,
ALLOWED_CONVERSATION_CONTINUITY,
@ -1830,7 +1829,7 @@ class MainTerminalToolsExecutionMixin:
"user_name": f"AI如何称呼用户: {result.get('user_name') or '(未设置)'}",
"profession": f"用户职业: {result.get('profession') or '(未设置)'}",
"tone": f"交流语气: {result.get('tone') or '(未设置)'}",
"considerations": f"注意事项: {len(result.get('considerations') or [])}",
"considerations": f"注意事项: {'已设置' if (result.get('considerations') or '').strip() else '未设置'}",
"theme": f"主题配色: {result.get('theme', 'classic')}",
"communication_style": f"交流风格: {result.get('communication_style', 'default')}",
"conversation_continuity": f"对话连续性: {result.get('conversation_continuity', 'medium')}"
@ -1874,17 +1873,11 @@ class MainTerminalToolsExecutionMixin:
validation_errors.append(f"{field} 不能超过 {MAX_SHORT_FIELD_LENGTH} 个字符")
elif field == "considerations":
# 注意事项数组验证
if not isinstance(value, list):
validation_errors.append("considerations 必须是字符串数组")
else:
if len(value) > MAX_CONSIDERATION_ITEMS:
validation_errors.append(f"considerations 不能超过 {MAX_CONSIDERATION_ITEMS}")
for i, item in enumerate(value):
if not isinstance(item, str):
validation_errors.append(f"considerations[{i}] 必须是字符串")
elif len(item) > MAX_CONSIDERATION_LENGTH:
validation_errors.append(f"considerations[{i}] 不能超过 {MAX_CONSIDERATION_LENGTH} 个字符")
# 注意事项文本验证
if not isinstance(value, str):
validation_errors.append("considerations 必须是字符串")
elif len(value) > MAX_CONSIDERATION_TEXT_LENGTH:
validation_errors.append(f"considerations 不能超过 {MAX_CONSIDERATION_TEXT_LENGTH} 个字符")
elif field == "theme":
# 主题验证

View File

@ -5,7 +5,7 @@ from __future__ import annotations
import json
from copy import deepcopy
from pathlib import Path
from typing import Any, Dict, Iterable, Optional, Union
from typing import Any, Dict, Optional, Union
try:
from config.limits import THINKING_FAST_INTERVAL
@ -22,6 +22,7 @@ ALLOWED_COMMUNICATION_STYLES = {"default", "human_like", "auto"}
ALLOWED_CONVERSATION_CONTINUITY = {"low", "medium", "high"}
ALLOWED_GOAL_REVIEW_MODES = {"readonly", "active"}
ALLOWED_GOAL_END_CONDITIONS = {"max_turns", "max_tokens"}
ALLOWED_BLOCK_DISPLAY_MODES = {"traditional", "stacked", "minimal"}
GOAL_MAX_TURNS_MIN = 1
GOAL_MAX_TURNS_MAX = 100
GOAL_MAX_TURNS_DEFAULT = 5
@ -30,7 +31,7 @@ GOAL_MAX_TOKENS_MAX = 100_000_000
PERSONALIZATION_FILENAME = "personalization.json"
MAX_SHORT_FIELD_LENGTH = 20
MAX_CONSIDERATION_LENGTH = 50
MAX_CONSIDERATION_TEXT_LENGTH = 2000
MAX_CONSIDERATION_ITEMS = 10
TONE_PRESETS = ["健谈", "幽默", "直言不讳", "鼓励性", "诗意", "企业商务", "打破常规", "同理心"]
THINKING_INTERVAL_MIN = 1
@ -94,6 +95,7 @@ DEFAULT_PERSONALIZATION_CONFIG: Dict[str, Any] = {
"silent_tool_disable": True, # 禁用工具时不向模型插入提示(默认开启)
"enhanced_tool_display": True, # 增强工具显示
"compact_message_display": "full", # 简略消息显示full-完整原始内容 / brief-一行概要
"block_display_mode": "stacked", # 堆叠块显示模式traditional-传统列表 / stacked-堆叠动画 / minimal-极简模式
"show_status_avatar": True, # 是否显示助手状态形象
"stacked_hide_borders": False, # 堆叠块隐藏边线
"show_git_status_bar": True, # 是否显示输入栏上方 Git 状态栏
@ -103,6 +105,9 @@ DEFAULT_PERSONALIZATION_CONFIG: Dict[str, Any] = {
"agents_md_auto_inject": False, # AGENTS.md 自动注入开关
"allow_root_file_creation": False, # 允许在根目录创建文件开关
"default_hide_workspace": False, # 默认隐藏工作区
"group_sidebar_by_workspace": False, # 侧边栏按工作区/项目分组显示对话
"sidebar_pinned_workspaces": [], # 分组侧边栏中永久置顶的工作区ID列表
"sidebar_workspace_order": [], # 分组侧边栏中非置顶工作区的显示顺序
"theme": "classic", # 主题配色: classic-经典/light-明亮/dark-暗黑
# 目标模式Goal Mode
"goal_review_mode": "readonly", # readonly-仅读对话判断 / active-允许审核智能体跑只读命令取证
@ -177,6 +182,25 @@ def load_personalization_config(base_dir: PathLike) -> Dict[str, Any]:
return deepcopy(DEFAULT_PERSONALIZATION_CONFIG)
def _sanitize_string_list(value: Any, max_length: int = 100) -> list:
"""Sanitize a list of non-empty strings, deduplicating and limiting length."""
if not isinstance(value, list):
return []
cleaned: list = []
seen = set()
for item in value:
if not isinstance(item, str):
continue
s = item.strip()
if not s or s in seen:
continue
seen.add(s)
cleaned.append(s)
if len(cleaned) >= max_length:
break
return cleaned
def sanitize_personalization_payload(
payload: Optional[Dict[str, Any]],
fallback: Optional[Dict[str, Any]] = None
@ -430,6 +454,13 @@ def sanitize_personalization_payload(
else:
base["compact_message_display"] = "full"
# 堆叠块显示模式traditional传统列表/ stacked堆叠动画/ minimal极简模式
block_display_mode = data.get("block_display_mode", base.get("block_display_mode"))
if isinstance(block_display_mode, str) and block_display_mode.strip().lower() in ALLOWED_BLOCK_DISPLAY_MODES:
base["block_display_mode"] = block_display_mode.strip().lower()
else:
base["block_display_mode"] = "stacked"
# 堆叠块隐藏边线
if "stacked_hide_borders" in data:
base["stacked_hide_borders"] = bool(data.get("stacked_hide_borders"))
@ -485,6 +516,32 @@ def sanitize_personalization_payload(
else:
base["default_hide_workspace"] = bool(base.get("default_hide_workspace", False))
# 侧边栏按工作区/项目分组显示对话
if "group_sidebar_by_workspace" in data:
base["group_sidebar_by_workspace"] = bool(data.get("group_sidebar_by_workspace"))
else:
base["group_sidebar_by_workspace"] = bool(base.get("group_sidebar_by_workspace", False))
# 分组侧边栏置顶工作区ID列表
if "sidebar_pinned_workspaces" in data:
base["sidebar_pinned_workspaces"] = _sanitize_string_list(
data.get("sidebar_pinned_workspaces"), max_length=100
)
else:
base["sidebar_pinned_workspaces"] = _sanitize_string_list(
base.get("sidebar_pinned_workspaces"), max_length=100
)
# 分组侧边栏工作区显示顺序
if "sidebar_workspace_order" in data:
base["sidebar_workspace_order"] = _sanitize_string_list(
data.get("sidebar_workspace_order"), max_length=200
)
else:
base["sidebar_workspace_order"] = _sanitize_string_list(
base.get("sidebar_workspace_order"), max_length=200
)
# 主题配色
theme_value = data.get("theme", base.get("theme"))
if isinstance(theme_value, str) and theme_value in ALLOWED_THEMES:
@ -705,12 +762,10 @@ def build_personalization_prompt(
if config.get("tone"):
lines.append(f"用户希望你使用 {config['tone']} 的语气与TA交流")
considerations: Iterable[str] = config.get("considerations") or []
considerations = [item for item in considerations if item]
considerations: str = config.get("considerations") or ""
if considerations:
lines.append("用户希望你在回答问题时必须考虑的信息是:")
for idx, item in enumerate(considerations, 1):
lines.append(f"{idx}. {item}")
lines.append(considerations)
conversation_continuity = str(config.get("conversation_continuity") or "medium").strip().lower()
if conversation_continuity == "high":
@ -761,20 +816,25 @@ def _sanitize_short_field(value: Optional[str]) -> str:
return text[:MAX_SHORT_FIELD_LENGTH]
def _sanitize_considerations(value: Any) -> list:
if not isinstance(value, list):
return []
cleaned = []
for item in value:
if not isinstance(item, str):
continue
text = item.strip()
if not text:
continue
cleaned.append(text[:MAX_CONSIDERATION_LENGTH])
if len(cleaned) >= MAX_CONSIDERATION_ITEMS:
break
return cleaned
def _sanitize_considerations(value: Any) -> str:
"""Sanitize considerations text. Legacy array data is joined with newlines."""
if value is None:
return ""
if isinstance(value, str):
return value[:MAX_CONSIDERATION_TEXT_LENGTH]
if isinstance(value, list):
cleaned = []
for item in value:
if not isinstance(item, str):
continue
text = item.strip()
if not text:
continue
cleaned.append(text)
if len(cleaned) >= MAX_CONSIDERATION_ITEMS:
break
return "\n".join(cleaned)[:MAX_CONSIDERATION_TEXT_LENGTH]
return ""
def _sanitize_thinking_interval(value: Any) -> Optional[int]:

View File

@ -63,7 +63,7 @@ def _set_terminal_workspace_label(terminal: WebTerminal, label: Optional[str]) -
pass
def _apply_workspace_personalization_preferences(terminal: WebTerminal, workspace) -> None:
def _apply_workspace_personalization_preferences(terminal: WebTerminal, workspace, update_session: bool = True) -> None:
"""Apply persisted workspace personalization after policy/workspace resolution."""
try:
config = load_personalization_config(workspace.data_dir)
@ -90,7 +90,7 @@ def _apply_workspace_personalization_preferences(terminal: WebTerminal, workspac
terminal._workspace_default_model_applied = True
except Exception:
pass
if has_request_context():
if has_request_context() and update_session:
session["run_mode"] = getattr(terminal, "run_mode", session.get("run_mode"))
session["thinking_mode"] = getattr(terminal, "thinking_mode", session.get("thinking_mode"))
session["model_key"] = getattr(terminal, "model_key", session.get("model_key"))
@ -128,7 +128,11 @@ def _ensure_workspace_skills_synced(terminal: WebTerminal, workspace) -> None:
debug_log(f"[Skills] 工作区同步异常: {exc}")
def get_user_resources(username: Optional[str] = None, workspace_id: Optional[str] = None) -> Tuple[Optional[WebTerminal], Optional['modules.user_manager.UserWorkspace']]:
def get_user_resources(
username: Optional[str] = None,
workspace_id: Optional[str] = None,
update_session: bool = True,
) -> Tuple[Optional[WebTerminal], Optional['modules.user_manager.UserWorkspace']]:
from modules.user_manager import UserWorkspace
username = (username or get_current_username())
if not username:
@ -277,7 +281,7 @@ def get_user_resources(username: Optional[str] = None, workspace_id: Optional[st
terminal.username = "host"
terminal.user_role = "admin"
terminal.quota_update_callback = None
if has_request_context():
if has_request_context() and update_session:
session['run_mode'] = terminal.run_mode
session['thinking_mode'] = terminal.thinking_mode
session['workspace_id'] = getattr(workspace, "workspace_id", None)
@ -287,7 +291,7 @@ def get_user_resources(username: Optional[str] = None, workspace_id: Optional[st
attach_user_broadcast(terminal, "host")
terminal.username = "host"
terminal.user_role = "admin"
if has_request_context():
if has_request_context() and update_session:
session['workspace_id'] = getattr(workspace, "workspace_id", None)
session['host_workspace_id'] = getattr(workspace, "workspace_id", None)
_set_terminal_workspace_label(
@ -327,7 +331,7 @@ def get_user_resources(username: Optional[str] = None, workspace_id: Optional[st
if candidate not in disabled_models:
try:
terminal.set_model(candidate)
if has_request_context():
if has_request_context() and update_session:
session["model_key"] = terminal.model_key
break
except Exception:
@ -410,7 +414,7 @@ def get_user_resources(username: Optional[str] = None, workspace_id: Optional[st
terminal.username = username
terminal.user_role = "api" if is_api_user else get_current_user_role(record)
terminal.quota_update_callback = (lambda metric=None: emit_user_quota_update(username)) if not is_api_user else None
if has_request_context():
if has_request_context() and update_session:
session['run_mode'] = terminal.run_mode
session['thinking_mode'] = terminal.thinking_mode
session['model_key'] = getattr(terminal, "model_key", None)
@ -421,7 +425,7 @@ def get_user_resources(username: Optional[str] = None, workspace_id: Optional[st
terminal.username = username
terminal.user_role = "api" if is_api_user else get_current_user_role(record)
terminal.quota_update_callback = (lambda metric=None: emit_user_quota_update(username)) if not is_api_user else None
if has_request_context():
if has_request_context() and update_session:
session['workspace_id'] = getattr(workspace, "workspace_id", None)
if is_api_user:
@ -466,14 +470,15 @@ def get_user_resources(username: Optional[str] = None, workspace_id: Optional[st
if candidate not in disabled_models:
try:
terminal.set_model(candidate)
session["model_key"] = terminal.model_key
if update_session:
session["model_key"] = terminal.model_key
break
except Exception:
continue
except Exception as exc:
debug_log(f"[admin_policy] 应用失败: {exc}")
_apply_workspace_personalization_preferences(terminal, workspace)
_apply_workspace_personalization_preferences(terminal, workspace, update_session=update_session)
_ensure_workspace_skills_synced(terminal, workspace)
return terminal, workspace

View File

@ -28,6 +28,7 @@ from config import (
DEFAULT_CONVERSATIONS_LIMIT,
MAX_CONVERSATIONS_LIMIT,
CONVERSATIONS_DIR,
DATA_DIR,
DEFAULT_RESPONSE_MAX_TOKENS,
DEFAULT_PROJECT_PATH,
LOGS_DIR,
@ -49,6 +50,8 @@ from modules.usage_tracker import QUOTA_DEFAULTS
from modules.sub_agent import TERMINAL_STATUSES
from modules.versioning_manager import ConversationVersioningManager, VersioningError
from modules.shallow_versioning import ShallowVersioningManager
from modules.host_workspace_manager import resolve_host_workspace
from utils.host_workspace_debug import write_host_workspace_debug
from utils.perf_log import perf_log, PerfTimer
from core.web_terminal import WebTerminal
from utils.tool_result_formatter import format_tool_result_for_context
@ -469,23 +472,88 @@ def upsert_input_draft(terminal: WebTerminal, workspace: UserWorkspace, username
return jsonify({"success": False, "error": f"保存输入草稿失败: {exc}"}), 500
# === 背景生成对话标题(从 app_legacy 拆分) ===
def _resolve_target_terminal_for_workspace(
username: str,
workspace_id: str,
current_terminal: WebTerminal,
current_workspace: UserWorkspace,
) -> tuple[WebTerminal, UserWorkspace]:
"""当请求指定了非当前 session 工作区时,解析并返回目标工作区的 terminal 与 workspace。"""
write_host_workspace_debug(
"sidebar-debug-api",
api="resolve_target_terminal",
requested_workspace_id=workspace_id,
session_workspace_id=session.get("workspace_id"),
current_terminal_project_path=str(getattr(current_terminal, "project_path", None)),
)
if _is_host_mode_request(username):
catalog, _ = resolve_host_workspace()
if not any(item.get("workspace_id") == workspace_id for item in (catalog.get("workspaces") or [])):
raise ValueError("工作区不存在")
else:
if workspace_id not in user_manager.list_user_workspaces(username):
raise ValueError("项目不存在")
try:
target_terminal, target_workspace = get_user_resources(
username, workspace_id=workspace_id, update_session=False
)
except RuntimeError as exc:
raise RuntimeError(str(exc)) from exc
if not target_terminal or not target_workspace:
raise RuntimeError("系统未初始化")
target_cm = getattr(getattr(target_terminal, "context_manager", None), "conversation_manager", None)
write_host_workspace_debug(
"sidebar-debug-api",
api="resolve_target_terminal_result",
requested_workspace_id=workspace_id,
target_terminal_project_path=str(getattr(target_terminal, "project_path", None)),
target_cm_workspace_id=getattr(target_cm, "current_workspace_id", None),
target_cm_conversations_dir=str(getattr(target_cm, "conversations_dir", None)),
)
return target_terminal, target_workspace
# === 背景生成对话标题(从 app_legacy 拆分) ===
@conversation_bp.route('/api/conversations', methods=['GET'])
@api_login_required
@with_terminal
def get_conversations(terminal: WebTerminal, workspace: UserWorkspace, username: str):
"""获取对话列表"""
"""获取对话列表,支持通过 workspace_id 查询指定工作区/项目。"""
try:
# 获取查询参数
limit = request.args.get('limit', 20, type=int)
offset = request.args.get('offset', 0, type=int)
non_empty = request.args.get('non_empty', '0') in ('1', 'true', 'True')
target_workspace_id = request.args.get('workspace_id', '', type=str).strip()
# 限制参数范围
limit = max(1, min(limit, 10000)) # 限制在1-10000之间
offset = max(0, offset)
if target_workspace_id:
try:
terminal, workspace = _resolve_target_terminal_for_workspace(
username, target_workspace_id, terminal, workspace
)
except ValueError as exc:
return jsonify({"success": False, "error": str(exc)}), 404
except RuntimeError as exc:
return jsonify({"success": False, "error": str(exc)}), 503
result = terminal.get_conversations_list(limit=limit, offset=offset, non_empty=non_empty)
cm = getattr(getattr(terminal, "context_manager", None), "conversation_manager", None)
write_host_workspace_debug(
"sidebar-debug-api",
api="GET /api/conversations",
workspace_id=target_workspace_id or None,
session_workspace_id=session.get("workspace_id"),
terminal_project_path=str(getattr(terminal, "project_path", None)),
cm_workspace_id=getattr(cm, "current_workspace_id", None),
cm_conversations_dir=str(getattr(cm, "conversations_dir", None)),
result_success=bool(result.get("success")),
result_count=len((result.get("data") or {}).get("conversations", [])),
)
if result["success"]:
return jsonify({
@ -511,7 +579,7 @@ def get_conversations(terminal: WebTerminal, workspace: UserWorkspace, username:
@api_login_required
@with_terminal
def create_conversation(terminal: WebTerminal, workspace: UserWorkspace, username: str):
"""创建新对话"""
"""创建新对话,支持通过 workspace_id 在指定工作区/项目中创建。"""
perf_log("create_conversation route enter", extra={"username": username})
t0 = time.perf_counter()
try:
@ -521,11 +589,22 @@ def create_conversation(terminal: WebTerminal, workspace: UserWorkspace, usernam
preserve_mode = bool(data.get('preserve_mode'))
thinking_mode = data.get('thinking_mode') if preserve_mode and 'thinking_mode' in data else None
run_mode = data.get('mode') if preserve_mode and 'mode' in data else None
target_workspace_id = (data.get('workspace_id') or '').strip()
if target_workspace_id:
try:
terminal, workspace = _resolve_target_terminal_for_workspace(
username, target_workspace_id, terminal, workspace
)
except ValueError as exc:
return jsonify({"success": False, "error": str(exc)}), 404
except RuntimeError as exc:
return jsonify({"success": False, "error": str(exc)}), 503
# 多工作区并行后,新建/切换对话只是前端视图导航,不应停止同工作区正在运行的主任务。
# 同工作区单任务互斥由 /api/tasks 创建任务时兜底限制;输入框禁用由前端根据任务状态处理。
workspace_id = session.get("workspace_id") or "default"
active_task = _get_active_workspace_task(username=username, workspace_id=workspace_id)
effective_workspace_id = target_workspace_id or session.get("workspace_id") or "default"
active_task = _get_active_workspace_task(username=username, workspace_id=effective_workspace_id)
if active_task:
# 运行中时只能创建“视图用”的新对话文件,不能调用 terminal.create_new_conversation。
# 后者会切换 context_manager.current_conversation_id导致运行任务后续内容串写到新对话。
@ -570,8 +649,10 @@ def create_conversation(terminal: WebTerminal, workspace: UserWorkspace, usernam
result = terminal.create_new_conversation(thinking_mode=thinking_mode, run_mode=run_mode)
if result["success"]:
session['run_mode'] = terminal.run_mode
session['thinking_mode'] = terminal.thinking_mode
# 仅在当前工作区创建时更新 session 模式;指定其他工作区时由前端切换后自动同步。
if not target_workspace_id:
session['run_mode'] = terminal.run_mode
session['thinking_mode'] = terminal.thinking_mode
perf_log("create_conversation before default versioning", elapsed_ms=(time.perf_counter() - t0) * 1000, extra={"conv_id": result.get("conversation_id")})
# 根据个性化设置,为新对话默认开启版本控制(安全导航路径也需要)。
try:
@ -651,9 +732,31 @@ def get_conversation_info(terminal: WebTerminal, workspace: UserWorkspace, usern
@api_login_required
@with_terminal
def load_conversation(conversation_id, terminal: WebTerminal, workspace: UserWorkspace, username: str):
"""加载特定对话"""
"""加载特定对话,支持通过 workspace_id 指定目标工作区/项目。"""
try:
workspace_id = session.get("workspace_id") or "default"
target_workspace_id = request.args.get('workspace_id', '', type=str).strip()
if target_workspace_id:
try:
terminal, workspace = _resolve_target_terminal_for_workspace(
username, target_workspace_id, terminal, workspace
)
except ValueError as exc:
return jsonify({"success": False, "error": str(exc)}), 404
except RuntimeError as exc:
return jsonify({"success": False, "error": str(exc)}), 503
cm = getattr(getattr(terminal, "context_manager", None), "conversation_manager", None)
write_host_workspace_debug(
"sidebar-debug-api",
api="PUT /api/conversations/load",
conversation_id=conversation_id,
target_workspace_id=target_workspace_id or None,
session_workspace_id=session.get("workspace_id"),
terminal_project_path=str(getattr(terminal, "project_path", None)),
cm_workspace_id=getattr(cm, "current_workspace_id", None),
cm_conversations_dir=str(getattr(cm, "conversations_dir", None)),
)
workspace_id = target_workspace_id or session.get("workspace_id") or "default"
active_task = _get_active_workspace_task(username=username, workspace_id=workspace_id)
if active_task:
# 同工作区有任务运行时,加载/查看其他对话不能改后端 terminal 当前上下文。
@ -712,10 +815,23 @@ def load_conversation(conversation_id, terminal: WebTerminal, workspace: UserWor
return jsonify(result)
else:
write_host_workspace_debug(
"sidebar-debug-api",
api="PUT /api/conversations/load",
conversation_id=conversation_id,
session_workspace_id=session.get("workspace_id"),
terminal_project_path=str(getattr(terminal, "project_path", None)),
cm_workspace_id=getattr(cm, "current_workspace_id", None),
cm_conversations_dir=str(getattr(cm, "conversations_dir", None)),
result_message=result.get("message", ""),
not_found=True,
)
return jsonify(result), 404 if "不存在" in result.get("message", "") else 500
except Exception as e:
import traceback
print(f"[API] 加载对话错误: {e}")
traceback.print_exc()
return jsonify({
"success": False,
"error": str(e),
@ -726,11 +842,22 @@ def load_conversation(conversation_id, terminal: WebTerminal, workspace: UserWor
@api_login_required
@with_terminal
def delete_conversation(conversation_id, terminal: WebTerminal, workspace: UserWorkspace, username: str):
"""删除特定对话"""
"""删除特定对话,支持通过 workspace_id 指定目标工作区/项目。"""
try:
target_workspace_id = request.args.get('workspace_id', '', type=str).strip()
if target_workspace_id:
try:
terminal, workspace = _resolve_target_terminal_for_workspace(
username, target_workspace_id, terminal, workspace
)
except ValueError as exc:
return jsonify({"success": False, "error": str(exc)}), 404
except RuntimeError as exc:
return jsonify({"success": False, "error": str(exc)}), 503
# 检查是否是当前对话
is_current = (terminal.context_manager.current_conversation_id == conversation_id)
result = terminal.delete_conversation(conversation_id)
if result["success"]:

View File

@ -325,7 +325,28 @@ def _open_file_with_app(file_path: Path, app_id: str) -> bool:
@api_login_required
@with_terminal
def open_project_in_file_manager(terminal, workspace, username):
project_path = Path(getattr(workspace, "project_path", "") or "").expanduser().resolve()
data = request.get_json(silent=True) or {}
target_workspace_id = (data.get("workspace_id") or "").strip()
if target_workspace_id:
if _is_host_mode_request():
catalog, _ = resolve_host_workspace()
target = next(
(item for item in (catalog.get("workspaces") or [])
if item.get("workspace_id") == target_workspace_id),
None,
)
if not target:
return jsonify({"success": False, "error": "工作区不存在"}), 404
project_path = Path(target.get("path") or "").expanduser().resolve()
else:
if target_workspace_id not in user_manager.list_user_workspaces(username):
return jsonify({"success": False, "error": "项目不存在"}), 404
ws_obj = user_manager.ensure_user_workspace(username, target_workspace_id)
project_path = Path(getattr(ws_obj, "project_path", "") or "").expanduser().resolve()
else:
project_path = Path(getattr(workspace, "project_path", "") or "").expanduser().resolve()
ok, root_text = _run_project_git(project_path, ["rev-parse", "--show-toplevel"])
target = Path(root_text).expanduser().resolve() if ok and root_text else project_path
if _open_path_in_file_manager(target):

View File

@ -66,6 +66,11 @@
:current-workspace-id="currentHostWorkspaceId"
:display-mode="chatDisplayMode"
:display-mode-disabled="displayModeSwitchDisabled"
:workspaces="hostWorkspaces"
:workspace-kind="versioningHostMode ? 'workspace' : 'project'"
:host-workspace-enabled="versioningHostMode || dockerProjectMode"
:group-by-workspace="personalizationStore?.form?.group_sidebar_by_workspace"
:versioning-host-mode="versioningHostMode"
@toggle="toggleSidebar"
@create="createNewConversation"
@search="handleSidebarSearchInput"
@ -79,6 +84,11 @@
@duplicate="duplicateConversation"
@toggle-workspace="handleWorkspaceToggle"
@toggle-display-mode="handleDisplayModeToggle"
@select-workspace-conversation="handleSelectWorkspaceConversation"
@create-workspace-conversation="createWorkspaceConversation"
@reveal-workspace="revealHostWorkspace"
@rename-workspace="handleRenameWorkspaceFromSidebar"
@pin-workspace="handlePinWorkspaceFromSidebar"
/>
<div v-if="!isMobileViewport" class="workspace-region">
@ -752,6 +762,11 @@
:current-workspace-id="currentHostWorkspaceId"
:display-mode="chatDisplayMode"
:display-mode-disabled="displayModeSwitchDisabled"
:workspaces="hostWorkspaces"
:workspace-kind="versioningHostMode ? 'workspace' : 'project'"
:host-workspace-enabled="versioningHostMode || dockerProjectMode"
:group-by-workspace="personalizationStore?.form?.group_sidebar_by_workspace"
:versioning-host-mode="versioningHostMode"
:show-collapse-button="true"
collapse-button-variant="close"
@toggle="closeMobileOverlay"
@ -766,6 +781,11 @@
@delete="deleteConversation"
@duplicate="duplicateConversation"
@toggle-display-mode="handleDisplayModeToggle"
@select-workspace-conversation="handleSelectWorkspaceConversation"
@create-workspace-conversation="createWorkspaceConversation"
@reveal-workspace="revealHostWorkspace"
@rename-workspace="handleRenameWorkspaceFromSidebar"
@pin-workspace="handlePinWorkspaceFromSidebar"
/>
</div>
</div>
@ -846,6 +866,7 @@ import { defineAsyncComponent, onMounted, ref } from 'vue';
import appOptions from './app';
import VideoPicker from './components/overlay/VideoPicker.vue';
import { useTutorialStore } from './stores/tutorial';
import { usePersonalizationStore } from './stores/personalization';
const VirtualMonitorSurface = defineAsyncComponent(
() => import('./components/chat/VirtualMonitorSurface.vue')
@ -855,6 +876,7 @@ const PathAuthorizationDialog = defineAsyncComponent(
);
const tutorialStore = useTutorialStore();
const personalizationStore = usePersonalizationStore();

View File

@ -106,7 +106,8 @@ export const computed = {
'conversationsOffset',
'conversationsLimit',
'runningWorkspaceTasks',
'acknowledgedCompletedTaskIds'
'acknowledgedCompletedTaskIds',
'workspaceGroups'
]),
...mapWritableState(useModelStore, ['currentModelKey']),
...mapState(useModelStore, ['models']),

View File

@ -161,6 +161,30 @@ export const actionMethods = {
placeholder,
...this.conversations.filter((conv) => conv && conv.id !== newConversationId)
];
// 分组视图下同步在当前工作区分组顶部插入占位
try {
const { useConversationStore } = await import('../../../stores/conversation');
const conversationStore = useConversationStore();
const currentWorkspaceId = this.currentHostWorkspaceId;
if (currentWorkspaceId) {
conversationStore.ensureWorkspaceGroup(currentWorkspaceId);
const group = conversationStore.workspaceGroups.find(
(g: any) => g.workspaceId === currentWorkspaceId
);
if (group) {
group.conversations = [
placeholder,
...group.conversations.filter((conv: any) => conv.id !== newConversationId)
];
group.expanded = true;
group.visibleOffset = 0;
}
}
} catch (_err) {
// ignore
}
window.setTimeout(() => {
const rest = { ...this.conversationInsertAnimations };
delete rest[newConversationId];
@ -168,6 +192,11 @@ export const actionMethods = {
this.conversationListAnimationMode = 'idle';
}, 700);
// 等待插入动画先完成一帧,避免立即加载对话导致列表闪烁/卡顿。
// 工作区旁新建不切换对话,所以不会触发此处的状态风暴;顶部新建需要切对话,
// 但把 loadConversation 放到入场动画基本结束后再执行,动画就和工作区旁一致。
await waitForAnimation(540);
// 直接加载新对话,确保状态一致
// 如果 socket 事件已把 currentConversationId 设置为新ID则强制加载一次以同步状态
await this.loadConversation(newConversationId, { force: true });
@ -176,9 +205,6 @@ export const actionMethods = {
currentConversationId: this.currentConversationId
});
// 等待顶部插入动画完成,避免刷新列表时打断“整体下移 + 从左到右出现”。
await waitForAnimation(540);
// 刷新对话列表获取最新统计
this.conversationsOffset = 0;
await this.loadConversationsList();
@ -187,6 +213,18 @@ export const actionMethods = {
conversationsLen: Array.isArray(this.conversations) ? this.conversations.length : 'n/a'
});
// 分组视图下刷新当前工作区数据,用 refresh 模式避免清空已加载内容导致闪烁
try {
const { useConversationStore } = await import('../../../stores/conversation');
const conversationStore = useConversationStore();
const currentWorkspaceId = this.currentHostWorkspaceId;
if (currentWorkspaceId) {
await conversationStore.loadWorkspaceConversations(currentWorkspaceId, { refresh: true });
}
} catch (_err) {
// ignore
}
await this.refreshRunningWorkspaceTasks?.();
} else {
console.error('创建对话失败:', result.message);
@ -210,7 +248,82 @@ export const actionMethods = {
}
}
},
async deleteConversation(conversationId) {
async createWorkspaceConversation(workspaceId: string) {
if (!workspaceId) return;
debugLog('在指定工作区创建新对话:', workspaceId);
try {
const response = await fetch('/api/conversations', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ workspace_id: workspaceId })
});
const result = await response.json();
if (result.success) {
const newConversationId = result.conversation_id;
debugLog('工作区新对话创建成功:', newConversationId);
const placeholder = {
id: newConversationId,
title: '新对话',
updated_at: new Date().toISOString(),
total_messages: 0,
total_tools: 0
};
// 统一播放“整体下移 + 新项从左侧滑入”的动画
this.conversationInsertAnimations = {
...this.conversationInsertAnimations,
[newConversationId]: 'create'
};
this.conversationListAnimationMode = 'create';
window.setTimeout(() => {
const rest = { ...this.conversationInsertAnimations };
delete rest[newConversationId];
this.conversationInsertAnimations = rest;
this.conversationListAnimationMode = 'idle';
}, 700);
// 立即在分组侧边栏插入占位,保证用户能立刻看到
try {
const { useConversationStore } = await import('../../../stores/conversation');
const conversationStore = useConversationStore();
conversationStore.ensureWorkspaceGroup(workspaceId);
const group = conversationStore.workspaceGroups.find((g: any) => g.workspaceId === workspaceId);
if (group) {
group.conversations = [
placeholder,
...group.conversations.filter((conv: any) => conv.id !== newConversationId)
];
group.expanded = true;
group.visibleOffset = 0;
}
// 延迟刷新该工作区列表以获取真实数据,用 refresh 模式保留占位避免闪烁
window.setTimeout(() => {
conversationStore.loadWorkspaceConversations(workspaceId, { refresh: true }).catch(() => {});
}, 300);
} catch (_err) {
// ignore
}
} else {
console.error('创建对话失败:', result.message);
this.uiPushToast({
title: '创建对话失败',
message: result.message || '服务器未返回成功状态',
type: 'error'
});
}
} catch (error) {
console.error('创建工作区对话异常:', error);
this.uiPushToast({
title: '创建工作区对话异常',
message: error.message || String(error),
type: 'error'
});
}
},
async deleteConversation(payload) {
const conversationId = typeof payload === 'string' ? payload : payload?.id;
const workspaceId = typeof payload === 'object' ? payload?.workspaceId : undefined;
if (!conversationId) return;
const confirmed = await this.confirmAction({
title: '删除对话',
message: '确定要删除这个对话吗?删除后无法恢复。',
@ -233,7 +346,10 @@ export const actionMethods = {
this.logMessageState('deleteConversation:start', { conversationId });
try {
const response = await fetch(`/api/conversations/${conversationId}`, {
const deleteUrl = workspaceId
? `/api/conversations/${conversationId}?workspace_id=${encodeURIComponent(workspaceId)}`
: `/api/conversations/${conversationId}`;
const response = await fetch(deleteUrl, {
method: 'DELETE'
});
@ -264,6 +380,20 @@ export const actionMethods = {
this.pendingDeletingConversationIds = [...deletingIds, conversationId];
}
await this.$nextTick();
// 分组侧边栏使用 transition-group 离场动画,立即从列表移除
try {
const { useConversationStore } = await import('../../../stores/conversation');
const conversationStore = useConversationStore();
conversationStore.workspaceGroups.forEach((group: any) => {
group.conversations = group.conversations.filter((conv: any) => conv.id !== conversationId);
const maxVisibleOffset = Math.max(0, group.conversations.length - group.visibleLimit);
if (group.visibleOffset > maxVisibleOffset) {
group.visibleOffset = maxVisibleOffset;
}
});
} catch (_err) {
// ignore
}
await waitForAnimation(430);
this.conversations = this.conversations.filter(
@ -365,6 +495,31 @@ export const actionMethods = {
this.conversationListAnimationMode = 'idle';
}, 860);
// 同步插入分组侧边栏对应工作区
try {
const { useConversationStore } = await import('../../../stores/conversation');
const conversationStore = useConversationStore();
const group = conversationStore.workspaceGroups.find((g: any) =>
g.conversations.some((conv: any) => conv.id === conversationId)
);
if (group) {
const sourceGroupIndex = group.conversations.findIndex((conv: any) => conv.id === conversationId);
const withoutDuplicate = group.conversations.filter((conv: any) => conv.id !== newId);
if (sourceGroupIndex >= 0) {
group.conversations = [
...withoutDuplicate.slice(0, sourceGroupIndex + 1),
duplicatePlaceholder,
...withoutDuplicate.slice(sourceGroupIndex + 1)
];
} else {
group.conversations = [duplicatePlaceholder, ...withoutDuplicate];
}
group.expanded = true;
}
} catch (_err) {
// ignore
}
// 先让“目标下方钻出 + 下方整体下移”动画播完,再加载复制出的对话。
await waitForAnimation(860);
await this.loadConversation(newId, { force: true, preserveListPosition: true });

View File

@ -76,6 +76,8 @@ export const loadMethods = {
async loadConversation(conversationId, options = {}) {
const force = Boolean(options.force);
const preserveListPosition = Boolean(options.preserveListPosition);
const workspaceId = options.workspaceId ? String(options.workspaceId) : '';
const isHostLikeMode = Boolean(this.versioningHostMode || this.dockerProjectMode);
debugLog('加载对话:', conversationId);
traceLog('loadConversation:start', {
conversationId,
@ -151,8 +153,11 @@ export const loadMethods = {
}).catch(() => {});
try {
// 1. 调用加载API
const response = await fetch(`/api/conversations/${conversationId}/load`, {
// 1. 调用加载API多工作区模式下携带目标 workspace_id避免并发/session 漂移导致加载错误工作区)
const loadUrl = workspaceId && isHostLikeMode
? `/api/conversations/${conversationId}/load?workspace_id=${encodeURIComponent(workspaceId)}`
: `/api/conversations/${conversationId}/load`;
const response = await fetch(loadUrl, {
method: 'PUT'
});
const result = await response.json();
@ -251,5 +256,68 @@ export const loadMethods = {
type: 'error'
});
}
},
async loadWorkspaceConversations(workspaceId: string, { reset = false } = {}) {
if (!workspaceId) return;
const { useConversationStore } = await import('../../../stores/conversation');
const conversationStore = useConversationStore();
let group = conversationStore.workspaceGroups.find((g: any) => g.workspaceId === workspaceId);
if (!group) {
group = {
workspaceId,
conversations: [],
loading: true,
hasMore: false,
loadingMore: false,
offset: 0,
limit: 20,
expanded: true
};
conversationStore.workspaceGroups.push(group);
}
if (reset) {
group.conversations = [];
group.offset = 0;
group.hasMore = false;
}
if (group.loading || group.loadingMore) return;
group.loading = true;
try {
const response = await fetch(`/api/conversations?workspace_id=${encodeURIComponent(workspaceId)}&limit=${group.limit}&offset=${group.offset}`);
const data = await response.json();
if (data.success) {
const items = (data.data?.conversations || []).map((conv: any) => ({
id: conv.id,
title: conv.title,
updated_at: conv.updated_at,
total_messages: conv.total_messages,
total_tools: conv.total_tools
}));
if (group.offset === 0) {
group.conversations = items;
} else {
group.conversations.push(...items);
}
group.hasMore = !!data.data?.has_more;
} else {
console.error('加载工作区对话失败:', data.error);
}
} catch (error) {
console.error('加载工作区对话异常:', error);
} finally {
group.loading = false;
}
},
async loadMoreWorkspaceConversations(workspaceId: string) {
const { useConversationStore } = await import('../../../stores/conversation');
const conversationStore = useConversationStore();
const group = conversationStore.workspaceGroups.find((g: any) => g.workspaceId === workspaceId);
if (!group || group.loadingMore || !group.hasMore) return;
group.loadingMore = true;
group.offset += group.limit;
await this.loadWorkspaceConversations(workspaceId);
group.loadingMore = false;
}
};

View File

@ -227,16 +227,36 @@ export const sendMethods = {
this.skipConversationHistoryReload = true;
this.currentConversationId = targetConversationId;
this.currentConversationTitle = '新对话';
this.conversations = [
{
id: targetConversationId,
title: '新对话',
updated_at: new Date().toISOString(),
total_messages: 0,
total_tools: 0
},
...this.conversations.filter((conv) => conv && conv.id !== targetConversationId)
];
const newPlaceholder = {
id: targetConversationId,
title: '新对话',
updated_at: new Date().toISOString(),
total_messages: 0,
total_tools: 0
};
this.conversations = [newPlaceholder, ...this.conversations.filter((conv) => conv && conv.id !== targetConversationId)];
// 分组视图下同步到当前工作区
try {
const { useConversationStore } = await import('../../../stores/conversation');
const conversationStore = useConversationStore();
const currentWorkspaceId = this.currentHostWorkspaceId;
if (currentWorkspaceId) {
conversationStore.ensureWorkspaceGroup(currentWorkspaceId);
const group = conversationStore.workspaceGroups.find(
(g: any) => g.workspaceId === currentWorkspaceId
);
if (group) {
group.conversations = [newPlaceholder, ...group.conversations.filter((conv: any) => conv.id !== targetConversationId)];
group.expanded = true;
group.visibleOffset = 0;
group.visibleLimit = 5;
}
}
} catch (_err) {
// ignore
}
const pathFragment = this.stripConversationPrefix(targetConversationId);
history.replaceState({ conversationId: targetConversationId }, '', `/${pathFragment}`);
} catch (error) {

View File

@ -350,6 +350,57 @@ export const hostWorkspaceMethods = {
this.hostWorkspaceManageSubmitting = false;
}
},
async handleSelectWorkspaceConversation(payload: { conversationId: string; workspaceId: string }) {
const { conversationId, workspaceId } = payload || {};
console.log('[sidebar-debug] handleSelectWorkspaceConversation', {
conversationId,
workspaceId,
currentHostWorkspaceId: this.currentHostWorkspaceId,
versioningHostMode: this.versioningHostMode,
dockerProjectMode: this.dockerProjectMode
});
if (!conversationId || !workspaceId) return;
if ((this.versioningHostMode || this.dockerProjectMode) && workspaceId !== this.currentHostWorkspaceId) {
console.log('[sidebar-debug] switching workspace', { from: this.currentHostWorkspaceId, to: workspaceId });
await this.handleHostWorkspaceSwitch(workspaceId);
}
console.log('[sidebar-debug] loading conversation', { conversationId, currentHostWorkspaceIdAfterSwitch: this.currentHostWorkspaceId });
await this.loadConversation(conversationId, { force: true, workspaceId });
},
async handleRenameWorkspaceFromSidebar(payload: { workspaceId: string; label: string }) {
const workspaceId = String(payload?.workspace_id || payload?.workspaceId || '').trim();
const label = String(payload?.label || '').trim();
if (!workspaceId || !label) return;
await this.renameHostWorkspace({ workspace_id: workspaceId, label });
},
async handlePinWorkspaceFromSidebar(_workspaceId: string) {
// 置顶状态由侧边栏组件本地维护并持久化到 localStorage此处无需额外操作。
},
async revealHostWorkspace(workspaceId: string) {
if (!(this.versioningHostMode || this.dockerProjectMode)) {
return;
}
const targetId = String(workspaceId || '').trim();
if (!targetId) return;
try {
const resp = await fetch('/api/project/open-in-file-manager', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ workspace_id: targetId })
});
const result = await resp.json().catch(() => ({}));
if (!resp.ok || !result?.success) {
throw new Error(result?.error || '打开失败');
}
} catch (error) {
const message = error instanceof Error ? error.message : String(error || '打开失败');
this.uiPushToast({
title: this.versioningHostMode ? '打开工作区失败' : '打开项目失败',
message,
type: 'error'
});
}
},
async setDefaultHostWorkspace(payload = {}) {
if (!(this.versioningHostMode || this.dockerProjectMode)) {
return;

View File

@ -151,7 +151,7 @@ function buildFaceInner(tool: ToolDef): string {
if (tool.raw) {
return tool.motion === 'bob' ? `<g class="sa-bob">${tool.raw}</g>` : tool.raw;
}
const s = tool.scale || 2.17;
const s = tool.scale || 2.75;
const scaled = `<g transform="translate(100, 100) scale(${s}) translate(-12, -12)">${tool.svg}</g>`;
const motionClass = tool.motion === 'wiggle' ? 'sa-wiggle' : 'sa-bob';
return `<g class="${motionClass}">${scaled}</g>`;

View File

@ -1093,8 +1093,8 @@ onBeforeUnmount(() => {
display: flex;
align-items: center;
justify-content: center;
width: 18px;
height: 18px;
min-width: 18px;
min-height: 18px;
}
.check-icon {

View File

@ -375,8 +375,14 @@ function renderEditFile(result: any, args: any): string {
if (details.length > 0) {
html += '<div class="tool-result-diff scrollable">';
const renderDiffLine = (lineNo: number | null, marker: string, text: string, className = '') => {
const lineNoText = typeof lineNo === 'number' && Number.isFinite(lineNo) ? String(lineNo) : '';
const renderDiffLine = (
lineNo: number | null,
marker: string,
text: string,
className = ''
) => {
const lineNoText =
typeof lineNo === 'number' && Number.isFinite(lineNo) ? String(lineNo) : '';
html += `<div class="diff-line${className ? ` ${className}` : ''}"><span class="diff-line-number">${escapeHtml(lineNoText)}</span><span class="diff-marker">${escapeHtml(marker)}</span><span class="diff-content">${escapeHtml(text)}</span></div>`;
};
const hunks: any[] = [];
@ -391,14 +397,24 @@ function renderEditFile(result: any, args: any): string {
if (oldText) {
const oldLines = oldText.split('\n');
oldLines.forEach((line: string, lineIdx: number) => {
rows.push({ lineNo: startLine === null ? null : startLine + lineIdx, marker: '-', text: line, className: 'diff-remove' });
rows.push({
lineNo: startLine === null ? null : startLine + lineIdx,
marker: '-',
text: line,
className: 'diff-remove'
});
});
}
if (newText) {
const newLines = newText.split('\n');
newLines.forEach((line: string, lineIdx: number) => {
rows.push({ lineNo: startLine === null ? null : startLine + lineIdx, marker: '+', text: line, className: 'diff-add' });
rows.push({
lineNo: startLine === null ? null : startLine + lineIdx,
marker: '+',
text: line,
className: 'diff-add'
});
});
}
return rows;
@ -410,30 +426,37 @@ function renderEditFile(result: any, args: any): string {
const rawBeforeLines = Array.isArray(context.before_lines) ? context.before_lines : [];
const rawAfterLines = Array.isArray(context.after_lines) ? context.after_lines : [];
const beforeLines =
rawBeforeLines.length >= 2 && rawBeforeLines.every((item: any) => String(item?.text ?? '') === '')
rawBeforeLines.length >= 2 &&
rawBeforeLines.every((item: any) => String(item?.text ?? '') === '')
? []
:
rawBeforeLines.length >= 2 && String(rawBeforeLines[0]?.text ?? '') === ''
? rawBeforeLines.slice(1)
: rawBeforeLines;
: rawBeforeLines.length >= 2 && String(rawBeforeLines[0]?.text ?? '') === ''
? rawBeforeLines.slice(1)
: rawBeforeLines;
const afterLines =
rawAfterLines.length >= 2 && rawAfterLines.every((item: any) => String(item?.text ?? '') === '')
rawAfterLines.length >= 2 &&
rawAfterLines.every((item: any) => String(item?.text ?? '') === '')
? []
:
rawAfterLines.length >= 2 && String(rawAfterLines[rawAfterLines.length - 1]?.text ?? '') === ''
? rawAfterLines.slice(0, -1)
: rawAfterLines;
: rawAfterLines.length >= 2 &&
String(rawAfterLines[rawAfterLines.length - 1]?.text ?? '') === ''
? rawAfterLines.slice(0, -1)
: rawAfterLines;
const contextOldStartLine = Number(context?.old_start_line ?? detail.matched_lines?.[0]);
const contextOldEndLine = Number(context?.old_end_line ?? contextOldStartLine);
const oldLineCount = Number.isFinite(contextOldStartLine) && Number.isFinite(contextOldEndLine)
? Math.max(1, contextOldEndLine - contextOldStartLine + 1)
: Math.max(1, (oldText ? oldText.split('\n').length : 1));
const oldLineCount =
Number.isFinite(contextOldStartLine) && Number.isFinite(contextOldEndLine)
? Math.max(1, contextOldEndLine - contextOldStartLine + 1)
: Math.max(1, oldText ? oldText.split('\n').length : 1);
const newLineCount = newText === '' ? 0 : newText.split('\n').length;
const afterContextOffset = newLineCount - oldLineCount;
beforeLines.forEach((item: any) => {
rows.push({ lineNo: Number(item.line), marker: ' ', text: String(item.text ?? ''), className: '' });
rows.push({
lineNo: Number(item.line),
marker: ' ',
text: String(item.text ?? ''),
className: ''
});
});
rows.push(...buildPairRows(context));
afterLines.forEach((item: any) => {
@ -441,28 +464,41 @@ function renderEditFile(result: any, args: any): string {
const lineNo = Number.isFinite(rawLineNo) ? rawLineNo + afterContextOffset : rawLineNo;
rows.push({ lineNo, marker: ' ', text: String(item.text ?? ''), className: '' });
});
const numberedRows = rows.filter((row) => typeof row.lineNo === 'number' && Number.isFinite(row.lineNo));
const numberedRows = rows.filter(
(row) => typeof row.lineNo === 'number' && Number.isFinite(row.lineNo)
);
hunks.push({
rows,
minLine: numberedRows.length ? Math.min(...numberedRows.map((row) => row.lineNo)) : null,
maxLine: numberedRows.length ? Math.max(...numberedRows.map((row) => row.lineNo)) : null,
minLine: numberedRows.length
? Math.min(...numberedRows.map((row) => row.lineNo))
: null,
maxLine: numberedRows.length ? Math.max(...numberedRows.map((row) => row.lineNo)) : null
});
});
} else {
const rows = buildPairRows();
const numberedRows = rows.filter((row) => typeof row.lineNo === 'number' && Number.isFinite(row.lineNo));
const numberedRows = rows.filter(
(row) => typeof row.lineNo === 'number' && Number.isFinite(row.lineNo)
);
hunks.push({
rows,
minLine: numberedRows.length ? Math.min(...numberedRows.map((row) => row.lineNo)) : null,
maxLine: numberedRows.length ? Math.max(...numberedRows.map((row) => row.lineNo)) : null,
maxLine: numberedRows.length ? Math.max(...numberedRows.map((row) => row.lineNo)) : null
});
}
});
const sortedHunks = hunks.sort((a, b) => (a.minLine ?? Number.MAX_SAFE_INTEGER) - (b.minLine ?? Number.MAX_SAFE_INTEGER));
const sortedHunks = hunks.sort(
(a, b) => (a.minLine ?? Number.MAX_SAFE_INTEGER) - (b.minLine ?? Number.MAX_SAFE_INTEGER)
);
const mergedHunks: any[] = [];
sortedHunks.forEach((hunk) => {
const last = mergedHunks[mergedHunks.length - 1];
if (last && hunk.minLine !== null && last.maxLine !== null && hunk.minLine <= last.maxLine + 1) {
if (
last &&
hunk.minLine !== null &&
last.maxLine !== null &&
hunk.minLine <= last.maxLine + 1
) {
last.rows.push(...hunk.rows);
last.maxLine = Math.max(last.maxLine, hunk.maxLine ?? last.maxLine);
} else {
@ -481,7 +517,8 @@ function renderEditFile(result: any, args: any): string {
return true;
})
.sort((a: any, b: any) => {
const lineDelta = (a.lineNo ?? Number.MAX_SAFE_INTEGER) - (b.lineNo ?? Number.MAX_SAFE_INTEGER);
const lineDelta =
(a.lineNo ?? Number.MAX_SAFE_INTEGER) - (b.lineNo ?? Number.MAX_SAFE_INTEGER);
return lineDelta || (markerOrder[a.marker] ?? 9) - (markerOrder[b.marker] ?? 9);
})
.forEach((row: any) => renderDiffLine(row.lineNo, row.marker, row.text, row.className));
@ -562,7 +599,10 @@ function renderReadFile(result: any, args: any): string {
matches.forEach((match: any, idx: number) => {
if (idx > 0) html += '<div class="search-match-separator">⋯</div>';
html += '<div class="search-match-item">';
const lineLabel = match.line_start === match.line_end ? `${match.line_start}` : `${match.line_start}-${match.line_end}`;
const lineLabel =
match.line_start === match.line_end
? `${match.line_start}`
: `${match.line_start}-${match.line_end}`;
html += `<div class="search-match-line">行 ${lineLabel}</div>`;
html += `<pre>${escapeHtml(match.snippet || match.content || '')}</pre>`;
html += '</div>';
@ -813,10 +853,12 @@ function renderConversationSearch(result: any, args: any): string {
const results = Array.isArray(result?.results) ? result.results : [];
const keywords = Array.isArray(result?.keywords)
? result.keywords
: (Array.isArray(args?.keywords) ? args.keywords : []);
: Array.isArray(args?.keywords)
? args.keywords
: [];
const keywordText = keywords.length
? keywords.join(' / ')
: (result?.query || args?.query || '(未指定)');
: result?.query || args?.query || '(未指定)';
let html = '<div class="tool-result-meta">';
html += `<div><strong>关键词:</strong>${escapeHtml(keywordText)}</div>`;
html += `<div><strong>日期范围:</strong>${escapeHtml(result?.start_date || args?.start_date || '不限')} ~ ${escapeHtml(result?.end_date || args?.end_date || '不限')}</div>`;
@ -941,21 +983,21 @@ function formatPersonalizationFieldValue(field: string, value: any): string {
return independenceMap[String(value)] || String(value);
}
if (field === 'considerations') {
if (!Array.isArray(value) || value.length === 0) {
if (typeof value !== 'string' || value.trim() === '') {
return '未设置';
}
return value.map((item) => String(item)).join('、');
return value.trim();
}
return String(value);
}
function renderAskUser(result: any, args: any): string {
const question = args.question || result.question || '';
const context = args.context || result.context || '';
const status = formatToolStatusLabel(result, '✓ 已回答');
const answerText = result.status === 'answered' ? String(result.answer_text || result.message || '').trim() : '';
const answerText =
result.status === 'answered' ? String(result.answer_text || result.message || '').trim() : '';
let html = '<div class="tool-result-meta">';
html += `<div><strong>状态:</strong>${escapeHtml(status)}</div>`;
@ -983,16 +1025,20 @@ function renderAskUser(result: any, args: any): string {
if (answerText) {
html += '<div class="tool-result-content">';
html += '<div class="tool-result-meta">';
answerText.split('\n').map((line) => line.trim()).filter(Boolean).forEach((line) => {
const separator = line.indexOf('');
if (separator > 0) {
const label = line.slice(0, separator + 1);
const value = line.slice(separator + 1);
html += `<div><strong>${escapeHtml(label)}</strong>${escapeHtml(value)}</div>`;
} else {
html += `<div><strong>用户回答:</strong>${escapeHtml(line)}</div>`;
}
});
answerText
.split('\n')
.map((line) => line.trim())
.filter(Boolean)
.forEach((line) => {
const separator = line.indexOf('');
if (separator > 0) {
const label = line.slice(0, separator + 1);
const value = line.slice(separator + 1);
html += `<div><strong>${escapeHtml(label)}</strong>${escapeHtml(value)}</div>`;
} else {
html += `<div><strong>用户回答:</strong>${escapeHtml(line)}</div>`;
}
});
html += '</div></div>';
}
@ -1001,10 +1047,7 @@ function renderAskUser(result: any, args: any): string {
function renderManagePersonalization(result: any, args: any): string {
const action = String(args.action || result.action || 'read');
const status = formatToolStatusLabel(
result,
action === 'update' ? '✓ 已更新' : '✓ 已读取'
);
const status = formatToolStatusLabel(result, action === 'update' ? '✓ 已更新' : '✓ 已读取');
let html = '<div class="tool-result-meta">';
html += `<div><strong>操作:</strong>${escapeHtml(action === 'update' ? '更新配置' : '读取配置')}</div>`;
@ -1118,7 +1161,8 @@ function renderCreateSubAgent(result: any, args: any): string {
const message = result.message ?? result.summary ?? '';
const stats = result.stats || {};
const runtimeSeconds = result.runtime_seconds ?? result.elapsed_seconds ?? stats.runtime_seconds ?? 0;
const runtimeSeconds =
result.runtime_seconds ?? result.elapsed_seconds ?? stats.runtime_seconds ?? 0;
const apiCalls = stats.api_calls ?? stats.turn_count ?? 0;
const toolCount =
(stats.files_read || 0) +
@ -1215,7 +1259,7 @@ function renderGetSubAgentStatus(result: any, args: any): string {
const found = item.found !== false;
const status = String(item.status || '');
const taskId = item.task_id ?? '';
const summary = item.summary ?? (item.final_result?.message) ?? (item.final_result?.summary) ?? '';
const summary = item.summary ?? item.final_result?.message ?? item.final_result?.summary ?? '';
html += '<div class="sub-agent-status-item">';
html += `<div class="sub-agent-status-header">子智能体 #${escapeHtml(String(agentId))}</div>`;
@ -1291,8 +1335,7 @@ function renderGetSubAgentStatus(result: any, args: any): string {
.code-block pre,
.output-block pre {
scrollbar-width: thin; /* Firefox */
scrollbar-color: color-mix(in srgb, var(--claude-text-secondary) 55%, transparent)
transparent;
scrollbar-color: color-mix(in srgb, var(--claude-text-secondary) 55%, transparent) transparent;
}
.tool-result-content.scrollable::-webkit-scrollbar,
@ -1571,7 +1614,11 @@ function renderGetSubAgentStatus(result: any, args: any): string {
/* 彩蛋 */
.easter-egg-content {
padding: 12px;
background: linear-gradient(135deg, color-mix(in srgb, var(--decorative-gold) 10%, transparent), color-mix(in srgb, var(--decorative-pink) 10%, transparent));
background: linear-gradient(
135deg,
color-mix(in srgb, var(--decorative-gold) 10%, transparent),
color-mix(in srgb, var(--decorative-pink) 10%, transparent)
);
border: 1px solid color-mix(in srgb, var(--decorative-gold) 30%, transparent);
border-radius: 8px;
font-size: 14px;

View File

@ -317,8 +317,14 @@ function renderEditFile(result: any, args: any): string {
if (details.length > 0) {
html += '<div class="tool-result-diff scrollable">';
const renderDiffLine = (lineNo: number | null, marker: string, text: string, className = '') => {
const lineNoText = typeof lineNo === 'number' && Number.isFinite(lineNo) ? String(lineNo) : '';
const renderDiffLine = (
lineNo: number | null,
marker: string,
text: string,
className = ''
) => {
const lineNoText =
typeof lineNo === 'number' && Number.isFinite(lineNo) ? String(lineNo) : '';
html += `<div class="diff-line${className ? ` ${className}` : ''}"><span class="diff-line-number">${escapeHtml(lineNoText)}</span><span class="diff-marker">${escapeHtml(marker)}</span><span class="diff-content">${escapeHtml(text)}</span></div>`;
};
const hunks: any[] = [];
@ -334,13 +340,23 @@ function renderEditFile(result: any, args: any): string {
const startLine = Number.isFinite(startLineCandidate) ? startLineCandidate : null;
if (oldText) {
oldLines.forEach((line: string, lineIdx: number) => {
rows.push({ lineNo: startLine === null ? null : startLine + lineIdx, marker: '-', text: line, className: 'diff-remove' });
rows.push({
lineNo: startLine === null ? null : startLine + lineIdx,
marker: '-',
text: line,
className: 'diff-remove'
});
});
}
if (newText) {
newLines.forEach((line: string, lineIdx: number) => {
rows.push({ lineNo: startLine === null ? null : startLine + lineIdx, marker: '+', text: line, className: 'diff-add' });
rows.push({
lineNo: startLine === null ? null : startLine + lineIdx,
marker: '+',
text: line,
className: 'diff-add'
});
});
}
return rows;
@ -352,30 +368,37 @@ function renderEditFile(result: any, args: any): string {
const rawBeforeLines = Array.isArray(context.before_lines) ? context.before_lines : [];
const rawAfterLines = Array.isArray(context.after_lines) ? context.after_lines : [];
const beforeLines =
rawBeforeLines.length >= 2 && rawBeforeLines.every((item: any) => String(item?.text ?? '') === '')
rawBeforeLines.length >= 2 &&
rawBeforeLines.every((item: any) => String(item?.text ?? '') === '')
? []
:
rawBeforeLines.length >= 2 && String(rawBeforeLines[0]?.text ?? '') === ''
? rawBeforeLines.slice(1)
: rawBeforeLines;
: rawBeforeLines.length >= 2 && String(rawBeforeLines[0]?.text ?? '') === ''
? rawBeforeLines.slice(1)
: rawBeforeLines;
const afterLines =
rawAfterLines.length >= 2 && rawAfterLines.every((item: any) => String(item?.text ?? '') === '')
rawAfterLines.length >= 2 &&
rawAfterLines.every((item: any) => String(item?.text ?? '') === '')
? []
:
rawAfterLines.length >= 2 && String(rawAfterLines[rawAfterLines.length - 1]?.text ?? '') === ''
? rawAfterLines.slice(0, -1)
: rawAfterLines;
: rawAfterLines.length >= 2 &&
String(rawAfterLines[rawAfterLines.length - 1]?.text ?? '') === ''
? rawAfterLines.slice(0, -1)
: rawAfterLines;
const contextOldStartLine = Number(context?.old_start_line ?? detail.matched_lines?.[0]);
const contextOldEndLine = Number(context?.old_end_line ?? contextOldStartLine);
const oldLineCount = Number.isFinite(contextOldStartLine) && Number.isFinite(contextOldEndLine)
? Math.max(1, contextOldEndLine - contextOldStartLine + 1)
: Math.max(1, (oldText ? oldText.split('\n').length : 1));
const oldLineCount =
Number.isFinite(contextOldStartLine) && Number.isFinite(contextOldEndLine)
? Math.max(1, contextOldEndLine - contextOldStartLine + 1)
: Math.max(1, oldText ? oldText.split('\n').length : 1);
const newLineCount = newText === '' ? 0 : newText.split('\n').length;
const afterContextOffset = newLineCount - oldLineCount;
beforeLines.forEach((item: any) => {
rows.push({ lineNo: Number(item.line), marker: ' ', text: String(item.text ?? ''), className: '' });
rows.push({
lineNo: Number(item.line),
marker: ' ',
text: String(item.text ?? ''),
className: ''
});
});
rows.push(...buildPairRows(context));
afterLines.forEach((item: any) => {
@ -383,28 +406,41 @@ function renderEditFile(result: any, args: any): string {
const lineNo = Number.isFinite(rawLineNo) ? rawLineNo + afterContextOffset : rawLineNo;
rows.push({ lineNo, marker: ' ', text: String(item.text ?? ''), className: '' });
});
const numberedRows = rows.filter((row) => typeof row.lineNo === 'number' && Number.isFinite(row.lineNo));
const numberedRows = rows.filter(
(row) => typeof row.lineNo === 'number' && Number.isFinite(row.lineNo)
);
hunks.push({
rows,
minLine: numberedRows.length ? Math.min(...numberedRows.map((row) => row.lineNo)) : null,
maxLine: numberedRows.length ? Math.max(...numberedRows.map((row) => row.lineNo)) : null,
minLine: numberedRows.length
? Math.min(...numberedRows.map((row) => row.lineNo))
: null,
maxLine: numberedRows.length ? Math.max(...numberedRows.map((row) => row.lineNo)) : null
});
});
} else {
const rows = buildPairRows();
const numberedRows = rows.filter((row) => typeof row.lineNo === 'number' && Number.isFinite(row.lineNo));
const numberedRows = rows.filter(
(row) => typeof row.lineNo === 'number' && Number.isFinite(row.lineNo)
);
hunks.push({
rows,
minLine: numberedRows.length ? Math.min(...numberedRows.map((row) => row.lineNo)) : null,
maxLine: numberedRows.length ? Math.max(...numberedRows.map((row) => row.lineNo)) : null,
maxLine: numberedRows.length ? Math.max(...numberedRows.map((row) => row.lineNo)) : null
});
}
});
const sortedHunks = hunks.sort((a, b) => (a.minLine ?? Number.MAX_SAFE_INTEGER) - (b.minLine ?? Number.MAX_SAFE_INTEGER));
const sortedHunks = hunks.sort(
(a, b) => (a.minLine ?? Number.MAX_SAFE_INTEGER) - (b.minLine ?? Number.MAX_SAFE_INTEGER)
);
const mergedHunks: any[] = [];
sortedHunks.forEach((hunk) => {
const last = mergedHunks[mergedHunks.length - 1];
if (last && hunk.minLine !== null && last.maxLine !== null && hunk.minLine <= last.maxLine + 1) {
if (
last &&
hunk.minLine !== null &&
last.maxLine !== null &&
hunk.minLine <= last.maxLine + 1
) {
last.rows.push(...hunk.rows);
last.maxLine = Math.max(last.maxLine, hunk.maxLine ?? last.maxLine);
} else {
@ -428,13 +464,19 @@ function renderEditFile(result: any, args: any): string {
// 仅变更行之间优先按 marker 分组(- 在前 + 在后),再按行号
if (aIsChange && bIsChange) {
const markerDelta = (markerOrder[a.marker] ?? 9) - (markerOrder[b.marker] ?? 9);
return markerDelta || (a.lineNo ?? Number.MAX_SAFE_INTEGER) - (b.lineNo ?? Number.MAX_SAFE_INTEGER);
return (
markerDelta ||
(a.lineNo ?? Number.MAX_SAFE_INTEGER) - (b.lineNo ?? Number.MAX_SAFE_INTEGER)
);
}
// 上下文行或跨类型:按行号优先
const lineDelta = (a.lineNo ?? Number.MAX_SAFE_INTEGER) - (b.lineNo ?? Number.MAX_SAFE_INTEGER);
const lineDelta =
(a.lineNo ?? Number.MAX_SAFE_INTEGER) - (b.lineNo ?? Number.MAX_SAFE_INTEGER);
return lineDelta || (markerOrder[a.marker] ?? 9) - (markerOrder[b.marker] ?? 9);
});
filteredRows.forEach((row: any) => renderDiffLine(row.lineNo, row.marker, row.text, row.className));
filteredRows.forEach((row: any) =>
renderDiffLine(row.lineNo, row.marker, row.text, row.className)
);
});
html += '</div>';
}
@ -512,7 +554,10 @@ function renderReadFile(result: any, args: any): string {
matches.forEach((match: any, idx: number) => {
if (idx > 0) html += '<div class="search-match-separator">⋯</div>';
html += '<div class="search-match-item">';
const lineLabel = match.line_start === match.line_end ? `${match.line_start}` : `${match.line_start}-${match.line_end}`;
const lineLabel =
match.line_start === match.line_end
? `${match.line_start}`
: `${match.line_start}-${match.line_end}`;
html += `<div class="search-match-line">行 ${lineLabel}</div>`;
html += `<pre>${escapeHtml(match.snippet || match.content || '')}</pre>`;
html += '</div>';
@ -794,10 +839,12 @@ function renderConversationSearch(result: any, args: any): string {
const results = Array.isArray(result?.results) ? result.results : [];
const keywords = Array.isArray(result?.keywords)
? result.keywords
: (Array.isArray(args?.keywords) ? args.keywords : []);
: Array.isArray(args?.keywords)
? args.keywords
: [];
const keywordText = keywords.length
? keywords.join(' / ')
: (result?.query || args?.query || '(未指定)');
: result?.query || args?.query || '(未指定)';
let html = '<div class="tool-result-meta">';
html += `<div><strong>关键词:</strong>${escapeHtml(keywordText)}</div>`;
html += `<div><strong>日期范围:</strong>${escapeHtml(result?.start_date || args?.start_date || '不限')} ~ ${escapeHtml(result?.end_date || args?.end_date || '不限')}</div>`;
@ -897,10 +944,10 @@ function formatPersonalizationFieldValue(field: string, value: any): string {
return value ? '开启' : '关闭';
}
if (field === 'considerations') {
if (!Array.isArray(value) || value.length === 0) {
if (typeof value !== 'string' || value.trim() === '') {
return '未设置';
}
return value.map((item) => String(item)).join('、');
return value.trim();
}
if (field === 'communication_style') {
const styleMap: Record<string, string> = {
@ -921,12 +968,12 @@ function formatPersonalizationFieldValue(field: string, value: any): string {
return String(value);
}
function renderAskUser(result: any, args: any): string {
const question = args.question || result.question || '';
const context = args.context || result.context || '';
const status = formatToolStatusLabel(result, '✓ 已回答');
const answerText = result.status === 'answered' ? String(result.answer_text || result.message || '').trim() : '';
const answerText =
result.status === 'answered' ? String(result.answer_text || result.message || '').trim() : '';
let html = '<div class="tool-result-meta">';
html += `<div><strong>状态:</strong>${escapeHtml(status)}</div>`;
@ -954,16 +1001,20 @@ function renderAskUser(result: any, args: any): string {
if (answerText) {
html += '<div class="tool-result-content">';
html += '<div class="tool-result-meta">';
answerText.split('\n').map((line) => line.trim()).filter(Boolean).forEach((line) => {
const separator = line.indexOf('');
if (separator > 0) {
const label = line.slice(0, separator + 1);
const value = line.slice(separator + 1);
html += `<div><strong>${escapeHtml(label)}</strong>${escapeHtml(value)}</div>`;
} else {
html += `<div><strong>用户回答:</strong>${escapeHtml(line)}</div>`;
}
});
answerText
.split('\n')
.map((line) => line.trim())
.filter(Boolean)
.forEach((line) => {
const separator = line.indexOf('');
if (separator > 0) {
const label = line.slice(0, separator + 1);
const value = line.slice(separator + 1);
html += `<div><strong>${escapeHtml(label)}</strong>${escapeHtml(value)}</div>`;
} else {
html += `<div><strong>用户回答:</strong>${escapeHtml(line)}</div>`;
}
});
html += '</div></div>';
}
@ -1086,7 +1137,8 @@ function renderCreateSubAgent(result: any, args: any): string {
const message = result.message ?? result.summary ?? '';
const stats = result.stats || {};
const runtimeSeconds = result.runtime_seconds ?? result.elapsed_seconds ?? stats.runtime_seconds ?? 0;
const runtimeSeconds =
result.runtime_seconds ?? result.elapsed_seconds ?? stats.runtime_seconds ?? 0;
const apiCalls = stats.api_calls ?? stats.turn_count ?? 0;
const toolCount =
(stats.files_read || 0) +
@ -1183,7 +1235,7 @@ function renderGetSubAgentStatus(result: any, args: any): string {
const found = item.found !== false;
const status = String(item.status || '');
const taskId = item.task_id ?? '';
const summary = item.summary ?? (item.final_result?.message) ?? (item.final_result?.summary) ?? '';
const summary = item.summary ?? item.final_result?.message ?? item.final_result?.summary ?? '';
html += '<div class="sub-agent-status-item">';
html += `<div class="sub-agent-status-header">子智能体 #${escapeHtml(String(agentId))}</div>`;

View File

@ -25,9 +25,7 @@
</button>
<div class="personalization-body settings-redesign-body" v-if="!loading">
<form
class="personal-form settings-redesign-form"
>
<form class="personal-form settings-redesign-form">
<div class="settings-redesign-layout">
<nav class="settings-redesign-tabs" aria-label="个人空间分组切换">
<button
@ -88,9 +86,7 @@
<label class="settings-toggle-row">
<span class="settings-row-copy">
<span class="settings-row-title">自动生成对话标题</span>
<span class="settings-row-desc"
>默认开启关闭后标题将沿用首条消息</span
>
<span class="settings-row-desc">默认开启关闭后标题将沿用首条消息</span>
</span>
<input
type="checkbox"
@ -273,54 +269,19 @@
</div>
</div>
</div>
<div class="settings-list-row tall">
<span class="settings-row-copy">
<div class="settings-textarea-row">
<div class="settings-row-copy">
<span class="settings-row-title">回答时必须考虑的信息</span>
<span class="settings-row-desc"
>最多 {{ maxConsiderations }} 可拖动排序</span
>
</span>
<div class="settings-input-stack wide">
<div class="settings-add-row">
<input
type="text"
:value="newConsideration"
maxlength="50"
placeholder="输入后点击 + 添加"
@input="personalization.updateNewConsideration($event.target.value)"
@focus="personalization.clearFeedback()"
/>
<button
type="button"
:disabled="
!newConsideration || form.considerations.length >= maxConsiderations
"
@click="personalization.addConsideration()"
>
+
</button>
</div>
<ul class="settings-consideration-list" v-if="form.considerations.length">
<li
v-for="(item, idx) in form.considerations"
:key="`consideration-${idx}`"
draggable="true"
@dragstart="personalization.considerationDragStart(idx, $event)"
@dragover.prevent="personalization.considerationDragOver(idx, $event)"
@drop.prevent="personalization.considerationDrop(idx, $event)"
@dragend="personalization.considerationDragEnd()"
>
<span class="drag-handle" aria-hidden="true"></span>
<span>{{ item }}</span>
<button
type="button"
@click="personalization.removeConsideration(idx)"
>
</button>
</li>
</ul>
<span class="settings-row-desc">每次回答前都会参考这些背景信息</span>
</div>
<textarea
:value="form.considerations"
rows="6"
maxlength="2000"
placeholder="例如用户家有一只泰迪犬棕色公狗2014年开始养的..."
@input="personalization.updateConsiderations($event.target.value)"
@focus="personalization.clearFeedback()"
></textarea>
</div>
<label
v-if="currentBlockDisplayMode === 'stacked'"
@ -328,9 +289,7 @@
>
<span class="settings-row-copy">
<span class="settings-row-title">隐藏块间边线</span>
<span class="settings-row-desc"
>去掉堆叠块之间的分割线更简洁</span
>
<span class="settings-row-desc">去掉堆叠块之间的分割线更简洁</span>
</span>
<input
type="checkbox"
@ -453,7 +412,9 @@
<div class="settings-action-row">
<span class="settings-row-copy">
<span class="settings-row-title">上传诊断日志</span>
<span class="settings-row-desc">复现问题后点击把当前环境信息上传给开发者排查</span>
<span class="settings-row-desc"
>复现问题后点击把当前环境信息上传给开发者排查</span
>
</span>
<button
type="button"
@ -640,8 +601,31 @@
><input
type="checkbox"
:checked="form.default_hide_workspace"
@change="applyDefaultHideWorkspaceOption($event.target.checked)"
/><span class="fancy-check" aria-hidden="true"
@change="applyDefaultHideWorkspaceOption($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"
><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"
@ -802,9 +786,7 @@
>
<span class="settings-row-copy">
<span class="settings-row-title">隐藏块间边线</span>
<span class="settings-row-desc"
>去掉堆叠块之间的分割线更简洁</span
>
<span class="settings-row-desc">去掉堆叠块之间的分割线更简洁</span>
</span>
<input
type="checkbox"
@ -1006,8 +988,7 @@
:class="{ selected: form.versioning_backup_mode === 'shallow' }"
@click="selectVersioningBackupMode('shallow')"
>
<strong>浅备份</strong
><span>速度快只回溯被编辑过的文件</span
<strong>浅备份</strong><span>速度快只回溯被编辑过的文件</span
><svg viewBox="0 0 24 24"><path d="M5 12.5 9.5 17 19 7" /></svg>
</button>
<button
@ -1016,8 +997,7 @@
:class="{ selected: form.versioning_backup_mode === 'full' }"
@click="selectVersioningBackupMode('full')"
>
<strong>完全备份</strong
><span>完整工作区快照首次创建可能较慢</span
<strong>完全备份</strong><span>完整工作区快照首次创建可能较慢</span
><svg viewBox="0 0 24 24"><path d="M5 12.5 9.5 17 19 7" /></svg>
</button>
</div>
@ -1379,9 +1359,7 @@
<div class="settings-group-block">
<div class="settings-group-title">
<span class="settings-row-title">强约束系统</span
><span class="settings-row-desc"
>仅对个人空间中已启用的 skill 生效</span
>
><span class="settings-row-desc">仅对个人空间中已启用的 skill 生效</span>
</div>
<label class="settings-toggle-row inner"
><span class="settings-row-title">Terminal 系列工具</span
@ -1460,7 +1438,8 @@
<div class="settings-group-title">
<span class="settings-row-title">可用 Skills</span
><span class="settings-row-desc"
>勾选后会注入 system prompt并同步到工作区的 .astrion/skills/ 目录</span
>勾选后会注入 system prompt并同步到工作区的 .astrion/skills/
目录</span
>
</div>
<div class="settings-check-grid">
@ -1487,9 +1466,7 @@
<div class="settings-group-block" v-if="toolCategories.length">
<div class="settings-group-title">
<span class="settings-row-title">默认禁用工具类别</span
><span class="settings-row-desc"
>选择后这些类别在新任务中保持关闭</span
>
><span class="settings-row-desc">选择后这些类别在新任务中保持关闭</span>
</div>
<div class="settings-check-grid">
<label
@ -1627,15 +1604,19 @@
</div>
</section>
<section
v-else-if="activeTab === 'voice'"
key="voice"
class="settings-page"
>
<div class="settings-section" style="margin-bottom: 16px;">
<p class="settings-section-desc" style="margin: 0; color: var(--text-secondary); font-size: 13px; line-height: 1.6;">
端侧语音识别模型SenseVoice int8支持中英混说 + 自动标点
模型约 228MB仅在手机本地运行无需网络
<section v-else-if="activeTab === 'voice'" key="voice" class="settings-page">
<div class="settings-section" style="margin-bottom: 16px">
<p
class="settings-section-desc"
style="
margin: 0;
color: var(--text-secondary);
font-size: 13px;
line-height: 1.6;
"
>
端侧语音识别模型SenseVoice int8支持中英混说 + 自动标点 模型约
228MB仅在手机本地运行无需网络
</p>
</div>
<div class="settings-action-row">
@ -1643,25 +1624,35 @@
<span class="settings-row-title">语音识别模型</span>
<span class="settings-row-desc">
<template v-if="voiceModelReady">已下载 (228MB)</template>
<template v-else-if="voiceDownloading">下载中 {{ voiceDownloadPercent }}% {{ voiceDownloadMsg }}</template>
<template v-else-if="voiceModelPartial">下载不完整请重新下载</template>
<template v-else-if="voiceDownloading"
>下载中 {{ voiceDownloadPercent }}% {{ voiceDownloadMsg }}</template
>
<template v-else-if="voiceModelPartial"
>下载不完整请重新下载</template
>
<template v-else>未下载 点击下载</template>
</span>
</span>
<div style="display: flex; gap: 6px;">
<div style="display: flex; gap: 6px">
<button
type="button"
class="settings-secondary-button"
:disabled="voiceDownloading"
@click="downloadVoiceModel"
>
{{ voiceDownloading ? '下载中...' : voiceModelReady ? '重新下载' : '下载模型' }}
{{
voiceDownloading
? '下载中...'
: voiceModelReady
? '重新下载'
: '下载模型'
}}
</button>
<button
v-if="voiceModelReady || voiceModelPartial"
type="button"
class="settings-secondary-button"
style="color: var(--state-error);"
style="color: var(--state-error)"
:disabled="voiceDownloading"
@click="deleteVoiceModel"
>
@ -1669,9 +1660,11 @@
</button>
</div>
</div>
<div class="settings-action-row" style="margin-top: 8px;">
<div class="settings-action-row" style="margin-top: 8px">
<span class="settings-row-copy">
<span class="settings-row-desc" style="font-size: 12px;">点击麦克风闪退先复现一次再点上传日志</span>
<span class="settings-row-desc" style="font-size: 12px"
>点击麦克风闪退先复现一次再点上传日志</span
>
</span>
<button
type="button"
@ -1681,11 +1674,20 @@
上传日志
</button>
</div>
<div v-if="voiceDownloading" class="voice-download-bar" style="margin: 8px 0 0;">
<div
v-if="voiceDownloading"
class="voice-download-bar"
style="margin: 8px 0 0"
>
<div class="voice-download-track">
<div class="voice-download-fill" :style="{ width: voiceDownloadPercent + '%' }"></div>
<div
class="voice-download-fill"
:style="{ width: voiceDownloadPercent + '%' }"
></div>
</div>
<span style="font-size: 11px; color: var(--text-secondary); margin-top: 4px;">{{ voiceDownloadPercent }}%</span>
<span style="font-size: 11px; color: var(--text-secondary); margin-top: 4px"
>{{ voiceDownloadPercent }}%</span
>
</div>
</section>
@ -1737,9 +1739,7 @@
<div class="settings-action-row">
<span class="settings-row-copy"
><span class="settings-row-title">自定义工具管理</span
><span class="settings-row-desc"
>在线创建/编辑自定义工具文件</span
></span
><span class="settings-row-desc">在线创建/编辑自定义工具文件</span></span
><button
type="button"
class="settings-secondary-button"
@ -1796,8 +1796,6 @@ const {
loading,
form,
tonePresets,
newConsideration,
maxConsiderations,
status,
error,
saving,
@ -1858,7 +1856,7 @@ const baseTabs = [
{ id: 'tools', label: '工具与 Skills', icon: 'wrench' },
{ id: 'files', label: '文件与图片', icon: 'file' },
{ id: 'data', label: '数据管理', icon: 'layers' },
{ id: 'voice', label: '语音模型', icon: 'mic' },
{ id: 'voice', label: '语音模型', icon: 'mic' }
] as const satisfies ReadonlyArray<{ id: PersonalTab; label: string; icon: IconKey }>;
const sessionRole = ref('');
@ -1917,7 +1915,9 @@ const checkVoiceModel = () => {
if (!voiceModelReady.value && typeof bridge.isModelPartial === 'function') {
voiceModelPartial.value = bridge.isModelPartial();
}
} catch (_) { /* ignore */ }
} catch (_) {
/* ignore */
}
}
};
@ -1969,7 +1969,10 @@ const saveDebugLog = async () => {
if (typeof bridge.collectDebugLog === 'function') {
log = bridge.collectDebugLog();
}
if (!log) { alert('无法收集日志'); return; }
if (!log) {
alert('无法收集日志');
return;
}
try {
const res = await fetch('/api/voice_debug', { method: 'POST', body: log });
if (res.ok) {
@ -2157,9 +2160,7 @@ const blockDisplayLabel = computed(() => {
);
});
const currentCompactMessageDisplay = computed(
() => form.value.compact_message_display || 'full'
);
const currentCompactMessageDisplay = computed(() => form.value.compact_message_display || 'full');
const compactMessageDisplayLabel = computed(() => {
return (
@ -3006,7 +3007,6 @@ const applyDefaultHideWorkspaceOption = async (hidden: boolean) => {
-ms-overflow-style: none;
}
.settings-redesign-tabs::-webkit-scrollbar,
.settings-redesign-scroll::-webkit-scrollbar,
.settings-floating-menu::-webkit-scrollbar,
@ -3379,37 +3379,41 @@ const applyDefaultHideWorkspaceOption = async (hidden: boolean) => {
flex: 1 1 auto;
}
.settings-consideration-list {
margin: 0;
padding: 0;
list-style: none;
display: grid;
gap: 6px;
.settings-textarea-row {
min-height: 0;
border-bottom: 1px solid var(--theme-control-border);
display: flex;
flex-direction: column;
gap: 10px;
padding: 14px 0;
color: var(--claude-text);
}
.settings-consideration-list li {
min-height: 34px;
.settings-textarea-row textarea {
width: 100%;
min-height: 120px;
max-height: 320px;
border: 1px solid var(--theme-control-border);
border-radius: 12px;
padding: 0 8px;
display: grid;
grid-template-columns: 18px minmax(0, 1fr) 28px;
gap: 8px;
align-items: center;
color: var(--claude-text);
font-size: 13px;
}
.settings-consideration-list button {
border: 0;
background: transparent;
color: var(--claude-text-secondary);
cursor: pointer;
color: var(--claude-text);
padding: 12px;
outline: none;
font-size: 13px;
line-height: 1.55;
resize: vertical;
overflow-y: auto;
font-family: inherit;
}
.drag-handle {
.settings-textarea-row textarea:focus {
border-color: var(--claude-text-secondary);
box-shadow: none;
}
.settings-textarea-row textarea::placeholder {
color: var(--claude-text-secondary);
cursor: grab;
opacity: 0.6;
}
.settings-toggle-row {

View File

@ -104,7 +104,7 @@
</div>
<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>
<button
v-for="task in runningTaskItems"
@ -128,99 +128,318 @@
</span>
</button>
</div>
<div
v-if="loading && !displayConversations.length && !searchActive && !isDeletingConversation"
class="loading-conversations"
>
正在加载...
</div>
<div v-else-if="!displayConversations.length && !loading && !isDeletingConversation" class="no-conversations">
{{ searchActive ? '未找到匹配对话' : '暂无对话记录' }}
</div>
<transition-group
v-else
name="conversation-delete"
tag="div"
class="conversation-list-items"
:class="`animation-mode-${conversationListAnimationMode}`"
>
<div
v-for="conv in displayConversations"
:key="conv.id"
class="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',
deleting: pendingDeletingConversationIds.includes(conv.id)
}"
@click="
openActionMenuId = null;
$emit('select', conv.id);
"
>
<div class="conversation-title">{{ conv.title }}</div>
<template v-if="isGroupByWorkspaceActive && !searchActive">
<div class="workspace-groups">
<div
class="conversation-action-wrap"
:class="{ 'menu-open': openActionMenuId === conv.id }"
@click.stop
v-for="ws in sortedWorkspaces"
:key="String(ws.workspace_id || ws.label)"
class="workspace-group"
>
<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="更多操作"
:aria-expanded="openActionMenuId === conv.id"
@click="toggleActionMenu(conv.id)"
>
<span aria-hidden="true"></span>
</button>
<div
v-if="!isConversationActive(conv.id) && !isConversationCompleted(conv.id)"
class="conversation-actions-menu"
:class="{ open: openActionMenuId === conv.id }"
class="workspace-group-header"
:class="{ active: String(ws.workspace_id || '') === currentWorkspaceId }"
@mouseenter="hoverWorkspaceId = String(ws.workspace_id || '')"
@mouseleave="hoverWorkspaceId = null"
>
<button
type="button"
@click="
openActionMenuId = null;
$emit('duplicate', conv.id);
"
class="workspace-group-toggle"
@click="toggleWorkspaceExpanded(String(ws.workspace_id || ''))"
>
<span class="icon icon-sm" :style="iconStyle('copy')" aria-hidden="true"></span>
<span>复制</span>
<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>
<button
type="button"
class="danger"
@click="
openActionMenuId = null;
$emit('delete', conv.id);
"
<div
class="workspace-group-actions"
:class="{ visible: hoverWorkspaceId === String(ws.workspace_id || '') || openWorkspaceMenuId === String(ws.workspace_id || '') }"
>
<span class="icon icon-sm" :style="iconStyle('trash')" aria-hidden="true"></span>
<span>删除</span>
</button>
<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>
</transition-group>
<div v-if="!searchActive && hasMore" class="load-more">
</template>
<template v-else>
<div
v-if="loading && !displayConversations.length && !searchActive && !isDeletingConversation"
class="loading-conversations"
>
正在加载...
</div>
<div v-else-if="!displayConversations.length && !loading && !isDeletingConversation" class="no-conversations">
{{ searchActive ? '未找到匹配对话' : '暂无对话记录' }}
</div>
<transition-group
v-else
name="conversation-delete"
tag="div"
class="conversation-list-items"
:class="`animation-mode-${conversationListAnimationMode}`"
>
<div
v-for="conv in displayConversations"
:key="conv.id"
class="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',
deleting: pendingDeletingConversationIds.includes(conv.id)
}"
@click="
openActionMenuId = null;
$emit('select', conv.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="!isConversationActive(conv.id) && !isConversationCompleted(conv.id) && openActionMenuId === 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', conv.id);
"
>
<span class="icon icon-sm" :style="iconStyle('trash')" aria-hidden="true"></span>
<span>删除</span>
</button>
</div>
</div>
</div>
</transition-group>
</template>
<div v-if="!searchActive && !isGroupByWorkspaceActive && hasMore" class="load-more">
<button
class="load-more-btn"
type="button"
@ -251,6 +470,35 @@
</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 }">
<button
type="button"
@ -278,7 +526,7 @@
<script setup lang="ts">
defineOptions({ name: 'ConversationSidebar' });
import { computed, onBeforeUnmount, onMounted, ref } from 'vue';
import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue';
import { storeToRefs } from 'pinia';
import { useUiStore } from '@/stores/ui';
import { useConversationStore } from '@/stores/conversation';
@ -294,6 +542,11 @@ const props = withDefaults(
displayModeDisabled?: boolean;
runningTasks?: any[];
currentWorkspaceId?: string;
workspaces?: any[];
workspaceKind?: 'workspace' | 'project';
hostWorkspaceEnabled?: boolean;
groupByWorkspace?: boolean;
versioningHostMode?: boolean;
}>(),
{
showCollapseButton: true,
@ -301,11 +554,16 @@ const props = withDefaults(
displayMode: 'chat',
displayModeDisabled: false,
runningTasks: () => [],
currentWorkspaceId: ''
currentWorkspaceId: '',
workspaces: () => [],
workspaceKind: 'workspace',
hostWorkspaceEnabled: false,
groupByWorkspace: false,
versioningHostMode: false
}
);
defineEmits<{
const emit = defineEmits<{
(event: 'toggle'): void;
(event: 'create'): void;
(event: 'search', value: string): void;
@ -319,6 +577,11 @@ defineEmits<{
(event: 'duplicate', id: string): void;
(event: 'toggle-workspace'): 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();
@ -354,21 +617,236 @@ const monitorModeActive = computed(
);
const displayModeDisabled = computed(() => props.displayModeDisabled);
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) => {
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 = () => {
openActionMenuId.value = null;
};
onMounted(() => {
document.addEventListener('click', closeActionMenu);
});
const pinnedWorkspaceIds = ref<Set<string>>(new Set());
const workspaceOrder = ref<string[]>([]);
const sidebarSortLoaded = ref(false);
onBeforeUnmount(() => {
document.removeEventListener('click', closeActionMenu);
const syncPinnedFromStore = () => {
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(() =>
@ -416,4 +894,146 @@ const isConversationActive = (conversationId: string) =>
activeConversationIds.value.has(String(conversationId || ''));
const isConversationCompleted = (conversationId: string) =>
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>

View File

@ -875,6 +875,16 @@ export async function initializeLegacySocket(ctx: any) {
ctx.hasMoreConversations = false;
ctx.loadingMoreConversations = false;
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
}
});
// 监听状态更新事件

View File

@ -8,6 +8,20 @@ export interface ConversationSummary {
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 {
conversations: ConversationSummary[];
searchResults: ConversationSummary[];
@ -30,6 +44,7 @@ interface ConversationState {
conversationsLimit: number;
runningWorkspaceTasks: any[];
acknowledgedCompletedTaskIds: string[];
workspaceGroups: WorkspaceConversationGroup[];
}
export const useConversationStore = defineStore('conversation', {
@ -54,7 +69,8 @@ export const useConversationStore = defineStore('conversation', {
conversationsOffset: 0,
conversationsLimit: 20,
runningWorkspaceTasks: [],
acknowledgedCompletedTaskIds: []
acknowledgedCompletedTaskIds: [],
workspaceGroups: []
}),
actions: {
resetConversations() {
@ -79,6 +95,171 @@ export const useConversationStore = defineStore('conversation', {
clearTimeout(this.searchTimer);
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;
}
}
});

View File

@ -26,6 +26,7 @@ interface PersonalForm {
silent_tool_disable: boolean;
enhanced_tool_display: boolean;
compact_message_display: CompactMessageDisplay;
block_display_mode: BlockDisplayMode;
show_status_avatar: boolean;
show_git_status_bar: boolean;
auto_open_terminal_panel: boolean;
@ -37,7 +38,7 @@ interface PersonalForm {
use_custom_names: boolean;
profession: string;
tone: string;
considerations: string[];
considerations: string;
thinking_interval: number | null;
disabled_tool_categories: string[];
default_run_mode: RunMode | null;
@ -59,6 +60,9 @@ interface PersonalForm {
agents_md_auto_inject: boolean;
allow_root_file_creation: boolean;
default_hide_workspace: boolean;
group_sidebar_by_workspace: boolean;
sidebar_pinned_workspaces: string[];
sidebar_workspace_order: string[];
theme: 'classic' | 'light' | 'dark';
goal_review_mode: 'readonly' | 'active';
goal_end_conditions: string[];
@ -78,12 +82,9 @@ interface PersonalizationState {
loaded: boolean;
status: string;
error: string;
maxConsiderations: number;
toggleUpdating: boolean;
overlayPressActive: boolean;
newConsideration: string;
tonePresets: string[];
draggedConsiderationIndex: number | null;
form: PersonalForm;
toolCategories: Array<{ id: string; label: string }>;
skillsCatalog: Array<{ id: string; label: string; description?: string }>;
@ -119,6 +120,28 @@ const loadCachedStackedHideBorders = (): boolean => {
return window.localStorage.getItem(STACKED_HIDE_BORDERS_STORAGE_KEY) === 'true';
};
const loadCachedBlockDisplayMode = (): BlockDisplayMode => {
if (typeof window === 'undefined' || !window.localStorage) {
return 'stacked';
}
try {
const raw = window.localStorage.getItem(EXPERIMENT_STORAGE_KEY);
if (!raw) {
return 'stacked';
}
const parsed = JSON.parse(raw);
if (
typeof parsed?.blockDisplayMode === 'string' &&
['traditional', 'stacked', 'minimal'].includes(parsed.blockDisplayMode)
) {
return parsed.blockDisplayMode as BlockDisplayMode;
}
} catch (error) {
console.warn('读取堆叠块显示模式缓存失败:', error);
}
return 'stacked';
};
const persistStackedHideBorders = (value: boolean) => {
if (typeof window === 'undefined' || !window.localStorage) {
return;
@ -146,6 +169,7 @@ const defaultForm = (): PersonalForm => ({
silent_tool_disable: false,
enhanced_tool_display: true,
compact_message_display: 'full',
block_display_mode: loadCachedBlockDisplayMode(),
show_status_avatar: true,
show_git_status_bar: true,
auto_open_terminal_panel: true,
@ -157,7 +181,7 @@ const defaultForm = (): PersonalForm => ({
use_custom_names: false,
profession: '',
tone: '',
considerations: [],
considerations: '',
thinking_interval: null,
disabled_tool_categories: [],
default_run_mode: null,
@ -179,6 +203,9 @@ const defaultForm = (): PersonalForm => ({
agents_md_auto_inject: false,
allow_root_file_creation: false,
default_hide_workspace: false,
group_sidebar_by_workspace: false,
sidebar_pinned_workspaces: [],
sidebar_workspace_order: [],
theme: loadCachedTheme(),
goal_review_mode: 'readonly',
goal_end_conditions: ['max_turns'],
@ -244,12 +271,9 @@ export const usePersonalizationStore = defineStore('personalization', {
loaded: false,
status: '',
error: '',
maxConsiderations: 10,
toggleUpdating: false,
overlayPressActive: false,
newConsideration: '',
tonePresets: ['健谈', '幽默', '直言不讳', '鼓励性', '诗意', '企业商务', '打破常规', '同理心'],
draggedConsiderationIndex: null,
form: defaultForm(),
toolCategories: [],
skillsCatalog: [],
@ -268,7 +292,6 @@ export const usePersonalizationStore = defineStore('personalization', {
},
closeDrawer() {
this.visible = false;
this.draggedConsiderationIndex = null;
this.overlayPressActive = false;
},
handleOverlayPressStart(event: Event) {
@ -311,9 +334,7 @@ export const usePersonalizationStore = defineStore('personalization', {
applyPersonalizationData(data: any) {
// 若后端未返回默认模型(旧版本接口),保持当前已选模型而不是回退到内置模型
const fallbackModel =
(this.form && typeof this.form.default_model === 'string'
? this.form.default_model
: null);
this.form && typeof this.form.default_model === 'string' ? this.form.default_model : null;
const fallbackTheme = this.form?.theme || loadCachedTheme();
this.form = {
enabled: !!data.enabled,
@ -327,8 +348,9 @@ export const usePersonalizationStore = defineStore('personalization', {
: 'medium',
auto_generate_title: data.auto_generate_title !== false,
recent_conversations_prompt_enabled: !!data.recent_conversations_prompt_enabled,
recent_conversations_prompt_limit:
this.normalizeRecentConversationsPromptLimit(data.recent_conversations_prompt_limit),
recent_conversations_prompt_limit: this.normalizeRecentConversationsPromptLimit(
data.recent_conversations_prompt_limit
),
tool_intent_enabled: !!data.tool_intent_enabled,
skill_hints_enabled: !!data.skill_hints_enabled,
skill_strict_terminal_enabled: !!data.skill_strict_terminal_enabled,
@ -340,12 +362,20 @@ export const usePersonalizationStore = defineStore('personalization', {
silent_tool_disable: !!data.silent_tool_disable,
enhanced_tool_display: data.enhanced_tool_display !== false,
compact_message_display: data.compact_message_display === 'brief' ? 'brief' : 'full',
block_display_mode:
data.block_display_mode === 'traditional' ||
data.block_display_mode === 'stacked' ||
data.block_display_mode === 'minimal'
? data.block_display_mode
: 'stacked',
show_status_avatar: data.show_status_avatar !== false,
show_git_status_bar: data.show_git_status_bar !== false,
auto_open_terminal_panel: data.auto_open_terminal_panel !== false,
stacked_hide_borders: !!data.stacked_hide_borders,
enhanced_tool_display_categories: Array.isArray(data.enhanced_tool_display_categories)
? data.enhanced_tool_display_categories.filter((item: unknown) => typeof item === 'string')
? data.enhanced_tool_display_categories.filter(
(item: unknown) => typeof item === 'string'
)
: [],
enabled_skills: Array.isArray(data.enabled_skills)
? data.enabled_skills.filter((item: unknown) => typeof item === 'string')
@ -355,7 +385,12 @@ export const usePersonalizationStore = defineStore('personalization', {
use_custom_names: !!data.use_custom_names,
profession: data.profession || '',
tone: data.tone || '',
considerations: Array.isArray(data.considerations) ? [...data.considerations] : [],
considerations:
typeof data.considerations === 'string'
? data.considerations
: Array.isArray(data.considerations)
? data.considerations.filter((item: unknown) => typeof item === 'string').join('\n')
: '',
thinking_interval:
typeof data.thinking_interval === 'number' ? data.thinking_interval : null,
disabled_tool_categories: Array.isArray(data.disabled_tool_categories)
@ -374,8 +409,7 @@ export const usePersonalizationStore = defineStore('personalization', {
? data.default_permission_mode
: 'unrestricted',
versioning_enabled_by_default: data.versioning_enabled_by_default !== false,
versioning_backup_mode:
data.versioning_backup_mode === 'full' ? 'full' : 'shallow',
versioning_backup_mode: data.versioning_backup_mode === 'full' ? 'full' : 'shallow',
versioning_restore_mode: 'overwrite',
default_model: typeof data.default_model === 'string' ? data.default_model : fallbackModel,
image_compression:
@ -404,12 +438,17 @@ export const usePersonalizationStore = defineStore('personalization', {
agents_md_auto_inject: !!data.agents_md_auto_inject,
allow_root_file_creation: !!data.allow_root_file_creation,
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,
goal_review_mode: data.goal_review_mode === 'active' ? 'active' : 'readonly',
goal_end_conditions: Array.isArray(data.goal_end_conditions)
? data.goal_end_conditions.filter(
(x: any) => x === 'max_turns' || x === 'max_tokens'
)
? data.goal_end_conditions.filter((x: any) => x === 'max_turns' || x === 'max_tokens')
: ['max_turns'],
goal_max_turns:
typeof data.goal_max_turns === 'number' && data.goal_max_turns > 0
@ -421,9 +460,10 @@ export const usePersonalizationStore = defineStore('personalization', {
: null
};
// 如果theme发生变化应用到界面
const currentTheme = (typeof window !== 'undefined' && window.localStorage)
? window.localStorage.getItem(THEME_STORAGE_KEY)
: null;
const currentTheme =
typeof window !== 'undefined' && window.localStorage
? window.localStorage.getItem(THEME_STORAGE_KEY)
: null;
if (this.form.theme !== currentTheme) {
this.applyTheme(this.form.theme);
}
@ -439,9 +479,15 @@ export const usePersonalizationStore = defineStore('personalization', {
this.form = { ...this.form, compact_message_display: 'brief' };
void this.persistCompactMessageDisplay('brief');
}
// 一次性迁移:堆叠块显示模式旧版仅在 localStorage 保存,后端默认 stacked 时回写本地缓存值。
const cachedBlockMode = this.experiments.blockDisplayMode;
if (this.form.block_display_mode === 'stacked' && cachedBlockMode !== 'stacked') {
this.form = { ...this.form, block_display_mode: cachedBlockMode };
}
this.experiments = {
...this.experiments,
compactMessageDisplay: this.form.compact_message_display
compactMessageDisplay: this.form.compact_message_display,
blockDisplayMode: this.form.block_display_mode
};
this.persistExperiments();
this.clearFeedback();
@ -803,69 +849,14 @@ export const usePersonalizationStore = defineStore('personalization', {
this.clearFeedback();
this.scheduleAutoSave();
},
updateNewConsideration(value: string) {
this.newConsideration = value;
this.clearFeedback();
},
addConsideration() {
if (!this.newConsideration) {
return;
}
if (this.form.considerations.length >= this.maxConsiderations) {
return;
}
updateConsiderations(value: string) {
this.form = {
...this.form,
considerations: [...this.form.considerations, this.newConsideration]
};
this.newConsideration = '';
this.clearFeedback();
this.scheduleAutoSave();
},
removeConsideration(index: number) {
const items = [...this.form.considerations];
items.splice(index, 1);
this.form = {
...this.form,
considerations: items
considerations: value
};
this.clearFeedback();
this.scheduleAutoSave();
},
considerationDragStart(index: number, event: DragEvent) {
this.draggedConsiderationIndex = index;
if (event && event.dataTransfer) {
event.dataTransfer.effectAllowed = 'move';
}
},
considerationDragOver(index: number, event: DragEvent) {
if (event) {
event.preventDefault();
}
if (this.draggedConsiderationIndex === null || this.draggedConsiderationIndex === index) {
return;
}
const items = [...this.form.considerations];
const [moved] = items.splice(this.draggedConsiderationIndex, 1);
items.splice(index, 0, moved);
this.form = {
...this.form,
considerations: items
};
this.draggedConsiderationIndex = index;
this.clearFeedback();
},
considerationDrop(index: number, event: DragEvent) {
if (event) {
event.preventDefault();
}
this.considerationDragEnd();
this.considerationDragOver(index, event);
this.scheduleAutoSave();
},
considerationDragEnd() {
this.draggedConsiderationIndex = null;
},
async logout() {
try {
console.info('[auth-debug] logout clicked, sending POST /logout');
@ -911,6 +902,10 @@ export const usePersonalizationStore = defineStore('personalization', {
blockDisplayMode: mode
};
this.persistExperiments();
this.form = {
...this.form,
block_display_mode: mode
};
this.scheduleAutoSave();
},
setCompactMessageDisplay(mode: CompactMessageDisplay) {

View File

@ -534,9 +534,6 @@ body[data-theme='dark'] .conversation-sidebar .conversation-item.active:focus-wi
}
.conversation-sidebar .conversation-actions-menu {
position: absolute;
right: 0;
top: calc(100% + 6px);
z-index: 140;
width: max-content;
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);
}
.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 {
opacity: 1;
transform: translateY(0) scale(1);
@ -680,6 +688,309 @@ body[data-theme='dark'] .conversation-sidebar .conversation-item.active:focus-wi
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. */
.conversation-sidebar .search-input {
background: transparent !important;

View File

@ -17,6 +17,7 @@ export const ICONS = Object.freeze({
file: '/static/icons/file.svg',
flag: '/static/icons/flag.svg',
folder: '/static/icons/folder.svg',
folderClosed: '/static/icons/folder-closed.svg',
folderOpen: '/static/icons/folder-open.svg',
globe: '/static/icons/globe.svg',
hammer: '/static/icons/hammer.svg',