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数据根下的统一配置 # 2) settings.json数据根下的统一配置
data_root = os.environ.get("ASTRION_DATA_ROOT", str(Path.home() / ".astrion" / "astrion")) 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(): if settings_path.exists():
try: try:
settings = json.loads(settings_path.read_text(encoding="utf-8")) 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_" _env_prefix = "TERMINAL_SANDBOX_ENV_"
TERMINAL_SANDBOX_MODE = os.environ.get("TERMINAL_SANDBOX_MODE", "docker").lower() TERMINAL_SANDBOX_MODE = os.environ.get("TERMINAL_SANDBOX_MODE", "host").lower()
TERMINAL_SANDBOX_IMAGE = os.environ.get("TERMINAL_SANDBOX_IMAGE", "python:3.11-slim") TERMINAL_SANDBOX_IMAGE = os.environ.get("TERMINAL_SANDBOX_IMAGE", "python:3.11-slim")
TERMINAL_SANDBOX_MOUNT_PATH = os.environ.get("TERMINAL_SANDBOX_MOUNT_PATH", "/workspace") TERMINAL_SANDBOX_MOUNT_PATH = os.environ.get("TERMINAL_SANDBOX_MOUNT_PATH", "/workspace")
TERMINAL_SANDBOX_SHELL = os.environ.get("TERMINAL_SANDBOX_SHELL", "/bin/bash") TERMINAL_SANDBOX_SHELL = os.environ.get("TERMINAL_SANDBOX_SHELL", "/bin/bash")

View File

@ -252,7 +252,7 @@ class ToolsDefinitionContextToolsMixin:
"type": "function", "type": "function",
"function": { "function": {
"name": "manage_personalization", "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": { "parameters": {
"type": "object", "type": "object",
"properties": self._inject_intent({ "properties": self._inject_intent({
@ -267,7 +267,7 @@ class ToolsDefinitionContextToolsMixin:
"description": "要更新的字段名仅action=update时需要" "description": "要更新的字段名仅action=update时需要"
}, },
"value": { "value": {
"description": "新值仅action=update时需要。注意事项提供字符串数组,其他字段提供字符串" "description": "新值仅action=update时需要。注意事项提供字符串,其他字段提供字符串"
} }
}), }),
"required": ["action"] "required": ["action"]

View File

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

View File

@ -5,7 +5,7 @@ from __future__ import annotations
import json import json
from copy import deepcopy from copy import deepcopy
from pathlib import Path from pathlib import Path
from typing import Any, Dict, Iterable, Optional, Union from typing import Any, Dict, Optional, Union
try: try:
from config.limits import THINKING_FAST_INTERVAL 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_CONVERSATION_CONTINUITY = {"low", "medium", "high"}
ALLOWED_GOAL_REVIEW_MODES = {"readonly", "active"} ALLOWED_GOAL_REVIEW_MODES = {"readonly", "active"}
ALLOWED_GOAL_END_CONDITIONS = {"max_turns", "max_tokens"} ALLOWED_GOAL_END_CONDITIONS = {"max_turns", "max_tokens"}
ALLOWED_BLOCK_DISPLAY_MODES = {"traditional", "stacked", "minimal"}
GOAL_MAX_TURNS_MIN = 1 GOAL_MAX_TURNS_MIN = 1
GOAL_MAX_TURNS_MAX = 100 GOAL_MAX_TURNS_MAX = 100
GOAL_MAX_TURNS_DEFAULT = 5 GOAL_MAX_TURNS_DEFAULT = 5
@ -30,7 +31,7 @@ GOAL_MAX_TOKENS_MAX = 100_000_000
PERSONALIZATION_FILENAME = "personalization.json" PERSONALIZATION_FILENAME = "personalization.json"
MAX_SHORT_FIELD_LENGTH = 20 MAX_SHORT_FIELD_LENGTH = 20
MAX_CONSIDERATION_LENGTH = 50 MAX_CONSIDERATION_TEXT_LENGTH = 2000
MAX_CONSIDERATION_ITEMS = 10 MAX_CONSIDERATION_ITEMS = 10
TONE_PRESETS = ["健谈", "幽默", "直言不讳", "鼓励性", "诗意", "企业商务", "打破常规", "同理心"] TONE_PRESETS = ["健谈", "幽默", "直言不讳", "鼓励性", "诗意", "企业商务", "打破常规", "同理心"]
THINKING_INTERVAL_MIN = 1 THINKING_INTERVAL_MIN = 1
@ -94,6 +95,7 @@ DEFAULT_PERSONALIZATION_CONFIG: Dict[str, Any] = {
"silent_tool_disable": True, # 禁用工具时不向模型插入提示(默认开启) "silent_tool_disable": True, # 禁用工具时不向模型插入提示(默认开启)
"enhanced_tool_display": True, # 增强工具显示 "enhanced_tool_display": True, # 增强工具显示
"compact_message_display": "full", # 简略消息显示full-完整原始内容 / brief-一行概要 "compact_message_display": "full", # 简略消息显示full-完整原始内容 / brief-一行概要
"block_display_mode": "stacked", # 堆叠块显示模式traditional-传统列表 / stacked-堆叠动画 / minimal-极简模式
"show_status_avatar": True, # 是否显示助手状态形象 "show_status_avatar": True, # 是否显示助手状态形象
"stacked_hide_borders": False, # 堆叠块隐藏边线 "stacked_hide_borders": False, # 堆叠块隐藏边线
"show_git_status_bar": True, # 是否显示输入栏上方 Git 状态栏 "show_git_status_bar": True, # 是否显示输入栏上方 Git 状态栏
@ -103,6 +105,9 @@ DEFAULT_PERSONALIZATION_CONFIG: Dict[str, Any] = {
"agents_md_auto_inject": False, # AGENTS.md 自动注入开关 "agents_md_auto_inject": False, # AGENTS.md 自动注入开关
"allow_root_file_creation": False, # 允许在根目录创建文件开关 "allow_root_file_creation": False, # 允许在根目录创建文件开关
"default_hide_workspace": False, # 默认隐藏工作区 "default_hide_workspace": False, # 默认隐藏工作区
"group_sidebar_by_workspace": False, # 侧边栏按工作区/项目分组显示对话
"sidebar_pinned_workspaces": [], # 分组侧边栏中永久置顶的工作区ID列表
"sidebar_workspace_order": [], # 分组侧边栏中非置顶工作区的显示顺序
"theme": "classic", # 主题配色: classic-经典/light-明亮/dark-暗黑 "theme": "classic", # 主题配色: classic-经典/light-明亮/dark-暗黑
# 目标模式Goal Mode # 目标模式Goal Mode
"goal_review_mode": "readonly", # readonly-仅读对话判断 / active-允许审核智能体跑只读命令取证 "goal_review_mode": "readonly", # readonly-仅读对话判断 / active-允许审核智能体跑只读命令取证
@ -177,6 +182,25 @@ def load_personalization_config(base_dir: PathLike) -> Dict[str, Any]:
return deepcopy(DEFAULT_PERSONALIZATION_CONFIG) return deepcopy(DEFAULT_PERSONALIZATION_CONFIG)
def _sanitize_string_list(value: Any, max_length: int = 100) -> list:
"""Sanitize a list of non-empty strings, deduplicating and limiting length."""
if not isinstance(value, list):
return []
cleaned: list = []
seen = set()
for item in value:
if not isinstance(item, str):
continue
s = item.strip()
if not s or s in seen:
continue
seen.add(s)
cleaned.append(s)
if len(cleaned) >= max_length:
break
return cleaned
def sanitize_personalization_payload( def sanitize_personalization_payload(
payload: Optional[Dict[str, Any]], payload: Optional[Dict[str, Any]],
fallback: Optional[Dict[str, Any]] = None fallback: Optional[Dict[str, Any]] = None
@ -430,6 +454,13 @@ def sanitize_personalization_payload(
else: else:
base["compact_message_display"] = "full" 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: if "stacked_hide_borders" in data:
base["stacked_hide_borders"] = bool(data.get("stacked_hide_borders")) base["stacked_hide_borders"] = bool(data.get("stacked_hide_borders"))
@ -485,6 +516,32 @@ def sanitize_personalization_payload(
else: else:
base["default_hide_workspace"] = bool(base.get("default_hide_workspace", False)) base["default_hide_workspace"] = bool(base.get("default_hide_workspace", False))
# 侧边栏按工作区/项目分组显示对话
if "group_sidebar_by_workspace" in data:
base["group_sidebar_by_workspace"] = bool(data.get("group_sidebar_by_workspace"))
else:
base["group_sidebar_by_workspace"] = bool(base.get("group_sidebar_by_workspace", False))
# 分组侧边栏置顶工作区ID列表
if "sidebar_pinned_workspaces" in data:
base["sidebar_pinned_workspaces"] = _sanitize_string_list(
data.get("sidebar_pinned_workspaces"), max_length=100
)
else:
base["sidebar_pinned_workspaces"] = _sanitize_string_list(
base.get("sidebar_pinned_workspaces"), max_length=100
)
# 分组侧边栏工作区显示顺序
if "sidebar_workspace_order" in data:
base["sidebar_workspace_order"] = _sanitize_string_list(
data.get("sidebar_workspace_order"), max_length=200
)
else:
base["sidebar_workspace_order"] = _sanitize_string_list(
base.get("sidebar_workspace_order"), max_length=200
)
# 主题配色 # 主题配色
theme_value = data.get("theme", base.get("theme")) theme_value = data.get("theme", base.get("theme"))
if isinstance(theme_value, str) and theme_value in ALLOWED_THEMES: if isinstance(theme_value, str) and theme_value in ALLOWED_THEMES:
@ -705,12 +762,10 @@ def build_personalization_prompt(
if config.get("tone"): if config.get("tone"):
lines.append(f"用户希望你使用 {config['tone']} 的语气与TA交流") lines.append(f"用户希望你使用 {config['tone']} 的语气与TA交流")
considerations: Iterable[str] = config.get("considerations") or [] considerations: str = config.get("considerations") or ""
considerations = [item for item in considerations if item]
if considerations: if considerations:
lines.append("用户希望你在回答问题时必须考虑的信息是:") lines.append("用户希望你在回答问题时必须考虑的信息是:")
for idx, item in enumerate(considerations, 1): lines.append(considerations)
lines.append(f"{idx}. {item}")
conversation_continuity = str(config.get("conversation_continuity") or "medium").strip().lower() conversation_continuity = str(config.get("conversation_continuity") or "medium").strip().lower()
if conversation_continuity == "high": if conversation_continuity == "high":
@ -761,9 +816,13 @@ def _sanitize_short_field(value: Optional[str]) -> str:
return text[:MAX_SHORT_FIELD_LENGTH] return text[:MAX_SHORT_FIELD_LENGTH]
def _sanitize_considerations(value: Any) -> list: def _sanitize_considerations(value: Any) -> str:
if not isinstance(value, list): """Sanitize considerations text. Legacy array data is joined with newlines."""
return [] if value is None:
return ""
if isinstance(value, str):
return value[:MAX_CONSIDERATION_TEXT_LENGTH]
if isinstance(value, list):
cleaned = [] cleaned = []
for item in value: for item in value:
if not isinstance(item, str): if not isinstance(item, str):
@ -771,10 +830,11 @@ def _sanitize_considerations(value: Any) -> list:
text = item.strip() text = item.strip()
if not text: if not text:
continue continue
cleaned.append(text[:MAX_CONSIDERATION_LENGTH]) cleaned.append(text)
if len(cleaned) >= MAX_CONSIDERATION_ITEMS: if len(cleaned) >= MAX_CONSIDERATION_ITEMS:
break break
return cleaned return "\n".join(cleaned)[:MAX_CONSIDERATION_TEXT_LENGTH]
return ""
def _sanitize_thinking_interval(value: Any) -> Optional[int]: 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 pass
def _apply_workspace_personalization_preferences(terminal: WebTerminal, workspace) -> None: def _apply_workspace_personalization_preferences(terminal: WebTerminal, workspace, update_session: bool = True) -> None:
"""Apply persisted workspace personalization after policy/workspace resolution.""" """Apply persisted workspace personalization after policy/workspace resolution."""
try: try:
config = load_personalization_config(workspace.data_dir) config = load_personalization_config(workspace.data_dir)
@ -90,7 +90,7 @@ def _apply_workspace_personalization_preferences(terminal: WebTerminal, workspac
terminal._workspace_default_model_applied = True terminal._workspace_default_model_applied = True
except Exception: except Exception:
pass pass
if has_request_context(): if has_request_context() and update_session:
session["run_mode"] = getattr(terminal, "run_mode", session.get("run_mode")) session["run_mode"] = getattr(terminal, "run_mode", session.get("run_mode"))
session["thinking_mode"] = getattr(terminal, "thinking_mode", session.get("thinking_mode")) session["thinking_mode"] = getattr(terminal, "thinking_mode", session.get("thinking_mode"))
session["model_key"] = getattr(terminal, "model_key", session.get("model_key")) session["model_key"] = getattr(terminal, "model_key", session.get("model_key"))
@ -128,7 +128,11 @@ def _ensure_workspace_skills_synced(terminal: WebTerminal, workspace) -> None:
debug_log(f"[Skills] 工作区同步异常: {exc}") debug_log(f"[Skills] 工作区同步异常: {exc}")
def get_user_resources(username: Optional[str] = None, workspace_id: Optional[str] = None) -> Tuple[Optional[WebTerminal], Optional['modules.user_manager.UserWorkspace']]: def get_user_resources(
username: Optional[str] = None,
workspace_id: Optional[str] = None,
update_session: bool = True,
) -> Tuple[Optional[WebTerminal], Optional['modules.user_manager.UserWorkspace']]:
from modules.user_manager import UserWorkspace from modules.user_manager import UserWorkspace
username = (username or get_current_username()) username = (username or get_current_username())
if not username: if not username:
@ -277,7 +281,7 @@ def get_user_resources(username: Optional[str] = None, workspace_id: Optional[st
terminal.username = "host" terminal.username = "host"
terminal.user_role = "admin" terminal.user_role = "admin"
terminal.quota_update_callback = None terminal.quota_update_callback = None
if has_request_context(): if has_request_context() and update_session:
session['run_mode'] = terminal.run_mode session['run_mode'] = terminal.run_mode
session['thinking_mode'] = terminal.thinking_mode session['thinking_mode'] = terminal.thinking_mode
session['workspace_id'] = getattr(workspace, "workspace_id", None) session['workspace_id'] = getattr(workspace, "workspace_id", None)
@ -287,7 +291,7 @@ def get_user_resources(username: Optional[str] = None, workspace_id: Optional[st
attach_user_broadcast(terminal, "host") attach_user_broadcast(terminal, "host")
terminal.username = "host" terminal.username = "host"
terminal.user_role = "admin" terminal.user_role = "admin"
if has_request_context(): if has_request_context() and update_session:
session['workspace_id'] = getattr(workspace, "workspace_id", None) session['workspace_id'] = getattr(workspace, "workspace_id", None)
session['host_workspace_id'] = getattr(workspace, "workspace_id", None) session['host_workspace_id'] = getattr(workspace, "workspace_id", None)
_set_terminal_workspace_label( _set_terminal_workspace_label(
@ -327,7 +331,7 @@ def get_user_resources(username: Optional[str] = None, workspace_id: Optional[st
if candidate not in disabled_models: if candidate not in disabled_models:
try: try:
terminal.set_model(candidate) terminal.set_model(candidate)
if has_request_context(): if has_request_context() and update_session:
session["model_key"] = terminal.model_key session["model_key"] = terminal.model_key
break break
except Exception: except Exception:
@ -410,7 +414,7 @@ def get_user_resources(username: Optional[str] = None, workspace_id: Optional[st
terminal.username = username terminal.username = username
terminal.user_role = "api" if is_api_user else get_current_user_role(record) terminal.user_role = "api" if is_api_user else get_current_user_role(record)
terminal.quota_update_callback = (lambda metric=None: emit_user_quota_update(username)) if not is_api_user else None terminal.quota_update_callback = (lambda metric=None: emit_user_quota_update(username)) if not is_api_user else None
if has_request_context(): if has_request_context() and update_session:
session['run_mode'] = terminal.run_mode session['run_mode'] = terminal.run_mode
session['thinking_mode'] = terminal.thinking_mode session['thinking_mode'] = terminal.thinking_mode
session['model_key'] = getattr(terminal, "model_key", None) session['model_key'] = getattr(terminal, "model_key", None)
@ -421,7 +425,7 @@ def get_user_resources(username: Optional[str] = None, workspace_id: Optional[st
terminal.username = username terminal.username = username
terminal.user_role = "api" if is_api_user else get_current_user_role(record) terminal.user_role = "api" if is_api_user else get_current_user_role(record)
terminal.quota_update_callback = (lambda metric=None: emit_user_quota_update(username)) if not is_api_user else None terminal.quota_update_callback = (lambda metric=None: emit_user_quota_update(username)) if not is_api_user else None
if has_request_context(): if has_request_context() and update_session:
session['workspace_id'] = getattr(workspace, "workspace_id", None) session['workspace_id'] = getattr(workspace, "workspace_id", None)
if is_api_user: if is_api_user:
@ -466,6 +470,7 @@ def get_user_resources(username: Optional[str] = None, workspace_id: Optional[st
if candidate not in disabled_models: if candidate not in disabled_models:
try: try:
terminal.set_model(candidate) terminal.set_model(candidate)
if update_session:
session["model_key"] = terminal.model_key session["model_key"] = terminal.model_key
break break
except Exception: except Exception:
@ -473,7 +478,7 @@ def get_user_resources(username: Optional[str] = None, workspace_id: Optional[st
except Exception as exc: except Exception as exc:
debug_log(f"[admin_policy] 应用失败: {exc}") debug_log(f"[admin_policy] 应用失败: {exc}")
_apply_workspace_personalization_preferences(terminal, workspace) _apply_workspace_personalization_preferences(terminal, workspace, update_session=update_session)
_ensure_workspace_skills_synced(terminal, workspace) _ensure_workspace_skills_synced(terminal, workspace)
return terminal, workspace return terminal, workspace

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -104,7 +104,7 @@
</div> </div>
<div class="conversation-list"> <div class="conversation-list">
<div v-if="runningTaskItems.length && !searchActive" class="running-task-section"> <div v-if="runningTaskItems.length && !searchActive && !isGroupByWorkspaceActive" class="running-task-section">
<div class="running-task-section-title">运行中的任务</div> <div class="running-task-section-title">运行中的任务</div>
<button <button
v-for="task in runningTaskItems" v-for="task in runningTaskItems"
@ -128,6 +128,218 @@
</span> </span>
</button> </button>
</div> </div>
<template v-if="isGroupByWorkspaceActive && !searchActive">
<div class="workspace-groups">
<div
v-for="ws in sortedWorkspaces"
:key="String(ws.workspace_id || ws.label)"
class="workspace-group"
>
<div
class="workspace-group-header"
:class="{ active: String(ws.workspace_id || '') === currentWorkspaceId }"
@mouseenter="hoverWorkspaceId = String(ws.workspace_id || '')"
@mouseleave="hoverWorkspaceId = null"
>
<button
type="button"
class="workspace-group-toggle"
@click="toggleWorkspaceExpanded(String(ws.workspace_id || ''))"
>
<span
class="icon icon-sm workspace-folder-icon"
:style="iconStyle(isWorkspaceExpanded(String(ws.workspace_id || '')) ? 'folderOpen' : 'folderClosed')"
aria-hidden="true"
></span>
<span class="workspace-group-label">{{ ws.label || ws.workspace_id }}</span>
<span
v-if="String(ws.workspace_id || '') === currentWorkspaceId"
class="workspace-current-dot"
aria-label="当前工作区"
></span>
<span
v-if="
isWorkspaceRunning(String(ws.workspace_id || '')) &&
!isWorkspaceExpanded(String(ws.workspace_id || '')) &&
hoverWorkspaceId !== String(ws.workspace_id || '') &&
openWorkspaceMenuId !== String(ws.workspace_id || '')
"
class="workspace-running-loader"
aria-label="运行中"
></span>
</button>
<div
class="workspace-group-actions"
:class="{ visible: hoverWorkspaceId === String(ws.workspace_id || '') || openWorkspaceMenuId === String(ws.workspace_id || '') }"
>
<button
type="button"
class="workspace-new-btn"
title="新建对话"
aria-label="新建对话"
@click="handleCreateWorkspaceConversation(String(ws.workspace_id || ''))"
>
<span class="icon icon-sm" :style="iconStyle('pencil')" aria-hidden="true"></span>
</button>
<button
type="button"
class="workspace-more-btn"
title="更多操作"
aria-label="更多操作"
:aria-expanded="openWorkspaceMenuId === String(ws.workspace_id || '')"
@click.stop="toggleWorkspaceMenu(String(ws.workspace_id || ''))"
>
<span aria-hidden="true"></span>
</button>
<div
class="workspace-actions-menu"
:class="{ open: openWorkspaceMenuId === String(ws.workspace_id || '') }"
>
<button type="button" @click="handlePinWorkspace(String(ws.workspace_id || ''))">
<span>{{ pinnedWorkspaceIds.has(String(ws.workspace_id || '')) ? '取消置顶' : '置顶' }}</span>
</button>
<button
v-if="props.versioningHostMode"
type="button"
@click="handleRevealWorkspace(String(ws.workspace_id || ''))"
>
<span>在文件夹中打开</span>
</button>
<button type="button" @click="startRenameWorkspace(ws)">
<span>重命名</span>
</button>
</div>
</div>
</div>
<div
class="workspace-group-children"
:class="{ expanded: isWorkspaceExpanded(String(ws.workspace_id || '')) }"
>
<div class="workspace-group-children-inner">
<div
v-if="getWorkspaceGroup(String(ws.workspace_id || ''))?.loading && !getWorkspaceGroup(String(ws.workspace_id || ''))?.conversations?.length"
class="workspace-conversations-loading"
>
正在加载...
</div>
<div
v-else-if="!getWorkspaceGroup(String(ws.workspace_id || ''))?.conversations?.length"
class="workspace-no-conversations"
>
暂无对话
</div>
<template v-else>
<div
class="workspace-conversations-viewport"
:style="{
'--workspace-visible-count': getWorkspaceGroup(String(ws.workspace_id || ''))?.visibleLimit || 5
}"
>
<transition-group
name="conversation-delete"
tag="div"
class="workspace-conversations-list"
:class="`animation-mode-${conversationListAnimationMode}`"
>
<div
v-for="conv in getWorkspaceVisibleAndBufferConversations(String(ws.workspace_id || ''))"
:key="conv.id"
class="conversation-item workspace-conversation-item"
:class="{
active: conv.id === currentConversationId,
running: isConversationActive(conv.id) || isConversationCompleted(conv.id),
'insert-from-left': conversationInsertAnimations[conv.id] === 'create',
'insert-from-under': conversationInsertAnimations[conv.id] === 'duplicate',
'duplicate-source-mask': conversationInsertAnimations[conv.id] === 'duplicateSource'
}"
@click="handleWorkspaceConversationClick(conv.id, String(ws.workspace_id || ''))"
>
<div class="conversation-title">{{ conv.title }}</div>
<div
class="conversation-action-wrap"
:class="{ 'menu-open': openActionMenuId === conv.id }"
@click.stop
>
<span
v-if="isConversationActive(conv.id)"
class="conversation-running-loader"
aria-label="运行中"
title="运行中"
></span>
<span
v-else-if="isConversationCompleted(conv.id)"
class="conversation-complete-check"
aria-label="已完成"
title="已完成"
></span>
<button
v-else
type="button"
class="conversation-more-btn"
title="更多操作"
aria-label="更多操作"
:data-action-trigger="conv.id"
:aria-expanded="openActionMenuId === conv.id"
@click="toggleActionMenu(conv.id)"
>
<span aria-hidden="true"></span>
</button>
<div
v-if="shouldShowActionMenu(conv.id)"
class="conversation-actions-menu conversation-actions-menu--fixed"
:class="{ open: openActionMenuId === conv.id }"
:style="{
top: actionMenuPosition.top + 'px',
right: actionMenuPosition.right + 'px'
}"
>
<button
type="button"
@click="
openActionMenuId = null;
$emit('duplicate', conv.id);
"
>
<span class="icon icon-sm" :style="iconStyle('copy')" aria-hidden="true"></span>
<span>复制</span>
</button>
<button
type="button"
class="danger"
@click="
openActionMenuId = null;
$emit('delete', { id: conv.id, workspaceId: String(ws.workspace_id || '') });
"
>
<span class="icon icon-sm" :style="iconStyle('trash')" aria-hidden="true"></span>
<span>删除</span>
</button>
</div>
</div>
</div>
</transition-group>
</div>
<div
v-if="getWorkspaceHasMore(String(ws.workspace_id || ''))"
class="load-more workspace-load-more"
>
<button
class="load-more-btn"
type="button"
:disabled="getWorkspaceGroup(String(ws.workspace_id || ''))?.loadingMore"
@click="loadMoreWorkspaceConversations(String(ws.workspace_id || ''))"
>
{{ getWorkspaceGroup(String(ws.workspace_id || ''))?.loadingMore ? '载入中...' : '加载更多' }}
</button>
</div>
</template>
</div>
</div>
</div>
</div>
</template>
<template v-else>
<div <div
v-if="loading && !displayConversations.length && !searchActive && !isDeletingConversation" v-if="loading && !displayConversations.length && !searchActive && !isDeletingConversation"
class="loading-conversations" class="loading-conversations"
@ -185,15 +397,20 @@
class="conversation-more-btn" class="conversation-more-btn"
title="更多操作" title="更多操作"
aria-label="更多操作" aria-label="更多操作"
:data-action-trigger="conv.id"
:aria-expanded="openActionMenuId === conv.id" :aria-expanded="openActionMenuId === conv.id"
@click="toggleActionMenu(conv.id)" @click="toggleActionMenu(conv.id)"
> >
<span aria-hidden="true"></span> <span aria-hidden="true"></span>
</button> </button>
<div <div
v-if="!isConversationActive(conv.id) && !isConversationCompleted(conv.id)" v-if="!isConversationActive(conv.id) && !isConversationCompleted(conv.id) && openActionMenuId === conv.id"
class="conversation-actions-menu" class="conversation-actions-menu conversation-actions-menu--fixed"
:class="{ open: openActionMenuId === conv.id }" :class="{ open: openActionMenuId === conv.id }"
:style="{
top: actionMenuPosition.top + 'px',
right: actionMenuPosition.right + 'px'
}"
> >
<button <button
type="button" type="button"
@ -220,7 +437,9 @@
</div> </div>
</div> </div>
</transition-group> </transition-group>
<div v-if="!searchActive && hasMore" class="load-more"> </template>
<div v-if="!searchActive && !isGroupByWorkspaceActive && hasMore" class="load-more">
<button <button
class="load-more-btn" class="load-more-btn"
type="button" type="button"
@ -251,6 +470,35 @@
</div> </div>
</div> </div>
<div
v-if="renameWorkspaceId"
class="workspace-rename-overlay"
@click.self="cancelRenameWorkspace"
>
<div class="workspace-rename-card">
<div class="workspace-rename-title">
重命名{{ workspaceKind === 'workspace' ? '工作区' : '项目' }}
</div>
<input
ref="renameInput"
v-model="renameWorkspaceLabel"
type="text"
class="workspace-rename-input"
placeholder="请输入名称"
@keydown.enter.prevent="submitRenameWorkspace"
@keydown.esc.prevent="cancelRenameWorkspace"
/>
<div class="workspace-rename-actions">
<button type="button" class="workspace-rename-cancel" @click="cancelRenameWorkspace">
取消
</button>
<button type="button" class="workspace-rename-confirm" @click="submitRenameWorkspace">
确认
</button>
</div>
</div>
</div>
<div class="conversation-personal-entry" :class="{ active: personalPageVisible }"> <div class="conversation-personal-entry" :class="{ active: personalPageVisible }">
<button <button
type="button" type="button"
@ -278,7 +526,7 @@
<script setup lang="ts"> <script setup lang="ts">
defineOptions({ name: 'ConversationSidebar' }); defineOptions({ name: 'ConversationSidebar' });
import { computed, onBeforeUnmount, onMounted, ref } from 'vue'; import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue';
import { storeToRefs } from 'pinia'; import { storeToRefs } from 'pinia';
import { useUiStore } from '@/stores/ui'; import { useUiStore } from '@/stores/ui';
import { useConversationStore } from '@/stores/conversation'; import { useConversationStore } from '@/stores/conversation';
@ -294,6 +542,11 @@ const props = withDefaults(
displayModeDisabled?: boolean; displayModeDisabled?: boolean;
runningTasks?: any[]; runningTasks?: any[];
currentWorkspaceId?: string; currentWorkspaceId?: string;
workspaces?: any[];
workspaceKind?: 'workspace' | 'project';
hostWorkspaceEnabled?: boolean;
groupByWorkspace?: boolean;
versioningHostMode?: boolean;
}>(), }>(),
{ {
showCollapseButton: true, showCollapseButton: true,
@ -301,11 +554,16 @@ const props = withDefaults(
displayMode: 'chat', displayMode: 'chat',
displayModeDisabled: false, displayModeDisabled: false,
runningTasks: () => [], runningTasks: () => [],
currentWorkspaceId: '' currentWorkspaceId: '',
workspaces: () => [],
workspaceKind: 'workspace',
hostWorkspaceEnabled: false,
groupByWorkspace: false,
versioningHostMode: false
} }
); );
defineEmits<{ const emit = defineEmits<{
(event: 'toggle'): void; (event: 'toggle'): void;
(event: 'create'): void; (event: 'create'): void;
(event: 'search', value: string): void; (event: 'search', value: string): void;
@ -319,6 +577,11 @@ defineEmits<{
(event: 'duplicate', id: string): void; (event: 'duplicate', id: string): void;
(event: 'toggle-workspace'): void; (event: 'toggle-workspace'): void;
(event: 'toggle-display-mode'): void; (event: 'toggle-display-mode'): void;
(event: 'select-workspace-conversation', payload: { conversationId: string; workspaceId: string }): void;
(event: 'create-workspace-conversation', workspaceId: string): void;
(event: 'reveal-workspace', workspaceId: string): void;
(event: 'rename-workspace', payload: { workspaceId: string; label: string }): void;
(event: 'pin-workspace', workspaceId: string): void;
}>(); }>();
const uiStore = useUiStore(); const uiStore = useUiStore();
@ -354,21 +617,236 @@ const monitorModeActive = computed(
); );
const displayModeDisabled = computed(() => props.displayModeDisabled); const displayModeDisabled = computed(() => props.displayModeDisabled);
const openActionMenuId = ref<string | null>(null); const openActionMenuId = ref<string | null>(null);
const actionMenuPosition = ref<{ top: number; right: number }>({ top: 0, right: 0 });
const hoverWorkspaceId = ref<string | null>(null);
const openWorkspaceMenuId = ref<string | null>(null);
const renameWorkspaceId = ref<string | null>(null);
const renameWorkspaceLabel = ref('');
const renameInput = ref<HTMLInputElement | null>(null);
watch(renameWorkspaceId, async (id) => {
if (id) {
await nextTick();
renameInput.value?.focus();
renameInput.value?.select();
}
});
const updateActionMenuPosition = (conversationId: string) => {
if (typeof document === 'undefined') return;
const trigger = document.querySelector(`[data-action-trigger="${conversationId}"]`) as HTMLElement | null;
if (!trigger) return;
const rect = trigger.getBoundingClientRect();
actionMenuPosition.value = {
top: rect.bottom + 6,
right: (typeof window !== 'undefined' ? window.innerWidth : 0) - rect.right
};
};
const toggleActionMenu = (conversationId: string) => { const toggleActionMenu = (conversationId: string) => {
openActionMenuId.value = openActionMenuId.value === conversationId ? null : conversationId; if (openActionMenuId.value === conversationId) {
openActionMenuId.value = null;
} else {
updateActionMenuPosition(conversationId);
openActionMenuId.value = conversationId;
}
};
watch(openActionMenuId, async (id) => {
if (id) {
await nextTick();
updateActionMenuPosition(id);
}
});
const shouldShowActionMenu = (conversationId: string) => {
return (
openActionMenuId.value === conversationId &&
!isConversationActive(conversationId) &&
!isConversationCompleted(conversationId)
);
}; };
const closeActionMenu = () => { const closeActionMenu = () => {
openActionMenuId.value = null; openActionMenuId.value = null;
}; };
onMounted(() => { const pinnedWorkspaceIds = ref<Set<string>>(new Set());
document.addEventListener('click', closeActionMenu); const workspaceOrder = ref<string[]>([]);
}); const sidebarSortLoaded = ref(false);
onBeforeUnmount(() => { const syncPinnedFromStore = () => {
document.removeEventListener('click', closeActionMenu); pinnedWorkspaceIds.value = new Set(
Array.isArray(personalizationStore.form.sidebar_pinned_workspaces)
? personalizationStore.form.sidebar_pinned_workspaces
: []
);
};
const syncOrderFromStore = () => {
workspaceOrder.value = Array.isArray(personalizationStore.form.sidebar_workspace_order)
? [...personalizationStore.form.sidebar_workspace_order]
: [];
};
const persistPinnedWorkspaces = () => {
if (!sidebarSortLoaded.value) return;
const next = Array.from(pinnedWorkspaceIds.value);
const current = personalizationStore.form.sidebar_pinned_workspaces || [];
if (JSON.stringify(next) === JSON.stringify(current)) return;
console.log('[sidebar-debug] persistPinnedWorkspaces', {
next: JSON.parse(JSON.stringify(next)),
current: JSON.parse(JSON.stringify(current))
});
personalizationStore.updateField({
key: 'sidebar_pinned_workspaces',
value: next
});
};
const persistWorkspaceOrder = () => {
if (!sidebarSortLoaded.value) return;
const next = Array.from(workspaceOrder.value);
const current = personalizationStore.form.sidebar_workspace_order || [];
if (JSON.stringify(next) === JSON.stringify(current)) return;
console.log('[sidebar-debug] persistWorkspaceOrder', {
next: JSON.parse(JSON.stringify(next)),
current: JSON.parse(JSON.stringify(current))
});
personalizationStore.updateField({
key: 'sidebar_workspace_order',
value: next
});
};
const bumpCurrentWorkspaceToTop = () => {
if (!sidebarSortLoaded.value) return;
const currentId = String(props.currentWorkspaceId || '');
console.log('[sidebar-debug] bumpCurrentWorkspaceToTop', {
currentId,
orderBefore: JSON.parse(JSON.stringify(workspaceOrder.value))
});
if (!currentId || pinnedWorkspaceIds.value.has(currentId)) return;
if (workspaceOrder.value[0] === currentId) return;
const order = workspaceOrder.value.filter((item) => item !== currentId);
order.unshift(currentId);
workspaceOrder.value = order;
persistWorkspaceOrder();
console.log('[sidebar-debug] bumpCurrentWorkspaceToTop after', {
orderAfter: JSON.parse(JSON.stringify(workspaceOrder.value))
});
};
const syncWorkspaceOrderWithWorkspaces = (workspaces: any[]) => {
if (!sidebarSortLoaded.value || !Array.isArray(workspaces)) return;
const ids = workspaces
.map((ws) => String(ws?.workspace_id || ''))
.filter(Boolean);
const existing = new Set(workspaceOrder.value);
const pinnedIds = new Set(
ids.filter((id) => pinnedWorkspaceIds.value.has(id))
);
const newIds = ids.filter((id) => !existing.has(id) && !pinnedIds.has(id));
const removedIds = new Set(
workspaceOrder.value.filter((id) => !ids.includes(id))
);
console.log('[sidebar-debug] syncWorkspaceOrderWithWorkspaces', {
ids,
newIds,
removedIds: Array.from(removedIds),
orderBefore: JSON.parse(JSON.stringify(workspaceOrder.value))
});
if (newIds.length || removedIds.size) {
workspaceOrder.value = workspaceOrder.value
.filter((id) => !removedIds.has(id))
.concat(newIds);
persistWorkspaceOrder();
}
};
const loadSidebarSortFromStore = () => {
if (!personalizationStore.loaded || sidebarSortLoaded.value) return;
console.log('[sidebar-debug] loadSidebarSortFromStore start', {
pinned: JSON.parse(JSON.stringify(personalizationStore.form.sidebar_pinned_workspaces)),
order: JSON.parse(JSON.stringify(personalizationStore.form.sidebar_workspace_order))
});
syncPinnedFromStore();
syncOrderFromStore();
sidebarSortLoaded.value = true;
if (Array.isArray(props.workspaces) && props.workspaces.length > 0) {
syncWorkspaceOrderWithWorkspaces(props.workspaces);
}
bumpCurrentWorkspaceToTop();
console.log('[sidebar-debug] loadSidebarSortFromStore end', {
pinned: Array.from(pinnedWorkspaceIds.value),
order: JSON.parse(JSON.stringify(workspaceOrder.value))
});
};
watch(
() => personalizationStore.loaded,
(loaded) => {
if (loaded) loadSidebarSortFromStore();
},
{ immediate: true }
);
watch(
() => personalizationStore.form.sidebar_pinned_workspaces,
() => {
if (!sidebarSortLoaded.value) return;
syncPinnedFromStore();
},
{ deep: true }
);
watch(
() => personalizationStore.form.sidebar_workspace_order,
() => {
if (!sidebarSortLoaded.value) return;
syncOrderFromStore();
if (Array.isArray(props.workspaces) && props.workspaces.length > 0) {
syncWorkspaceOrderWithWorkspaces(props.workspaces);
}
bumpCurrentWorkspaceToTop();
},
{ deep: true }
);
watch(
() => props.workspaces,
(list) => {
if (Array.isArray(list) && list.length > 0) {
syncWorkspaceOrderWithWorkspaces(list);
}
},
{ immediate: true, deep: true }
);
watch(
() => props.currentWorkspaceId,
() => bumpCurrentWorkspaceToTop(),
{ immediate: true }
);
const isGroupByWorkspaceActive = computed(
() => props.groupByWorkspace && props.hostWorkspaceEnabled && Array.isArray(props.workspaces) && props.workspaces.length > 0
);
const sortedWorkspaces = computed(() => {
const list = Array.isArray(props.workspaces) ? [...props.workspaces] : [];
return list.sort((a: any, b: any) => {
const aId = String(a?.workspace_id || '');
const bId = String(b?.workspace_id || '');
const aPinned = pinnedWorkspaceIds.value.has(aId) ? 1 : 0;
const bPinned = pinnedWorkspaceIds.value.has(bId) ? 1 : 0;
if (aPinned !== bPinned) return bPinned - aPinned;
const aIndex = workspaceOrder.value.indexOf(aId);
const bIndex = workspaceOrder.value.indexOf(bId);
if (aIndex !== -1 && bIndex !== -1) return aIndex - bIndex;
if (aIndex !== -1) return -1;
if (bIndex !== -1) return 1;
return String(a?.label || aId).localeCompare(String(b?.label || bId));
});
}); });
const displayConversations = computed(() => const displayConversations = computed(() =>
@ -416,4 +894,146 @@ const isConversationActive = (conversationId: string) =>
activeConversationIds.value.has(String(conversationId || '')); activeConversationIds.value.has(String(conversationId || ''));
const isConversationCompleted = (conversationId: string) => const isConversationCompleted = (conversationId: string) =>
completedConversationIds.value.has(String(conversationId || '')); completedConversationIds.value.has(String(conversationId || ''));
const isWorkspaceRunning = (workspaceId: string) =>
(Array.isArray(props.runningTasks) ? props.runningTasks : []).some(
(task) => isTaskActive(task) && String(task?.workspace_id || '') === workspaceId
);
const getWorkspaceGroup = (workspaceId: string) => {
return conversationStore.workspaceGroups.find((g) => g.workspaceId === workspaceId);
};
const getWorkspaceVisibleAndBufferConversations = (workspaceId: string) => {
const group = getWorkspaceGroup(workspaceId);
if (!group) return [];
const end = group.visibleLimit + group.bufferLimit;
return group.conversations.slice(0, end);
};
const getWorkspaceHasMore = (workspaceId: string) => {
const group = getWorkspaceGroup(workspaceId);
if (!group) return false;
return group.hasMore || group.conversations.length > group.visibleLimit;
};
const isWorkspaceExpanded = (workspaceId: string) => {
const group = getWorkspaceGroup(workspaceId);
return group ? group.expanded : true;
};
const toggleWorkspaceExpanded = (workspaceId: string) => {
const group = getWorkspaceGroup(workspaceId);
if (!group) {
conversationStore.ensureWorkspaceGroup(workspaceId);
void conversationStore.loadWorkspaceConversations(workspaceId);
return;
}
conversationStore.setWorkspaceGroupExpanded(workspaceId, !group.expanded);
if (!group.expanded && group.conversations.length === 0 && !group.loading) {
void conversationStore.loadWorkspaceConversations(workspaceId);
}
};
const ensureWorkspaceGroup = (workspaceId: string) => {
const group = getWorkspaceGroup(workspaceId);
if (!group) {
conversationStore.ensureWorkspaceGroup(workspaceId);
void conversationStore.loadWorkspaceConversations(workspaceId);
} else if (group.conversations.length === 0 && !group.loading && !group.loadingMore) {
void conversationStore.loadWorkspaceConversations(workspaceId);
}
};
watch(
[isGroupByWorkspaceActive, sortedWorkspaces],
([active, list]) => {
if (active && list.length > 0) {
list.forEach((ws: any) => ensureWorkspaceGroup(String(ws?.workspace_id || '')));
}
},
{ immediate: true }
);
const handleWorkspaceConversationClick = (conversationId: string, workspaceId: string) => {
console.log('[sidebar-debug] handleWorkspaceConversationClick', { conversationId, workspaceId });
openWorkspaceMenuId.value = null;
emit('select-workspace-conversation', { conversationId, workspaceId });
};
const handleCreateWorkspaceConversation = (workspaceId: string) => {
openWorkspaceMenuId.value = null;
emit('create-workspace-conversation', workspaceId);
};
const toggleWorkspaceMenu = (workspaceId: string) => {
openWorkspaceMenuId.value = openWorkspaceMenuId.value === workspaceId ? null : workspaceId;
};
const closeWorkspaceMenu = () => {
openWorkspaceMenuId.value = null;
};
const handlePinWorkspace = (workspaceId: string) => {
openWorkspaceMenuId.value = null;
const order = [...workspaceOrder.value];
const orderIndex = order.indexOf(workspaceId);
if (pinnedWorkspaceIds.value.has(workspaceId)) {
pinnedWorkspaceIds.value.delete(workspaceId);
if (orderIndex !== -1) order.splice(orderIndex, 1);
const currentId = String(props.currentWorkspaceId || '');
const currentIndex = order.indexOf(currentId);
const insertIndex = currentIndex !== -1 ? currentIndex + 1 : 0;
order.splice(insertIndex, 0, workspaceId);
} else {
pinnedWorkspaceIds.value.add(workspaceId);
if (orderIndex !== -1) order.splice(orderIndex, 1);
}
workspaceOrder.value = order;
persistPinnedWorkspaces();
persistWorkspaceOrder();
emit('pin-workspace', workspaceId);
};
const handleRevealWorkspace = (workspaceId: string) => {
openWorkspaceMenuId.value = null;
emit('reveal-workspace', workspaceId);
};
const startRenameWorkspace = (workspace: any) => {
openWorkspaceMenuId.value = null;
renameWorkspaceId.value = String(workspace?.workspace_id || '');
renameWorkspaceLabel.value = String(workspace?.label || workspace?.workspace_id || '');
};
const submitRenameWorkspace = () => {
const workspaceId = renameWorkspaceId.value;
const label = renameWorkspaceLabel.value.trim();
if (workspaceId && label) {
emit('rename-workspace', { workspaceId, label });
}
renameWorkspaceId.value = null;
renameWorkspaceLabel.value = '';
};
const cancelRenameWorkspace = () => {
renameWorkspaceId.value = null;
renameWorkspaceLabel.value = '';
};
const loadMoreWorkspaceConversations = (workspaceId: string) => {
void conversationStore.loadMoreWorkspaceConversations(workspaceId);
};
onMounted(() => {
document.addEventListener('click', closeActionMenu);
document.addEventListener('click', closeWorkspaceMenu);
if (isGroupByWorkspaceActive.value) {
sortedWorkspaces.value.forEach((ws: any) => ensureWorkspaceGroup(String(ws?.workspace_id || '')));
}
});
onBeforeUnmount(() => {
document.removeEventListener('click', closeActionMenu);
document.removeEventListener('click', closeWorkspaceMenu);
});
</script> </script>

View File

@ -875,6 +875,16 @@ export async function initializeLegacySocket(ctx: any) {
ctx.hasMoreConversations = false; ctx.hasMoreConversations = false;
ctx.loadingMoreConversations = false; ctx.loadingMoreConversations = false;
ctx.loadConversationsList(); ctx.loadConversationsList();
// 同时刷新分组侧边栏中已加载的工作区
try {
const { useConversationStore } = require('../stores/conversation');
const conversationStore = useConversationStore();
conversationStore.workspaceGroups.forEach((group: any) => {
conversationStore.loadWorkspaceConversations(group.workspaceId, { refresh: true });
});
} catch (_err) {
// ignore
}
}); });
// 监听状态更新事件 // 监听状态更新事件

View File

@ -8,6 +8,20 @@ export interface ConversationSummary {
total_tools?: number; total_tools?: number;
} }
export interface WorkspaceConversationGroup {
workspaceId: string;
conversations: ConversationSummary[];
loading: boolean;
hasMore: boolean;
loadingMore: boolean;
offset: number;
visibleOffset: number;
visibleLimit: number;
bufferLimit: number;
fetchLimit: number;
expanded: boolean;
}
interface ConversationState { interface ConversationState {
conversations: ConversationSummary[]; conversations: ConversationSummary[];
searchResults: ConversationSummary[]; searchResults: ConversationSummary[];
@ -30,6 +44,7 @@ interface ConversationState {
conversationsLimit: number; conversationsLimit: number;
runningWorkspaceTasks: any[]; runningWorkspaceTasks: any[];
acknowledgedCompletedTaskIds: string[]; acknowledgedCompletedTaskIds: string[];
workspaceGroups: WorkspaceConversationGroup[];
} }
export const useConversationStore = defineStore('conversation', { export const useConversationStore = defineStore('conversation', {
@ -54,7 +69,8 @@ export const useConversationStore = defineStore('conversation', {
conversationsOffset: 0, conversationsOffset: 0,
conversationsLimit: 20, conversationsLimit: 20,
runningWorkspaceTasks: [], runningWorkspaceTasks: [],
acknowledgedCompletedTaskIds: [] acknowledgedCompletedTaskIds: [],
workspaceGroups: []
}), }),
actions: { actions: {
resetConversations() { resetConversations() {
@ -79,6 +95,171 @@ export const useConversationStore = defineStore('conversation', {
clearTimeout(this.searchTimer); clearTimeout(this.searchTimer);
this.searchTimer = null; this.searchTimer = null;
} }
},
setWorkspaceGroups(groups: WorkspaceConversationGroup[]) {
this.workspaceGroups = groups;
},
setWorkspaceGroupExpanded(workspaceId: string, expanded: boolean) {
const group = this.workspaceGroups.find((g) => g.workspaceId === workspaceId);
if (group) {
group.expanded = expanded;
}
},
setWorkspaceGroupConversations(workspaceId: string, conversations: ConversationSummary[]) {
const group = this.workspaceGroups.find((g) => g.workspaceId === workspaceId);
if (group) {
group.conversations = conversations;
}
},
appendWorkspaceGroupConversations(workspaceId: string, conversations: ConversationSummary[]) {
const group = this.workspaceGroups.find((g) => g.workspaceId === workspaceId);
if (group) {
group.conversations.push(...conversations);
}
},
setWorkspaceGroupLoading(workspaceId: string, loading: boolean) {
const group = this.workspaceGroups.find((g) => g.workspaceId === workspaceId);
if (group) {
group.loading = loading;
}
},
setWorkspaceGroupLoadingMore(workspaceId: string, loadingMore: boolean) {
const group = this.workspaceGroups.find((g) => g.workspaceId === workspaceId);
if (group) {
group.loadingMore = loadingMore;
}
},
setWorkspaceGroupHasMore(workspaceId: string, hasMore: boolean) {
const group = this.workspaceGroups.find((g) => g.workspaceId === workspaceId);
if (group) {
group.hasMore = hasMore;
}
},
setWorkspaceGroupOffset(workspaceId: string, offset: number) {
const group = this.workspaceGroups.find((g) => g.workspaceId === workspaceId);
if (group) {
group.offset = offset;
}
},
setWorkspaceGroupVisibleOffset(workspaceId: string, visibleOffset: number) {
const group = this.workspaceGroups.find((g) => g.workspaceId === workspaceId);
if (group) {
group.visibleOffset = Math.max(0, visibleOffset);
}
},
ensureWorkspaceGroup(workspaceId: string) {
if (!workspaceId) return;
const exists = this.workspaceGroups.some((g) => g.workspaceId === workspaceId);
if (!exists) {
this.workspaceGroups.push({
workspaceId,
conversations: [],
loading: false,
hasMore: false,
loadingMore: false,
offset: 0,
visibleOffset: 0,
visibleLimit: 5,
bufferLimit: 20,
fetchLimit: 25,
expanded: true
});
}
},
async loadWorkspaceConversations(
workspaceId: string,
{ reset = false, refresh = false } = {}
) {
if (!workspaceId) return;
console.log('[sidebar-debug] loadWorkspaceConversations', {
workspaceId,
reset,
refresh
});
let index = this.workspaceGroups.findIndex((g) => g.workspaceId === workspaceId);
if (index === -1) {
this.ensureWorkspaceGroup(workspaceId);
index = this.workspaceGroups.length - 1;
}
const group = this.workspaceGroups[index];
if (reset) {
group.conversations = [];
group.offset = 0;
group.visibleOffset = 0;
group.hasMore = false;
}
if (group.loading || group.loadingMore) return;
const fetchOffset = refresh ? 0 : group.offset;
group.loading = true;
try {
const response = await fetch(
`/api/conversations?workspace_id=${encodeURIComponent(workspaceId)}&limit=${group.fetchLimit}&offset=${fetchOffset}`
);
const data = await response.json();
if (data.success) {
const items = (data.data?.conversations || []).map((conv: any) => ({
id: conv.id,
title: conv.title,
updated_at: conv.updated_at,
total_messages: conv.total_messages,
total_tools: conv.total_tools
}));
if (refresh) {
const tail = group.conversations.slice(items.length);
group.conversations = [...items, ...tail];
} else if (fetchOffset === 0) {
group.conversations = items;
} else {
group.conversations.push(...items);
}
group.hasMore = !!data.data?.has_more;
if (!refresh) {
group.offset = fetchOffset + items.length;
}
} else {
console.error('加载工作区对话失败:', data.error);
}
} catch (error) {
console.error('加载工作区对话异常:', error);
} finally {
group.loading = false;
}
},
async loadMoreWorkspaceConversations(workspaceId: string) {
const index = this.workspaceGroups.findIndex((g) => g.workspaceId === workspaceId);
if (index === -1) return;
const group = this.workspaceGroups[index];
if (group.loadingMore) return;
const hasHidden = group.conversations.length > group.visibleLimit;
if (!hasHidden && !group.hasMore) return;
group.loadingMore = true;
// 先尝试从后端再加载 20 条作为新的缓冲
if (group.hasMore) {
try {
const fetchOffset = group.conversations.length;
const response = await fetch(
`/api/conversations?workspace_id=${encodeURIComponent(workspaceId)}&limit=${group.bufferLimit}&offset=${fetchOffset}`
);
const data = await response.json();
if (data.success) {
const items = (data.data?.conversations || []).map((conv: any) => ({
id: conv.id,
title: conv.title,
updated_at: conv.updated_at,
total_messages: conv.total_messages,
total_tools: conv.total_tools
}));
group.conversations.push(...items);
group.hasMore = !!data.data?.has_more;
}
} catch (error) {
console.error('加载更多工作区对话异常:', error);
}
}
// 把可见区扩大 20 条,让用户直接看到更多对话
group.visibleLimit += group.bufferLimit;
group.loadingMore = false;
} }
} }
}); });

View File

@ -26,6 +26,7 @@ interface PersonalForm {
silent_tool_disable: boolean; silent_tool_disable: boolean;
enhanced_tool_display: boolean; enhanced_tool_display: boolean;
compact_message_display: CompactMessageDisplay; compact_message_display: CompactMessageDisplay;
block_display_mode: BlockDisplayMode;
show_status_avatar: boolean; show_status_avatar: boolean;
show_git_status_bar: boolean; show_git_status_bar: boolean;
auto_open_terminal_panel: boolean; auto_open_terminal_panel: boolean;
@ -37,7 +38,7 @@ interface PersonalForm {
use_custom_names: boolean; use_custom_names: boolean;
profession: string; profession: string;
tone: string; tone: string;
considerations: string[]; considerations: string;
thinking_interval: number | null; thinking_interval: number | null;
disabled_tool_categories: string[]; disabled_tool_categories: string[];
default_run_mode: RunMode | null; default_run_mode: RunMode | null;
@ -59,6 +60,9 @@ interface PersonalForm {
agents_md_auto_inject: boolean; agents_md_auto_inject: boolean;
allow_root_file_creation: boolean; allow_root_file_creation: boolean;
default_hide_workspace: boolean; default_hide_workspace: boolean;
group_sidebar_by_workspace: boolean;
sidebar_pinned_workspaces: string[];
sidebar_workspace_order: string[];
theme: 'classic' | 'light' | 'dark'; theme: 'classic' | 'light' | 'dark';
goal_review_mode: 'readonly' | 'active'; goal_review_mode: 'readonly' | 'active';
goal_end_conditions: string[]; goal_end_conditions: string[];
@ -78,12 +82,9 @@ interface PersonalizationState {
loaded: boolean; loaded: boolean;
status: string; status: string;
error: string; error: string;
maxConsiderations: number;
toggleUpdating: boolean; toggleUpdating: boolean;
overlayPressActive: boolean; overlayPressActive: boolean;
newConsideration: string;
tonePresets: string[]; tonePresets: string[];
draggedConsiderationIndex: number | null;
form: PersonalForm; form: PersonalForm;
toolCategories: Array<{ id: string; label: string }>; toolCategories: Array<{ id: string; label: string }>;
skillsCatalog: Array<{ id: string; label: string; description?: 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'; 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) => { const persistStackedHideBorders = (value: boolean) => {
if (typeof window === 'undefined' || !window.localStorage) { if (typeof window === 'undefined' || !window.localStorage) {
return; return;
@ -146,6 +169,7 @@ const defaultForm = (): PersonalForm => ({
silent_tool_disable: false, silent_tool_disable: false,
enhanced_tool_display: true, enhanced_tool_display: true,
compact_message_display: 'full', compact_message_display: 'full',
block_display_mode: loadCachedBlockDisplayMode(),
show_status_avatar: true, show_status_avatar: true,
show_git_status_bar: true, show_git_status_bar: true,
auto_open_terminal_panel: true, auto_open_terminal_panel: true,
@ -157,7 +181,7 @@ const defaultForm = (): PersonalForm => ({
use_custom_names: false, use_custom_names: false,
profession: '', profession: '',
tone: '', tone: '',
considerations: [], considerations: '',
thinking_interval: null, thinking_interval: null,
disabled_tool_categories: [], disabled_tool_categories: [],
default_run_mode: null, default_run_mode: null,
@ -179,6 +203,9 @@ const defaultForm = (): PersonalForm => ({
agents_md_auto_inject: false, agents_md_auto_inject: false,
allow_root_file_creation: false, allow_root_file_creation: false,
default_hide_workspace: false, default_hide_workspace: false,
group_sidebar_by_workspace: false,
sidebar_pinned_workspaces: [],
sidebar_workspace_order: [],
theme: loadCachedTheme(), theme: loadCachedTheme(),
goal_review_mode: 'readonly', goal_review_mode: 'readonly',
goal_end_conditions: ['max_turns'], goal_end_conditions: ['max_turns'],
@ -244,12 +271,9 @@ export const usePersonalizationStore = defineStore('personalization', {
loaded: false, loaded: false,
status: '', status: '',
error: '', error: '',
maxConsiderations: 10,
toggleUpdating: false, toggleUpdating: false,
overlayPressActive: false, overlayPressActive: false,
newConsideration: '',
tonePresets: ['健谈', '幽默', '直言不讳', '鼓励性', '诗意', '企业商务', '打破常规', '同理心'], tonePresets: ['健谈', '幽默', '直言不讳', '鼓励性', '诗意', '企业商务', '打破常规', '同理心'],
draggedConsiderationIndex: null,
form: defaultForm(), form: defaultForm(),
toolCategories: [], toolCategories: [],
skillsCatalog: [], skillsCatalog: [],
@ -268,7 +292,6 @@ export const usePersonalizationStore = defineStore('personalization', {
}, },
closeDrawer() { closeDrawer() {
this.visible = false; this.visible = false;
this.draggedConsiderationIndex = null;
this.overlayPressActive = false; this.overlayPressActive = false;
}, },
handleOverlayPressStart(event: Event) { handleOverlayPressStart(event: Event) {
@ -311,9 +334,7 @@ export const usePersonalizationStore = defineStore('personalization', {
applyPersonalizationData(data: any) { applyPersonalizationData(data: any) {
// 若后端未返回默认模型(旧版本接口),保持当前已选模型而不是回退到内置模型 // 若后端未返回默认模型(旧版本接口),保持当前已选模型而不是回退到内置模型
const fallbackModel = const fallbackModel =
(this.form && typeof this.form.default_model === 'string' this.form && typeof this.form.default_model === 'string' ? this.form.default_model : null;
? this.form.default_model
: null);
const fallbackTheme = this.form?.theme || loadCachedTheme(); const fallbackTheme = this.form?.theme || loadCachedTheme();
this.form = { this.form = {
enabled: !!data.enabled, enabled: !!data.enabled,
@ -327,8 +348,9 @@ export const usePersonalizationStore = defineStore('personalization', {
: 'medium', : 'medium',
auto_generate_title: data.auto_generate_title !== false, auto_generate_title: data.auto_generate_title !== false,
recent_conversations_prompt_enabled: !!data.recent_conversations_prompt_enabled, recent_conversations_prompt_enabled: !!data.recent_conversations_prompt_enabled,
recent_conversations_prompt_limit: recent_conversations_prompt_limit: this.normalizeRecentConversationsPromptLimit(
this.normalizeRecentConversationsPromptLimit(data.recent_conversations_prompt_limit), data.recent_conversations_prompt_limit
),
tool_intent_enabled: !!data.tool_intent_enabled, tool_intent_enabled: !!data.tool_intent_enabled,
skill_hints_enabled: !!data.skill_hints_enabled, skill_hints_enabled: !!data.skill_hints_enabled,
skill_strict_terminal_enabled: !!data.skill_strict_terminal_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, silent_tool_disable: !!data.silent_tool_disable,
enhanced_tool_display: data.enhanced_tool_display !== false, enhanced_tool_display: data.enhanced_tool_display !== false,
compact_message_display: data.compact_message_display === 'brief' ? 'brief' : 'full', 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_status_avatar: data.show_status_avatar !== false,
show_git_status_bar: data.show_git_status_bar !== false, show_git_status_bar: data.show_git_status_bar !== false,
auto_open_terminal_panel: data.auto_open_terminal_panel !== false, auto_open_terminal_panel: data.auto_open_terminal_panel !== false,
stacked_hide_borders: !!data.stacked_hide_borders, stacked_hide_borders: !!data.stacked_hide_borders,
enhanced_tool_display_categories: Array.isArray(data.enhanced_tool_display_categories) 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) enabled_skills: Array.isArray(data.enabled_skills)
? data.enabled_skills.filter((item: unknown) => typeof item === 'string') ? 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, use_custom_names: !!data.use_custom_names,
profession: data.profession || '', profession: data.profession || '',
tone: data.tone || '', 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: thinking_interval:
typeof data.thinking_interval === 'number' ? data.thinking_interval : null, typeof data.thinking_interval === 'number' ? data.thinking_interval : null,
disabled_tool_categories: Array.isArray(data.disabled_tool_categories) disabled_tool_categories: Array.isArray(data.disabled_tool_categories)
@ -374,8 +409,7 @@ export const usePersonalizationStore = defineStore('personalization', {
? data.default_permission_mode ? data.default_permission_mode
: 'unrestricted', : 'unrestricted',
versioning_enabled_by_default: data.versioning_enabled_by_default !== false, versioning_enabled_by_default: data.versioning_enabled_by_default !== false,
versioning_backup_mode: versioning_backup_mode: data.versioning_backup_mode === 'full' ? 'full' : 'shallow',
data.versioning_backup_mode === 'full' ? 'full' : 'shallow',
versioning_restore_mode: 'overwrite', versioning_restore_mode: 'overwrite',
default_model: typeof data.default_model === 'string' ? data.default_model : fallbackModel, default_model: typeof data.default_model === 'string' ? data.default_model : fallbackModel,
image_compression: image_compression:
@ -404,12 +438,17 @@ export const usePersonalizationStore = defineStore('personalization', {
agents_md_auto_inject: !!data.agents_md_auto_inject, agents_md_auto_inject: !!data.agents_md_auto_inject,
allow_root_file_creation: !!data.allow_root_file_creation, allow_root_file_creation: !!data.allow_root_file_creation,
default_hide_workspace: !!data.default_hide_workspace, default_hide_workspace: !!data.default_hide_workspace,
group_sidebar_by_workspace: !!data.group_sidebar_by_workspace,
sidebar_pinned_workspaces: Array.isArray(data.sidebar_pinned_workspaces)
? data.sidebar_pinned_workspaces.filter((item: unknown) => typeof item === 'string')
: [],
sidebar_workspace_order: Array.isArray(data.sidebar_workspace_order)
? data.sidebar_workspace_order.filter((item: unknown) => typeof item === 'string')
: [],
theme: ['classic', 'light', 'dark'].includes(data.theme) ? data.theme : fallbackTheme, theme: ['classic', 'light', 'dark'].includes(data.theme) ? data.theme : fallbackTheme,
goal_review_mode: data.goal_review_mode === 'active' ? 'active' : 'readonly', goal_review_mode: data.goal_review_mode === 'active' ? 'active' : 'readonly',
goal_end_conditions: Array.isArray(data.goal_end_conditions) goal_end_conditions: Array.isArray(data.goal_end_conditions)
? data.goal_end_conditions.filter( ? data.goal_end_conditions.filter((x: any) => x === 'max_turns' || x === 'max_tokens')
(x: any) => x === 'max_turns' || x === 'max_tokens'
)
: ['max_turns'], : ['max_turns'],
goal_max_turns: goal_max_turns:
typeof data.goal_max_turns === 'number' && data.goal_max_turns > 0 typeof data.goal_max_turns === 'number' && data.goal_max_turns > 0
@ -421,7 +460,8 @@ export const usePersonalizationStore = defineStore('personalization', {
: null : null
}; };
// 如果theme发生变化应用到界面 // 如果theme发生变化应用到界面
const currentTheme = (typeof window !== 'undefined' && window.localStorage) const currentTheme =
typeof window !== 'undefined' && window.localStorage
? window.localStorage.getItem(THEME_STORAGE_KEY) ? window.localStorage.getItem(THEME_STORAGE_KEY)
: null; : null;
if (this.form.theme !== currentTheme) { if (this.form.theme !== currentTheme) {
@ -439,9 +479,15 @@ export const usePersonalizationStore = defineStore('personalization', {
this.form = { ...this.form, compact_message_display: 'brief' }; this.form = { ...this.form, compact_message_display: 'brief' };
void this.persistCompactMessageDisplay('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 = {
...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.persistExperiments();
this.clearFeedback(); this.clearFeedback();
@ -803,69 +849,14 @@ export const usePersonalizationStore = defineStore('personalization', {
this.clearFeedback(); this.clearFeedback();
this.scheduleAutoSave(); this.scheduleAutoSave();
}, },
updateNewConsideration(value: string) { updateConsiderations(value: string) {
this.newConsideration = value;
this.clearFeedback();
},
addConsideration() {
if (!this.newConsideration) {
return;
}
if (this.form.considerations.length >= this.maxConsiderations) {
return;
}
this.form = { this.form = {
...this.form, ...this.form,
considerations: [...this.form.considerations, this.newConsideration] considerations: value
};
this.newConsideration = '';
this.clearFeedback();
this.scheduleAutoSave();
},
removeConsideration(index: number) {
const items = [...this.form.considerations];
items.splice(index, 1);
this.form = {
...this.form,
considerations: items
}; };
this.clearFeedback(); this.clearFeedback();
this.scheduleAutoSave(); 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() { async logout() {
try { try {
console.info('[auth-debug] logout clicked, sending POST /logout'); console.info('[auth-debug] logout clicked, sending POST /logout');
@ -911,6 +902,10 @@ export const usePersonalizationStore = defineStore('personalization', {
blockDisplayMode: mode blockDisplayMode: mode
}; };
this.persistExperiments(); this.persistExperiments();
this.form = {
...this.form,
block_display_mode: mode
};
this.scheduleAutoSave(); this.scheduleAutoSave();
}, },
setCompactMessageDisplay(mode: CompactMessageDisplay) { 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 { .conversation-sidebar .conversation-actions-menu {
position: absolute;
right: 0;
top: calc(100% + 6px);
z-index: 140; z-index: 140;
width: max-content; width: max-content;
min-width: 92px; min-width: 92px;
@ -553,6 +550,17 @@ body[data-theme='dark'] .conversation-sidebar .conversation-item.active:focus-wi
transform 150ms cubic-bezier(0.22, 1, 0.36, 1); transform 150ms cubic-bezier(0.22, 1, 0.36, 1);
} }
.conversation-sidebar .conversation-actions-menu--absolute {
position: absolute;
right: 0;
top: calc(100% + 6px);
}
.conversation-sidebar .conversation-actions-menu--fixed {
position: fixed;
z-index: 1000;
}
.conversation-sidebar .conversation-actions-menu.open { .conversation-sidebar .conversation-actions-menu.open {
opacity: 1; opacity: 1;
transform: translateY(0) scale(1); transform: translateY(0) scale(1);
@ -680,6 +688,309 @@ body[data-theme='dark'] .conversation-sidebar .conversation-item.active:focus-wi
border: 0; border: 0;
} }
/* Workspace groups */
.workspace-groups {
display: flex;
flex-direction: column;
gap: 2px;
padding: 0 2px;
}
.workspace-group {
display: flex;
flex-direction: column;
}
.workspace-group-header {
position: relative;
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
align-items: center;
height: var(--conversation-row-height);
border-radius: 12px;
transition: background 140ms ease;
}
.workspace-group-header:hover {
background: var(--theme-tab-active);
}
:root[data-theme='dark'] .workspace-group-header:hover,
body[data-theme='dark'] .workspace-group-header:hover {
background: var(--hover-bg);
}
.workspace-group-toggle {
display: grid;
grid-template-columns: 24px minmax(0, 1fr) auto;
align-items: center;
gap: 6px;
height: 100%;
padding: 0 10px;
border: 0;
border-radius: 12px;
background: transparent;
color: var(--claude-text);
text-align: left;
cursor: pointer;
}
.workspace-folder-icon {
color: var(--claude-text-secondary);
}
.workspace-group-label {
min-width: 0;
font-size: 14px;
font-weight: 600;
letter-spacing: -0.01em;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.workspace-current-dot {
width: 6px;
height: 6px;
border-radius: 50%;
background: var(--accent-primary);
}
.workspace-running-loader {
width: 14px;
height: 14px;
border: 2px solid var(--claude-text-secondary);
border-bottom-color: transparent;
border-radius: 50%;
display: inline-block;
box-sizing: border-box;
animation: conversation-running-rotation 1s linear infinite;
}
.workspace-group-actions {
display: flex;
align-items: center;
gap: 2px;
padding-right: 6px;
opacity: 0;
transition: opacity 160ms ease;
}
.workspace-group-actions.visible {
opacity: 1;
}
.workspace-new-btn,
.workspace-more-btn {
width: 28px;
height: 28px;
border: 0;
border-radius: 8px;
background: transparent;
color: var(--claude-text-secondary);
display: grid;
place-items: center;
cursor: pointer;
transition:
background 140ms ease,
color 140ms ease;
}
.workspace-new-btn:hover,
.workspace-more-btn:hover,
.workspace-group-actions.visible .workspace-more-btn[aria-expanded='true'] {
background: var(--theme-surface-strong);
color: var(--claude-text);
}
.workspace-more-btn span {
position: relative;
display: inline-block;
width: 18px;
height: 18px;
}
.workspace-more-btn span::before {
content: '';
position: absolute;
left: 3px;
top: 8px;
width: 3px;
height: 3px;
border-radius: 999px;
background: currentColor;
box-shadow:
4.5px 0 0 currentColor,
9px 0 0 currentColor;
}
.workspace-actions-menu {
position: absolute;
right: 6px;
top: calc(100% + 4px);
z-index: 140;
width: max-content;
min-width: 120px;
padding: 6px;
border: 1px solid var(--claude-border);
border-radius: 14px;
background: var(--theme-surface-strong);
box-shadow: none;
opacity: 0;
transform: translateY(-4px) scale(0.98);
pointer-events: none;
transition:
opacity 120ms ease,
transform 150ms cubic-bezier(0.22, 1, 0.36, 1);
}
.workspace-actions-menu.open {
opacity: 1;
transform: translateY(0) scale(1);
pointer-events: auto;
}
.workspace-actions-menu button {
width: 100%;
min-width: 80px;
height: 34px;
border: 0;
border-radius: 10px;
background: transparent;
color: var(--claude-text);
display: grid;
align-items: center;
padding: 0 10px;
text-align: left;
cursor: pointer;
font-size: 14px;
transition: background 140ms ease;
}
.workspace-actions-menu button:hover {
background: var(--theme-tab-active);
}
.workspace-group-children {
display: grid;
grid-template-rows: 0fr;
transition: grid-template-rows 260ms cubic-bezier(0.22, 1, 0.36, 1);
}
.workspace-group-children.expanded {
grid-template-rows: 1fr;
}
.workspace-group-children-inner {
overflow: hidden;
display: flex;
flex-direction: column;
}
.workspace-conversations-viewport {
position: relative;
max-height: calc((var(--conversation-row-height) + 2px) * var(--workspace-visible-count, 5));
overflow: hidden;
}
.workspace-conversations-list {
position: relative;
display: flex;
flex-direction: column;
}
.workspace-conversation-item {
grid-template-columns: minmax(0, 1fr) 40px;
}
.workspace-no-conversations,
.workspace-conversations-loading {
color: var(--claude-text-secondary);
font-size: 13px;
padding: 10px 12px;
}
.workspace-load-more {
padding: 4px 0 8px;
}
/* Workspace rename dialog */
.workspace-rename-overlay {
position: absolute;
inset: 0;
z-index: 200;
display: grid;
place-items: center;
background: var(--overlay-scrim);
}
.workspace-rename-card {
width: min(calc(100% - 32px), 320px);
padding: 16px;
border-radius: 16px;
background: var(--theme-surface-strong);
border: 1px solid var(--claude-border);
display: flex;
flex-direction: column;
gap: 12px;
}
.workspace-rename-title {
font-size: 15px;
font-weight: 600;
color: var(--claude-text);
}
.workspace-rename-input {
width: 100%;
height: 40px;
padding: 0 12px;
border: 1px solid var(--claude-border);
border-radius: 10px;
background: var(--surface-base);
color: var(--claude-text);
font-size: 14px;
outline: none;
}
.workspace-rename-input:focus {
border-color: var(--accent-primary);
}
.workspace-rename-actions {
display: flex;
justify-content: flex-end;
gap: 8px;
}
.workspace-rename-actions button {
height: 34px;
padding: 0 14px;
border: 0;
border-radius: 10px;
font-size: 14px;
cursor: pointer;
transition: background 140ms ease;
}
.workspace-rename-cancel {
background: transparent;
color: var(--claude-text);
}
.workspace-rename-cancel:hover {
background: var(--theme-tab-active);
}
.workspace-rename-confirm {
background: var(--accent-primary);
color: var(--accent-on-primary);
}
.workspace-rename-confirm:hover {
background: var(--accent-primary-hover);
}
/* Final sidebar-specific guards against broad legacy rules. */ /* Final sidebar-specific guards against broad legacy rules. */
.conversation-sidebar .search-input { .conversation-sidebar .search-input {
background: transparent !important; background: transparent !important;

View File

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