From b151f8ff7160ed325a04a3bb4d3dacf096d6b520 Mon Sep 17 00:00:00 2001 From: JOJO <1498581755@qq.com> Date: Tue, 28 Apr 2026 13:04:51 +0800 Subject: [PATCH] feat: add host workspace manager, mcp-tool-config skill, and UI enhancements --- .env.example | 2 + .gitignore | 3 + README.md | 16 +- agentskills/mcp-tool-config/SKILL.md | 7 + .../mcp-tool-config/mcp-tool-config-guide.md | 202 ++++++++++++++ config/host_workspaces.json.example | 10 + config/paths.py | 5 +- core/main_terminal.py | 118 ++++++++ core/main_terminal_parts/context.py | 14 + modules/host_workspace_manager.py | 239 ++++++++++++++++ modules/skills_manager.py | 48 +++- server/auth.py | 8 +- server/chat_flow_task_main.py | 6 + server/context.py | 111 +++++++- server/status.py | 260 +++++++++++++++++- server/tasks.py | 22 +- static/src/App.vue | 29 ++ static/src/app/components.ts | 4 + static/src/app/methods/ui.ts | 198 ++++++++++++- static/src/app/state.ts | 11 + .../overlay/HostWorkspaceCreateDialog.vue | 211 ++++++++++++++ static/src/components/panels/LeftPanel.vue | 86 +++++- .../styles/components/panels/_left-panel.scss | 182 ++++++++++++ test/test_host_workspace_manager.py | 69 +++++ utils/context_manager.py | 46 +++- utils/host_workspace_debug.py | 37 +++ 26 files changed, 1901 insertions(+), 43 deletions(-) create mode 100644 agentskills/mcp-tool-config/SKILL.md create mode 100644 agentskills/mcp-tool-config/mcp-tool-config-guide.md create mode 100644 config/host_workspaces.json.example create mode 100644 modules/host_workspace_manager.py create mode 100644 static/src/components/overlay/HostWorkspaceCreateDialog.vue create mode 100644 test/test_host_workspace_manager.py create mode 100644 utils/host_workspace_debug.py diff --git a/.env.example b/.env.example index 8feec66..49d921a 100644 --- a/.env.example +++ b/.env.example @@ -26,6 +26,8 @@ WEB_SECRET_KEY=replace-with-random-hex # === 终端容器沙箱 ============================================================= # 模式:docker / host TERMINAL_SANDBOX_MODE=docker +# 宿主机模式工作区配置(JSON 文件) +HOST_WORKSPACES_FILE=./config/host_workspaces.json # 容器镜像及挂载路径 TERMINAL_SANDBOX_IMAGE=python:3.11-slim TERMINAL_SANDBOX_MOUNT_PATH=/workspace diff --git a/.gitignore b/.gitignore index 20d816a..b72e2b9 100644 --- a/.gitignore +++ b/.gitignore @@ -38,3 +38,6 @@ ax_probe_chrome.py # Ignore docs and runtime pid webapp.pid doc/ + +# Host workspace config (local, use .example) +config/host_workspaces.json diff --git a/README.md b/README.md index 9e90af5..ec21de2 100644 --- a/README.md +++ b/README.md @@ -124,7 +124,19 @@ 配置: ```bash TERMINAL_SANDBOX_MODE=host -HOST_PROJECT_PATH=/path/to/project # 可选,指定项目路径 +HOST_WORKSPACES_FILE=./config/host_workspaces.json +``` + +`HOST_WORKSPACES_FILE` 对应 JSON 示例: + +```json +{ + "default_workspace_id": "default", + "workspaces": [ + { "workspace_id": "default", "label": "默认工作区", "path": "./project" }, + { "workspace_id": "my-repo", "label": "我的仓库", "path": "/Users/you/code/my-repo" } + ] +} ``` #### Docker 容器模式 (Docker Mode) @@ -482,7 +494,7 @@ static/src/ **宿主机模式**: - `TERMINAL_SANDBOX_MODE=host`: 启用宿主机模式 -- `HOST_PROJECT_PATH`: 指定项目路径(可选) +- `HOST_WORKSPACES_FILE`: 宿主机工作区 JSON 配置文件路径 - `LINUX_SAFETY`: Linux 安全保护开关 **Docker 模式**: diff --git a/agentskills/mcp-tool-config/SKILL.md b/agentskills/mcp-tool-config/SKILL.md new file mode 100644 index 0000000..0260dce --- /dev/null +++ b/agentskills/mcp-tool-config/SKILL.md @@ -0,0 +1,7 @@ +--- +name: mcp-tool-config +description: 指导模型在宿主机模式下,如何给自己配置新的mcp工具 +--- + +Just Read This File +skills/mcp-tool-config/mcp-tool-config-guide.md \ No newline at end of file diff --git a/agentskills/mcp-tool-config/mcp-tool-config-guide.md b/agentskills/mcp-tool-config/mcp-tool-config-guide.md new file mode 100644 index 0000000..4fa83f8 --- /dev/null +++ b/agentskills/mcp-tool-config/mcp-tool-config-guide.md @@ -0,0 +1,202 @@ +# 本项目新增 MCP 工具配置指南 + +> 最后更新:2026-04-26 +> 适用目录:`/Users/jojo/Desktop/agents/正在修复中/agents` + +本文面向后续维护者,说明如何在当前项目里新增并接入一个 MCP 工具(MCP Server)。 + +--- + +## 1. 先了解本项目里的 MCP 架构 + +当前实现是「**本项目作为 MCP 客户端桥接层**」: + +1. 在后台配置一个或多个 MCP Server(stdio / streamable_http) +2. 系统通过 `tools/list` 拉取远端工具 +3. 本地自动生成工具别名:`mcp____` +4. 模型调用该别名时,桥接层转发到对应服务的 `tools/call` +5. 对于同一服务,桥接层会复用长连接会话(避免每次调用都重启 MCP 进程),并处理常见 Server→Client 请求(如 `ping`、`roots/list`) + +内置了一个原生工具: + +- `list_mcp_servers`:查看服务、缓存工具和别名映射(可选刷新) + +--- + +## 2. 配置入口(如果你是个人类) + +### 2.1 管理后台入口(推荐) + +- 打开:`/admin/policy` +- 页面中的「**MCP 服务配置(统一工具扩展)**」区域可做增删改查、同步工具 + +### 2.2 个人空间入口(宿主机管理员) + +- 个人空间里有单独的「**MCP 配置**」入口 +- 仅在 **宿主机模式 + 管理员** 时显示 +- 点击后会跳转到 `/admin/policy` + +> 管理 API 受管理员权限和二级密码校验保护,建议优先使用页面配置,不建议直接手改请求。 + +--- + +## 3. 第一步:先写一个可运行的 MCP Server + +仓库已有可直接参考的测试服务: + +- `/Users/jojo/Desktop/agents/正在修复中/agents/scripts/mcp_calculator_server.py` + +这个示例使用 stdio,支持: + +- `initialize` +- `tools/list` +- `tools/call` + +如果你写新服务,至少要保证上述 3 个方法可用。 + +--- + +## 4. 第二步:在后台新增服务配置 + +在 `/admin/policy` 的 MCP 区域点击“新增 MCP 服务”,填写核心字段: + +- `id`:必须以字母开头,仅允许字母/数字/`_`/`-` +- `transport`:`stdio` 或 `streamable_http` +- `enabled`:是否启用 +- `timeout(s)`:调用超时 + +### 4.1 stdio 示例 + +- `transport`: `stdio` +- `command`: `python3`(或绝对路径) +- `args`: 每行一个参数(常见是服务脚本绝对路径) +- `cwd`: 可选 +- `env`: 可选 JSON + +示例: + +```json +{ + "id": "calc_stdio", + "name": "Calculator MCP", + "enabled": true, + "transport": "stdio", + "command": "python3", + "args": ["/abs/path/scripts/mcp_calculator_server.py"], + "timeout_seconds": 12 +} +``` + +### 4.2 streamable_http 示例 + +```json +{ + "id": "my_http_mcp", + "name": "My HTTP MCP", + "enabled": true, + "transport": "streamable_http", + "url": "http://127.0.0.1:8000/mcp", + "headers": { + "Authorization": "Bearer " + }, + "timeout_seconds": 25 +} +``` + +### 4.3 可选过滤 + +- `include_tools`:仅放行这些工具名 +- `exclude_tools`:屏蔽这些工具名 + +--- + +## 5. 第三步:同步工具缓存 + +保存后点击: + +- “同步工具”(单服务) +- 或“同步全部工具” + +同步成功后会写入缓存字段(页面可见): + +- `tools_cache_count` +- `tools_cache_updated_at` +- `last_error` + +--- + +## 6. 第四步:在对话里验证是否接入成功 + +让模型先调用 `list_mcp_servers`(可 `refresh=true`),确认: + +1. 目标服务在列表中 +2. `tools_cache_names` 有你新工具 +3. `tool_aliases` 已生成(通常是 `mcp__...`) + +再让模型调用对应别名工具即可。 + +--- + +## 7. 环境变量(可选) + +见 `.env.example`: + +- `MCP_TOOLS_ENABLED=1` +- `MCP_SERVERS_FILE=./data/mcp_servers.json` +- `MCP_PROTOCOL_VERSION=2025-06-18` +- `MCP_DEFAULT_TIMEOUT_SECONDS=25` + +(如果你只是个AI,那就去改这个文件) + +默认配置文件是运行态文件: + +- `/Users/jojo/Desktop/agents/正在修复中/agents/data/mcp_servers.json` + +--- + +## 8. Docker 模式下的 stdio MCP(重要) + +当前策略已收敛为:**MCP 仅在宿主机模式支持**。 + +当会话运行在 docker 模式时: + +1. 调用 `list_mcp_servers` 会直接返回:`当前为docker模式,MCP仅支持宿主机模式`。 +2. 调用任意 `mcp__...` 工具别名也会返回同样提示。 +3. 不再在 docker 模式执行 MCP 服务同步与调用。 + +如果需要使用 MCP,请先切换到宿主机模式后再进行配置/调用。 + +--- + +## 9. 常见问题排查 + +1. **服务保存失败** + - 检查 `id` 格式、transport 必填项(stdio 要 `command`,HTTP 要 `url`) + +2. **同步失败 / 无工具** + - 看 `last_error` + - 检查服务是否能独立启动 + - 检查 `include_tools/exclude_tools` + +3. **工具在页面有,但模型看不到** + - 确认服务 `enabled=true` + - 确认管理策略没禁用 MCP 分类 + - 重新“同步工具”后再试 + +4. **调用时报超时** + - 提高 `timeout_seconds` + - 优化 MCP Server 响应速度 + +--- + +## 10. 关键代码位置(便于二次开发) + +- MCP 配置:`/Users/jojo/Desktop/agents/正在修复中/agents/config/mcp.py` +- 服务注册表:`/Users/jojo/Desktop/agents/正在修复中/agents/modules/mcp_server_registry.py` +- MCP 客户端桥接:`/Users/jojo/Desktop/agents/正在修复中/agents/modules/mcp_client_manager.py` +- 管理 API:`/Users/jojo/Desktop/agents/正在修复中/agents/server/admin.py` +- 工具定义/执行: + - `/Users/jojo/Desktop/agents/正在修复中/agents/core/main_terminal_parts/tools_definition.py` + - `/Users/jojo/Desktop/agents/正在修复中/agents/core/main_terminal_parts/tools_execution.py` +- 管理端页面:`/Users/jojo/Desktop/agents/正在修复中/agents/static/src/admin/PolicyApp.vue` +- 测试示例服务:`/Users/jojo/Desktop/agents/正在修复中/agents/scripts/mcp_calculator_server.py` diff --git a/config/host_workspaces.json.example b/config/host_workspaces.json.example new file mode 100644 index 0000000..375998d --- /dev/null +++ b/config/host_workspaces.json.example @@ -0,0 +1,10 @@ +{ + "default_workspace_id": "default", + "workspaces": [ + { + "workspace_id": "default", + "label": "默认工作区", + "path": "/Users/jojo/Desktop/agents/正在修复中/agents/project" + } + ] +} \ No newline at end of file diff --git a/config/paths.py b/config/paths.py index 3e897ca..d9d3ae6 100644 --- a/config/paths.py +++ b/config/paths.py @@ -4,7 +4,9 @@ import os # 默认项目路径,可通过环境变量覆盖以指向宿主机任意目录 DEFAULT_PROJECT_PATH = os.environ.get("DEFAULT_PROJECT_PATH", "./project") -# 当终端运行在宿主机模式时,可显式指定工作目录;未设置时回退到 DEFAULT_PROJECT_PATH +# 宿主机模式工作区配置文件(JSON) +HOST_WORKSPACES_FILE = os.environ.get("HOST_WORKSPACES_FILE", "./config/host_workspaces.json") +# 兼容旧配置:若仍有模块读取 HOST_PROJECT_PATH,保留该键(实际宿主机路径选择改由 JSON 管理) HOST_PROJECT_PATH = os.environ.get("HOST_PROJECT_PATH", DEFAULT_PROJECT_PATH) PROMPTS_DIR = "./prompts" DATA_DIR = "./data" @@ -26,6 +28,7 @@ API_USAGE_FILE = f"{DATA_DIR}/api_usage.json" __all__ = [ "DEFAULT_PROJECT_PATH", + "HOST_WORKSPACES_FILE", "HOST_PROJECT_PATH", "PROMPTS_DIR", "DATA_DIR", diff --git a/core/main_terminal.py b/core/main_terminal.py index 3da3f95..0f67719 100644 --- a/core/main_terminal.py +++ b/core/main_terminal.py @@ -57,6 +57,7 @@ except ImportError: from core.tool_config import TOOL_CATEGORIES from utils.api_client import DeepSeekClient from utils.context_manager import ContextManager +from utils.host_workspace_debug import write_host_workspace_debug from config.model_profiles import get_model_profile from core.main_terminal_parts import ( @@ -289,6 +290,123 @@ class MainTerminal(MainTerminalCommandMixin, MainTerminalContextMixin, MainTermi except Exception: pass + def update_project_path(self, project_path: str): + """更新当前终端绑定的工作目录(宿主机工作区切换场景)。""" + try: + resolved_path = str(Path(project_path).expanduser().resolve()) + except Exception: + resolved_path = str(project_path) + + try: + current_resolved = str(Path(self.project_path).expanduser().resolve()) + except Exception: + current_resolved = str(self.project_path) + try: + current_context_resolved = str( + Path(getattr(getattr(self, "context_manager", None), "project_path", "")).expanduser().resolve() + ) + except Exception: + current_context_resolved = str( + getattr(getattr(self, "context_manager", None), "project_path", "") + ) + + write_host_workspace_debug( + "terminal.update_project_path.begin", + terminal_id=id(self), + current_project_path=current_resolved, + target_project_path=resolved_path, + ) + + if resolved_path == current_resolved and current_context_resolved == resolved_path: + write_host_workspace_debug( + "terminal.update_project_path.skip_same_path", + terminal_id=id(self), + project_path=resolved_path, + ) + return + + self.project_path = resolved_path + + # API 客户端 + if getattr(self, "api_client", None): + try: + self.api_client.project_path = resolved_path + except Exception: + pass + + # 上下文管理器(影响 file tree / AGENTS.md 注入 / prompt) + if getattr(self, "context_manager", None): + try: + new_path = Path(resolved_path).resolve() + self.context_manager.project_path = new_path + self.context_manager.initial_project_path = new_path + self.context_manager.project_snapshot = None + self.context_manager._host_runtime_cache = None + metadata = getattr(self.context_manager, "conversation_metadata", None) + if isinstance(metadata, dict): + metadata["project_path"] = str(new_path) + try: + workspace_root = Path( + getattr(self.context_manager.conversation_manager, "workspace_root", "") + ).resolve() + metadata["project_relative_path"] = new_path.relative_to(workspace_root).as_posix() + except Exception: + metadata["project_relative_path"] = None + # 清理旧工作区文件树快照,避免 prompt 继续展示切换前内容 + metadata.pop("project_file_tree", None) + metadata.pop("project_statistics", None) + metadata.pop("project_snapshot_at", None) + except Exception: + pass + + # 文件/终端操作器 + if getattr(self, "file_manager", None): + try: + self.file_manager.project_path = Path(resolved_path).resolve() + except Exception: + pass + if getattr(self, "terminal_ops", None): + try: + self.terminal_ops.project_path = Path(resolved_path).resolve() + except Exception: + pass + + # 关闭已有实时终端会话,确保后续 pwd/cwd 以新工作区启动 + if getattr(self, "terminal_manager", None): + try: + self.terminal_manager.project_path = Path(resolved_path).resolve() + self.terminal_manager.close_all() + except Exception: + pass + + # 其它依赖 project_path 的模块 + if getattr(self, "ocr_client", None): + try: + self.ocr_client.project_path = Path(resolved_path).resolve() + except Exception: + pass + if getattr(self, "sub_agent_manager", None): + try: + self.sub_agent_manager.project_path = Path(resolved_path).resolve() + except Exception: + pass + if getattr(self, "background_command_manager", None): + try: + self.background_command_manager.project_path = Path(resolved_path).resolve() + except Exception: + pass + + # 强制下次请求重新同步 skills(含 AGENTS.md 场景) + self._skills_synced_project_path = None + + write_host_workspace_debug( + "terminal.update_project_path.done", + terminal_id=id(self), + project_path=self.project_path, + context_project_path=str(getattr(getattr(self, "context_manager", None), "project_path", "")), + current_conversation_id=getattr(getattr(self, "context_manager", None), "current_conversation_id", None), + ) + def _ensure_conversation(self): """确保CLI模式下存在可用的对话ID""" if self.context_manager.current_conversation_id: diff --git a/core/main_terminal_parts/context.py b/core/main_terminal_parts/context.py index b80a2fe..a2d5688 100644 --- a/core/main_terminal_parts/context.py +++ b/core/main_terminal_parts/context.py @@ -73,6 +73,7 @@ from modules.container_monitor import collect_stats, inspect_state from core.tool_config import TOOL_CATEGORIES from utils.api_client import DeepSeekClient from utils.context_manager import ContextManager, AUTO_SHALLOW_PLACEHOLDER +from utils.host_workspace_debug import write_host_workspace_debug from utils.tool_result_formatter import format_tool_result_for_context from utils.logger import setup_logger from config.model_profiles import ( @@ -177,6 +178,19 @@ class MainTerminalContextMixin: def build_messages(self, context: Dict, user_input: str) -> List[Dict]: """构建消息列表(添加终端内容注入)""" + try: + file_tree_preview = (context.get("project_info", {}).get("file_tree") or "").splitlines() + write_host_workspace_debug( + "main_terminal.build_messages.context_snapshot", + terminal_id=id(self), + terminal_project_path=str(getattr(self, "project_path", "")), + context_project_path=str(getattr(getattr(self, "context_manager", None), "project_path", "")), + project_info_path=str(context.get("project_info", {}).get("path", "")), + file_tree_first_line=file_tree_preview[0] if file_tree_preview else "", + current_conversation_id=getattr(getattr(self, "context_manager", None), "current_conversation_id", None), + ) + except Exception: + pass # 加载系统提示(Qwen3.5 使用专用提示) current_model = getattr(self, "model_key", "kimi") prompt_name = "main_system_qwenvl" if (model_supports_image(current_model) or model_supports_video(current_model)) else "main_system" diff --git a/modules/host_workspace_manager.py b/modules/host_workspace_manager.py new file mode 100644 index 0000000..fb77587 --- /dev/null +++ b/modules/host_workspace_manager.py @@ -0,0 +1,239 @@ +"""宿主机模式工作区配置管理。""" + +from __future__ import annotations + +import json +import re +from pathlib import Path +from typing import Any, Dict, List, Optional, Tuple, Union + +from config import DEFAULT_PROJECT_PATH, HOST_WORKSPACES_FILE + +_WORKSPACE_ID_RE = re.compile(r"^[a-zA-Z0-9._-]{1,64}$") +_REPO_ROOT = Path(__file__).resolve().parents[1] + + +def _resolve_config_path(config_path: Optional[Union[str, Path]] = None) -> Path: + raw = str(config_path or HOST_WORKSPACES_FILE or "").strip() + if not raw: + raw = "./config/host_workspaces.json" + path = Path(raw).expanduser() + if not path.is_absolute(): + path = (_REPO_ROOT / path).resolve() + return path + + +def _default_workspace_path() -> Path: + p = Path(DEFAULT_PROJECT_PATH).expanduser() + if not p.is_absolute(): + p = (_REPO_ROOT / p).resolve() + return p + + +def _default_payload() -> Dict[str, Any]: + return { + "default_workspace_id": "default", + "workspaces": [ + { + "workspace_id": "default", + "label": "默认工作区", + "path": str(_default_workspace_path()), + } + ], + } + + +def _ensure_config_file(path: Path) -> Dict[str, Any]: + path.parent.mkdir(parents=True, exist_ok=True) + if path.exists(): + try: + data = json.loads(path.read_text(encoding="utf-8")) + if isinstance(data, dict): + return data + except Exception: + pass + data = _default_payload() + path.write_text(json.dumps(data, ensure_ascii=False, indent=2), encoding="utf-8") + return data + + +def _normalize_workspace_id(raw: Any, index: int) -> str: + value = str(raw or "").strip() + if _WORKSPACE_ID_RE.match(value): + return value + return f"workspace_{index + 1}" + + +def _slugify_workspace_id(raw: Any) -> str: + value = str(raw or "").strip().lower() + value = re.sub(r"[^a-z0-9._-]+", "-", value) + value = re.sub(r"-{2,}", "-", value).strip("-._") + if not value: + return "workspace" + if _WORKSPACE_ID_RE.match(value): + return value + return "workspace" + + +def _normalize_workspace_path(raw: Any) -> Path: + value = str(raw or "").strip() + if not value: + return _default_workspace_path() + path = Path(value).expanduser() + if not path.is_absolute(): + path = (_REPO_ROOT / path).resolve() + else: + path = path.resolve() + return path + + +def _normalize_entry(raw: Any, index: int) -> Optional[Dict[str, str]]: + if not isinstance(raw, dict): + return None + workspace_id = _normalize_workspace_id( + raw.get("workspace_id") or raw.get("id") or raw.get("name"), index + ) + label = str(raw.get("label") or raw.get("name") or workspace_id).strip() or workspace_id + path = _normalize_workspace_path(raw.get("path")) + path.mkdir(parents=True, exist_ok=True) + return { + "workspace_id": workspace_id, + "label": label, + "path": str(path), + } + + +def load_host_workspace_catalog( + config_path: Optional[Union[str, Path]] = None, +) -> Dict[str, Any]: + cfg_path = _resolve_config_path(config_path) + payload = _ensure_config_file(cfg_path) + raw_workspaces = payload.get("workspaces") + if not isinstance(raw_workspaces, list): + raw_workspaces = [] + + seen_ids = set() + workspaces: List[Dict[str, str]] = [] + for idx, item in enumerate(raw_workspaces): + normalized = _normalize_entry(item, idx) + if not normalized: + continue + ws_id = normalized["workspace_id"] + if ws_id in seen_ids: + continue + seen_ids.add(ws_id) + workspaces.append(normalized) + + if not workspaces: + fallback = _default_payload()["workspaces"][0] + path = _normalize_workspace_path(fallback.get("path")) + path.mkdir(parents=True, exist_ok=True) + workspaces = [ + { + "workspace_id": "default", + "label": "默认工作区", + "path": str(path), + } + ] + seen_ids.add("default") + + default_workspace_id = str(payload.get("default_workspace_id") or "").strip() + if default_workspace_id not in seen_ids: + default_workspace_id = workspaces[0]["workspace_id"] + + return { + "source_path": str(cfg_path), + "default_workspace_id": default_workspace_id, + "workspaces": workspaces, + } + + +def resolve_host_workspace( + selected_workspace_id: Optional[str] = None, + config_path: Optional[Union[str, Path]] = None, +) -> Tuple[Dict[str, Any], Dict[str, str]]: + catalog = load_host_workspace_catalog(config_path=config_path) + candidates = catalog.get("workspaces") or [] + selected_id = str(selected_workspace_id or "").strip() + current = None + if selected_id: + current = next((ws for ws in candidates if ws.get("workspace_id") == selected_id), None) + if not current: + default_id = catalog.get("default_workspace_id") + current = next((ws for ws in candidates if ws.get("workspace_id") == default_id), None) + if not current: + current = candidates[0] + return catalog, current + + +def create_host_workspace( + path: str, + label: Optional[str] = None, + *, + set_default: bool = False, + config_path: Optional[Union[str, Path]] = None, +) -> Dict[str, Any]: + cfg_path = _resolve_config_path(config_path) + payload = _ensure_config_file(cfg_path) + + raw_workspaces = payload.get("workspaces") + if not isinstance(raw_workspaces, list): + raw_workspaces = [] + + normalized_path = _normalize_workspace_path(path) + normalized_path.mkdir(parents=True, exist_ok=True) + target_path_str = str(normalized_path) + + for idx, item in enumerate(raw_workspaces): + existing = _normalize_entry(item, idx) + if not existing: + continue + if existing.get("path") == target_path_str: + # 已存在相同路径则直接返回 + return { + "created": False, + "workspace": existing, + "catalog": load_host_workspace_catalog(config_path=cfg_path), + } + + clean_label = str(label or "").strip() + base_id_seed = clean_label or normalized_path.name or "workspace" + base_id = _slugify_workspace_id(base_id_seed) + existing_ids = { + _normalize_workspace_id(item.get("workspace_id") or item.get("id"), i) + for i, item in enumerate(raw_workspaces) + if isinstance(item, dict) + } + workspace_id = base_id + suffix = 2 + while workspace_id in existing_ids: + workspace_id = f"{base_id}-{suffix}" + suffix += 1 + + workspace = { + "workspace_id": workspace_id, + "label": clean_label or workspace_id, + "path": target_path_str, + } + raw_workspaces.append(workspace) + payload["workspaces"] = raw_workspaces + + default_id = str(payload.get("default_workspace_id") or "").strip() + if set_default or not default_id: + payload["default_workspace_id"] = workspace_id + + cfg_path.parent.mkdir(parents=True, exist_ok=True) + cfg_path.write_text(json.dumps(payload, ensure_ascii=False, indent=2), encoding="utf-8") + + return { + "created": True, + "workspace": workspace, + "catalog": load_host_workspace_catalog(config_path=cfg_path), + } + + +__all__ = [ + "load_host_workspace_catalog", + "resolve_host_workspace", + "create_host_workspace", +] diff --git a/modules/skills_manager.py b/modules/skills_manager.py index ec022a3..891a3b0 100644 --- a/modules/skills_manager.py +++ b/modules/skills_manager.py @@ -7,6 +7,7 @@ from __future__ import annotations import re import shutil +import threading from pathlib import Path from typing import Dict, List, Optional, Sequence @@ -17,6 +18,18 @@ logger = setup_logger(__name__) SKILL_FILE_NAME = "SKILL.md" SKILL_ID_PATTERN = re.compile(r"^[A-Za-z0-9][A-Za-z0-9_-]*$") +_SYNC_LOCK_GUARD = threading.Lock() +_SYNC_LOCKS: Dict[str, threading.Lock] = {} + + +def _get_sync_lock(skills_dir: Path) -> threading.Lock: + key = str(skills_dir.resolve()) + with _SYNC_LOCK_GUARD: + lock = _SYNC_LOCKS.get(key) + if lock is None: + lock = threading.Lock() + _SYNC_LOCKS[key] = lock + return lock def ensure_agent_skills_dir(base_dir: Optional[str] = None) -> Path: @@ -190,22 +203,27 @@ def sync_workspace_skills( catalog = get_skills_catalog(base_dir) resolved = resolve_enabled_skills(enabled_skills, catalog) + lock = _get_sync_lock(skills_dir) try: - if skills_dir.exists(): - shutil.rmtree(skills_dir, ignore_errors=True) - skills_dir.mkdir(parents=True, exist_ok=True) - global_root = Path(base_dir or AGENT_SKILLS_DIR).expanduser().resolve() - for skill_id in resolved: - src = global_root / skill_id - if not src.exists() or not src.is_dir(): - continue - shutil.copytree(src, skills_dir / skill_id) - return { - "success": True, - "copied": list(resolved), - "available": [item.get("id") for item in catalog], - "target": str(skills_dir), - } + with lock: + if skills_dir.exists(): + shutil.rmtree(skills_dir, ignore_errors=True) + skills_dir.mkdir(parents=True, exist_ok=True) + global_root = Path(base_dir or AGENT_SKILLS_DIR).expanduser().resolve() + for skill_id in resolved: + src = global_root / skill_id + if not src.exists() or not src.is_dir(): + continue + dst = skills_dir / skill_id + if dst.exists() and not dst.is_dir(): + dst.unlink(missing_ok=True) + shutil.copytree(src, dst, dirs_exist_ok=True) + return { + "success": True, + "copied": list(resolved), + "available": [item.get("id") for item in catalog], + "target": str(skills_dir), + } except Exception as exc: logger.error("同步 skills 失败: %s", exc, exc_info=True) return {"success": False, "error": str(exc), "target": str(skills_dir)} diff --git a/server/auth.py b/server/auth.py index 09a1c26..31c37bc 100644 --- a/server/auth.py +++ b/server/auth.py @@ -6,10 +6,10 @@ from datetime import datetime from flask import Blueprint, request, jsonify, session, redirect, send_from_directory, abort, current_app, make_response from modules.personalization_manager import load_personalization_config +from modules.host_workspace_manager import resolve_host_workspace from modules.user_manager import UserWorkspace from config import ( TERMINAL_SANDBOX_MODE, - HOST_PROJECT_PATH, DATA_DIR, LOGS_DIR, UPLOAD_QUARANTINE_SUBDIR, @@ -210,7 +210,9 @@ def host_login(): if not state.container_manager.has_capacity("host"): return jsonify({"success": False, "error": "资源繁忙,请稍后再试"}), 503 - host_path = Path(HOST_PROJECT_PATH).expanduser().resolve() + _, host_workspace = resolve_host_workspace() + host_workspace_id = host_workspace.get("workspace_id") or "default" + host_path = Path(host_workspace.get("path") or "").expanduser().resolve() host_path.mkdir(parents=True, exist_ok=True) data_dir = Path(DATA_DIR).expanduser().resolve() data_dir.mkdir(parents=True, exist_ok=True) @@ -229,6 +231,8 @@ def host_login(): session['username'] = 'host' session['role'] = 'admin' session['host_mode'] = True + session['host_workspace_id'] = host_workspace_id + session['workspace_id'] = host_workspace_id default_thinking = current_app.config.get('DEFAULT_THINKING_MODE', False) session['thinking_mode'] = default_thinking session['run_mode'] = current_app.config.get('DEFAULT_RUN_MODE', "deep" if default_thinking else "fast") diff --git a/server/chat_flow_task_main.py b/server/chat_flow_task_main.py index b0c4f4b..5ffeff1 100644 --- a/server/chat_flow_task_main.py +++ b/server/chat_flow_task_main.py @@ -277,10 +277,13 @@ async def _dispatch_completion_user_notice( try: from .tasks import task_manager workspace_id = getattr(workspace, "workspace_id", None) or "default" + host_mode = bool(getattr(workspace, "username", None) == "host") session_data = { "username": username, "role": getattr(web_terminal, "user_role", "user"), "is_api_user": getattr(web_terminal, "user_role", "") == "api", + "host_mode": host_mode, + "host_workspace_id": workspace_id if host_mode else None, "workspace_id": workspace_id, "run_mode": getattr(web_terminal, "run_mode", None), "thinking_mode": getattr(web_terminal, "thinking_mode", None), @@ -563,10 +566,13 @@ async def poll_sub_agent_completion(*, web_terminal, workspace, conversation_id, }) from .tasks import task_manager workspace_id = getattr(workspace, "workspace_id", None) or "default" + host_mode = bool(getattr(workspace, "username", None) == "host") session_data = { "username": username, "role": getattr(web_terminal, "user_role", "user"), "is_api_user": getattr(web_terminal, "user_role", "") == "api", + "host_mode": host_mode, + "host_workspace_id": workspace_id if host_mode else None, "workspace_id": workspace_id, "run_mode": getattr(web_terminal, "run_mode", None), "thinking_mode": getattr(web_terminal, "thinking_mode", None), diff --git a/server/context.py b/server/context.py index 61f9147..5c28262 100644 --- a/server/context.py +++ b/server/context.py @@ -9,11 +9,12 @@ from modules.gui_file_manager import GuiFileManager from modules.upload_security import UploadQuarantineManager, UploadSecurityError from modules.personalization_manager import load_personalization_config from modules.skills_manager import sync_workspace_skills +from modules.host_workspace_manager import resolve_host_workspace +from utils.host_workspace_debug import write_host_workspace_debug import json from pathlib import Path from modules.usage_tracker import UsageTracker from config import ( - HOST_PROJECT_PATH, DATA_DIR, LOGS_DIR, TERMINAL_SANDBOX_MODE, @@ -81,11 +82,20 @@ def get_user_resources(username: Optional[str] = None, workspace_id: Optional[st if not username: return None, None - # 宿主机免登录模式:使用 HOST_PROJECT_PATH 直接进入,不创建 /users//project + # 宿主机免登录模式:根据 host_workspaces.json 选择路径,不创建 /users//project host_mode_session = bool(session.get("host_mode")) if has_request_context() else False sandbox_is_host = (TERMINAL_SANDBOX_MODE or "host").lower() == "host" if host_mode_session and sandbox_is_host: - project_path = Path(HOST_PROJECT_PATH).expanduser().resolve() + selected_workspace_id = session.get("host_workspace_id") if has_request_context() else None + _, host_workspace = resolve_host_workspace(selected_workspace_id) + project_path = Path(host_workspace.get("path") or "").expanduser().resolve() + write_host_workspace_debug( + "context.get_user_resources.host.selected_workspace", + selected_workspace_id=selected_workspace_id, + resolved_workspace_id=host_workspace.get("workspace_id"), + project_path=str(project_path), + username=username, + ) project_path.mkdir(parents=True, exist_ok=True) data_dir = Path(DATA_DIR).expanduser().resolve() data_dir.mkdir(parents=True, exist_ok=True) @@ -110,12 +120,65 @@ def get_user_resources(username: Optional[str] = None, workspace_id: Optional[st quarantine_dir=quarantine_root, ) if not hasattr(workspace, "workspace_id"): - workspace.workspace_id = "host" + workspace.workspace_id = host_workspace.get("workspace_id") or "default" term_key = "host" container_handle = state.container_manager.ensure_container("host", str(project_path), container_key=term_key, preferred_mode="host") usage_tracker = None # 宿主机模式不计配额 terminal = state.user_terminals.get(term_key) + target_project_path = str(project_path) + if terminal: + should_recreate_terminal = False + try: + current_project_path = str(Path(getattr(terminal, "project_path", "")).expanduser().resolve()) + except Exception: + current_project_path = str(getattr(terminal, "project_path", "")) + try: + current_context_project_path = str( + Path(getattr(getattr(terminal, "context_manager", None), "project_path", "")).expanduser().resolve() + ) + except Exception: + current_context_project_path = str( + getattr(getattr(terminal, "context_manager", None), "project_path", "") + ) + if current_project_path != target_project_path or current_context_project_path != target_project_path: + write_host_workspace_debug( + "context.get_user_resources.host.path_mismatch", + terminal_id=id(terminal), + current_project_path=current_project_path, + current_context_project_path=current_context_project_path, + target_project_path=target_project_path, + ) + try: + if hasattr(terminal, "update_project_path"): + terminal.update_project_path(target_project_path) + else: + should_recreate_terminal = True + except Exception as exc: + debug_log(f"[HostWorkspace] update_project_path 失败,回退重建终端: {exc}") + should_recreate_terminal = True + + if not should_recreate_terminal: + try: + updated_project_path = str(Path(getattr(terminal, "project_path", "")).expanduser().resolve()) + except Exception: + updated_project_path = str(getattr(terminal, "project_path", "")) + if updated_project_path != target_project_path: + should_recreate_terminal = True + + if should_recreate_terminal: + write_host_workspace_debug( + "context.get_user_resources.host.recreate_terminal", + terminal_id=id(terminal), + target_project_path=target_project_path, + ) + try: + if getattr(terminal, "terminal_manager", None): + terminal.terminal_manager.close_all() + except Exception: + pass + state.user_terminals.pop(term_key, None) + terminal = None if not terminal: run_mode = session.get('run_mode') if has_request_context() else None thinking_mode_flag = session.get('thinking_mode') if has_request_context() else None @@ -142,6 +205,7 @@ def get_user_resources(username: Optional[str] = None, workspace_id: Optional[st session['run_mode'] = terminal.run_mode session['thinking_mode'] = terminal.thinking_mode session['workspace_id'] = getattr(workspace, "workspace_id", None) + session['host_workspace_id'] = getattr(workspace, "workspace_id", None) else: terminal.update_container_session(container_handle) attach_user_broadcast(terminal, "host") @@ -149,6 +213,7 @@ def get_user_resources(username: Optional[str] = None, workspace_id: Optional[st terminal.user_role = "admin" if has_request_context(): session['workspace_id'] = getattr(workspace, "workspace_id", None) + session['host_workspace_id'] = getattr(workspace, "workspace_id", None) # 宿主机模式同样需要应用管理员策略(否则前端工具菜单会退化成静态基础分类) try: @@ -191,6 +256,15 @@ def get_user_resources(username: Optional[str] = None, workspace_id: Optional[st debug_log(f"[admin_policy][host_mode] 应用失败: {exc}") _ensure_workspace_skills_synced(terminal, workspace) + write_host_workspace_debug( + "context.get_user_resources.host.return", + terminal_id=id(terminal), + terminal_project_path=str(getattr(terminal, "project_path", "")), + context_project_path=str(getattr(getattr(terminal, "context_manager", None), "project_path", "")), + workspace_project_path=str(getattr(workspace, "project_path", "")), + workspace_id=getattr(workspace, "workspace_id", None), + current_conversation_id=getattr(getattr(terminal, "context_manager", None), "current_conversation_id", None), + ) return terminal, workspace is_api_user = bool(session.get("is_api_user")) if has_request_context() else False @@ -449,6 +523,16 @@ def ensure_conversation_loaded( load_result = terminal.load_conversation(conversation_id) if not load_result.get("success"): raise RuntimeError(load_result.get("message", "对话加载失败")) + write_host_workspace_debug( + "context.ensure_conversation_loaded.after_load", + terminal_id=id(terminal), + conversation_id=conversation_id, + terminal_project_path=str(getattr(terminal, "project_path", "")), + context_project_path=str(getattr(getattr(terminal, "context_manager", None), "project_path", "")), + metadata_project_path=( + getattr(getattr(terminal, "context_manager", None), "conversation_metadata", {}) or {} + ).get("project_path"), + ) try: conv_data = terminal.context_manager.conversation_manager.load_conversation(conversation_id) or {} meta = conv_data.get("metadata", {}) or {} @@ -469,6 +553,25 @@ def ensure_conversation_loaded( session['model_key'] = getattr(terminal, "model_key", None) except Exception: pass + if workspace is not None: + try: + workspace_project_path = str(Path(workspace.project_path).expanduser().resolve()) + terminal.update_project_path(workspace_project_path) + write_host_workspace_debug( + "context.ensure_conversation_loaded.reapply_workspace_path", + terminal_id=id(terminal), + conversation_id=conversation_id, + workspace_project_path=workspace_project_path, + terminal_project_path=str(getattr(terminal, "project_path", "")), + context_project_path=str(getattr(getattr(terminal, "context_manager", None), "project_path", "")), + ) + except Exception as exc: + write_host_workspace_debug( + "context.ensure_conversation_loaded.reapply_workspace_path_failed", + terminal_id=id(terminal), + conversation_id=conversation_id, + error=str(exc), + ) # 应用对话级自定义 prompt / personalization(仅 API)。 # 注意:ensure_conversation_loaded 在 WebSocket/后台任务等多处复用,有些调用点拿不到 workspace; # 因此这里允许 workspace 为空(仅跳过 override,不影响正常对话加载)。 diff --git a/server/status.py b/server/status.py index 9a91ab4..05a213a 100644 --- a/server/status.py +++ b/server/status.py @@ -2,10 +2,10 @@ from __future__ import annotations import time import re from pathlib import Path -from flask import Blueprint, jsonify, request, send_file +from flask import Blueprint, jsonify, request, send_file, session from .auth_helpers import api_login_required, resolve_admin_policy -from .context import with_terminal +from .context import with_terminal, attach_user_broadcast from .state import ( PROJECT_STORAGE_CACHE, PROJECT_STORAGE_CACHE_TTL_SECONDS, @@ -13,7 +13,14 @@ from .state import ( container_manager, user_manager, ) -from config import AGENT_VERSION +from config import AGENT_VERSION, TERMINAL_SANDBOX_MODE +from modules.host_workspace_manager import ( + create_host_workspace, + load_host_workspace_catalog, + resolve_host_workspace, +) +from utils.host_workspace_debug import write_host_workspace_debug +from . import state status_bp = Blueprint('status', __name__) @@ -54,6 +61,10 @@ def _load_app_changelog(project_root: Path) -> str: return "" +def _is_host_mode_request() -> bool: + return bool(session.get("host_mode")) and (TERMINAL_SANDBOX_MODE or "").lower() == "host" + + @status_bp.route('/api/status') @api_login_required @with_terminal @@ -101,6 +112,249 @@ def get_status(terminal, workspace, username): return jsonify(status) +@status_bp.route('/api/host/workspaces') +@api_login_required +def list_host_workspaces(): + if not _is_host_mode_request(): + return jsonify({"success": False, "error": "仅宿主机模式可用"}), 403 + + catalog, current = resolve_host_workspace(session.get("host_workspace_id")) + current_id = current.get("workspace_id") + session['host_workspace_id'] = current_id + session['workspace_id'] = current_id + + workspaces = [] + for item in catalog.get("workspaces") or []: + workspaces.append({ + "workspace_id": item.get("workspace_id"), + "label": item.get("label"), + "path": item.get("path"), + "is_current": item.get("workspace_id") == current_id, + }) + + return jsonify({ + "success": True, + "data": { + "source_path": catalog.get("source_path"), + "default_workspace_id": catalog.get("default_workspace_id"), + "current_workspace_id": current_id, + "workspaces": workspaces, + } + }) + + +@status_bp.route('/api/host/workspaces/select', methods=['GET', 'POST']) +@api_login_required +def select_host_workspace(): + if not _is_host_mode_request(): + return jsonify({"success": False, "error": "仅宿主机模式可用"}), 403 + + payload = request.get_json(silent=True) if request.method != "GET" else None + workspace_id = ( + request.args.get("workspace_id") + or (payload or {}).get("workspace_id") + or "" + ).strip() + if not workspace_id: + return jsonify({"success": False, "error": "缺少 workspace_id"}), 400 + write_host_workspace_debug( + "status.select_host_workspace.request", + workspace_id=workspace_id, + current_session_workspace_id=session.get("host_workspace_id"), + ) + + catalog = load_host_workspace_catalog() + current_id = (session.get("host_workspace_id") or "").strip() + target = next( + (item for item in (catalog.get("workspaces") or []) if item.get("workspace_id") == workspace_id), + None, + ) + if not target: + return jsonify({"success": False, "error": "workspace_id 不存在"}), 404 + target_path = str(Path(target.get("path") or "").expanduser().resolve()) + write_host_workspace_debug( + "status.select_host_workspace.target", + workspace_id=workspace_id, + target_path=target_path, + current_workspace_id=current_id, + ) + + if current_id == workspace_id: + session['workspace_id'] = workspace_id + try: + container_handle = state.container_manager.ensure_container( + "host", + target_path, + container_key="host", + preferred_mode="host", + ) + host_terminal = state.user_terminals.get("host") + if host_terminal: + host_terminal.update_project_path(target_path) + host_terminal.update_container_session(container_handle) + attach_user_broadcast(host_terminal, "host") + host_terminal.username = "host" + host_terminal.user_role = "admin" + write_host_workspace_debug( + "status.select_host_workspace.same_id_reapply", + terminal_id=id(host_terminal), + workspace_id=workspace_id, + project_path=str(getattr(host_terminal, "project_path", "")), + context_project_path=str(getattr(getattr(host_terminal, "context_manager", None), "project_path", "")), + ) + except Exception: + pass + return jsonify({ + "success": True, + "data": { + "current_workspace_id": workspace_id, + "project_path": target_path, + "default_workspace_id": catalog.get("default_workspace_id"), + "reloaded": False, + } + }) + + try: + from .tasks import task_manager + + running = [ + rec for rec in task_manager.list_tasks("host") + if rec.status in {"pending", "running", "cancel_requested"} + ] + if running: + return jsonify({ + "success": False, + "error": "存在运行中的任务,请先停止后再切换工作区", + }), 409 + except Exception: + pass + + previous_workspace_id = session.get("workspace_id") + session['host_workspace_id'] = workspace_id + session['workspace_id'] = workspace_id + + try: + container_handle = state.container_manager.ensure_container( + "host", + target_path, + container_key="host", + preferred_mode="host", + ) + except RuntimeError as exc: + if current_id: + session['host_workspace_id'] = current_id + if previous_workspace_id is not None: + session['workspace_id'] = previous_workspace_id + elif current_id: + session['workspace_id'] = current_id + write_host_workspace_debug( + "status.select_host_workspace.ensure_container_failed", + workspace_id=workspace_id, + target_path=target_path, + error=str(exc), + ) + return jsonify({"success": False, "error": str(exc)}), 503 + + host_terminal = state.user_terminals.get("host") + if host_terminal: + try: + host_terminal.update_project_path(target_path) + host_terminal.update_container_session(container_handle) + attach_user_broadcast(host_terminal, "host") + host_terminal.username = "host" + host_terminal.user_role = "admin" + write_host_workspace_debug( + "status.select_host_workspace.updated_terminal", + terminal_id=id(host_terminal), + workspace_id=workspace_id, + project_path=str(getattr(host_terminal, "project_path", "")), + context_project_path=str(getattr(getattr(host_terminal, "context_manager", None), "project_path", "")), + current_conversation_id=getattr(getattr(host_terminal, "context_manager", None), "current_conversation_id", None), + ) + except Exception: + try: + if getattr(host_terminal, "terminal_manager", None): + host_terminal.terminal_manager.close_all() + except Exception: + pass + state.user_terminals.pop("host", None) + write_host_workspace_debug( + "status.select_host_workspace.drop_terminal_after_error", + workspace_id=workspace_id, + ) + + write_host_workspace_debug( + "status.select_host_workspace.success", + workspace_id=workspace_id, + target_path=target_path, + ) + return jsonify({ + "success": True, + "data": { + "current_workspace_id": workspace_id, + "project_path": target_path, + "default_workspace_id": catalog.get("default_workspace_id"), + "reloaded": True, + } + }) + + +@status_bp.route('/api/host/workspaces/create', methods=['GET', 'POST']) +@api_login_required +def create_host_workspace_api(): + if not _is_host_mode_request(): + return jsonify({"success": False, "error": "仅宿主机模式可用"}), 403 + + payload = request.get_json(silent=True) if request.method != "GET" else None + workspace_path = ( + request.args.get("path") + or (payload or {}).get("path") + or "" + ).strip() + label = ( + request.args.get("label") + or (payload or {}).get("label") + or "" + ).strip() + set_default_raw = ( + request.args.get("set_default") + or (payload or {}).get("set_default") + or "" + ) + set_default = str(set_default_raw).lower() in {"1", "true", "yes", "on"} + + if not workspace_path: + return jsonify({"success": False, "error": "缺少 path"}), 400 + + try: + result = create_host_workspace(workspace_path, label=label, set_default=set_default) + except Exception as exc: + return jsonify({"success": False, "error": str(exc)}), 500 + + catalog = result.get("catalog") or {} + workspaces = [] + current_id = (session.get("host_workspace_id") or "").strip() + for item in catalog.get("workspaces") or []: + workspaces.append({ + "workspace_id": item.get("workspace_id"), + "label": item.get("label"), + "path": item.get("path"), + "is_current": item.get("workspace_id") == current_id, + }) + + return jsonify({ + "success": True, + "data": { + "created": bool(result.get("created")), + "workspace": result.get("workspace") or {}, + "source_path": catalog.get("source_path"), + "default_workspace_id": catalog.get("default_workspace_id"), + "current_workspace_id": current_id, + "workspaces": workspaces, + } + }) + + @status_bp.route('/api/container-status') @api_login_required @with_terminal diff --git a/server/tasks.py b/server/tasks.py index 4277413..ffdb6c6 100644 --- a/server/tasks.py +++ b/server/tasks.py @@ -15,6 +15,7 @@ from .context import get_user_resources, ensure_conversation_loaded from .chat_flow import run_chat_task_sync from .state import stop_flags from .utils_common import debug_log +from utils.host_workspace_debug import write_host_workspace_debug class TaskRecord: @@ -124,7 +125,16 @@ class TaskManager: record = TaskRecord(task_id, username, workspace_id, message, conversation_id, model_key, thinking_mode, run_mode, max_iterations) # 记录当前 session 快照,便于后台线程内使用 if session_data is not None: - record.session_data = dict(session_data) + snapshot = dict(session_data) + snapshot.setdefault("workspace_id", workspace_id) + try: + snapshot.setdefault("host_mode", session.get("host_mode")) + if snapshot.get("host_mode"): + snapshot.setdefault("host_workspace_id", session.get("host_workspace_id") or workspace_id) + except Exception: + if snapshot.get("host_mode"): + snapshot.setdefault("host_workspace_id", workspace_id) + record.session_data = snapshot else: try: record.session_data = { @@ -132,6 +142,7 @@ class TaskManager: "role": session.get("role"), "is_api_user": session.get("is_api_user"), "host_mode": session.get("host_mode"), + "host_workspace_id": session.get("host_workspace_id") or workspace_id, "workspace_id": workspace_id, "run_mode": session.get("run_mode"), "thinking_mode": session.get("thinking_mode"), @@ -217,6 +228,15 @@ class TaskManager: for k, v in (rec.session_data or {}).items(): if v is not None: session[k] = v + if session.get("host_mode"): + session["workspace_id"] = workspace_id + session["host_workspace_id"] = session.get("host_workspace_id") or workspace_id + write_host_workspace_debug( + "tasks.run_chat_task.apply_host_session", + task_id=rec.task_id, + workspace_id=workspace_id, + host_workspace_id=session.get("host_workspace_id"), + ) except Exception: pass terminal, workspace = get_user_resources(username, workspace_id=workspace_id) diff --git a/static/src/App.vue b/static/src/App.vue index 8717b35..aa8e6ca 100644 --- a/static/src/App.vue +++ b/static/src/App.vue @@ -93,10 +93,17 @@ :panel-menu-open="panelMenuOpen" :panel-mode="panelMode" :file-manager-disabled="policyUiBlocks.block_file_manager" + :host-workspace-enabled="versioningHostMode" + :host-workspaces="hostWorkspaces" + :host-workspace-id="currentHostWorkspaceId" + :host-workspace-default-id="defaultHostWorkspaceId" + :host-workspace-switching="hostWorkspaceSwitching" @toggle-panel-menu="togglePanelMenu" @select-panel="selectPanelMode" @open-file-manager="openGuiFileManager" @toggle-thinking-mode="handleQuickModeToggle" + @switch-host-workspace="handleHostWorkspaceSwitch" + @create-host-workspace="handleCreateHostWorkspace" />
+ + +
diff --git a/static/src/app/components.ts b/static/src/app/components.ts index c0f0f3c..691b06a 100644 --- a/static/src/app/components.ts +++ b/static/src/app/components.ts @@ -24,6 +24,9 @@ const BackgroundCommandDialog = defineAsyncComponent( const VersioningDialog = defineAsyncComponent( () => import('../components/overlay/VersioningDialog.vue') ); +const HostWorkspaceCreateDialog = defineAsyncComponent( + () => import('../components/overlay/HostWorkspaceCreateDialog.vue') +); const TutorialOverlay = defineAsyncComponent( () => import('../components/overlay/TutorialOverlay.vue') ); @@ -46,6 +49,7 @@ export const appComponents = { SubAgentActivityDialog, BackgroundCommandDialog, VersioningDialog, + HostWorkspaceCreateDialog, TutorialOverlay, NewUserTutorialPrompt }; diff --git a/static/src/app/methods/ui.ts b/static/src/app/methods/ui.ts index 57807c8..0fcb8ca 100644 --- a/static/src/app/methods/ui.ts +++ b/static/src/app/methods/ui.ts @@ -488,6 +488,159 @@ export const uiMethods = { await this.updateTutorialPromptStatus(true); }, + async fetchHostWorkspaces() { + if (!this.versioningHostMode) { + this.hostWorkspaces = []; + this.currentHostWorkspaceId = ''; + this.defaultHostWorkspaceId = ''; + return; + } + try { + const resp = await fetch('/api/host/workspaces'); + const payload = await resp.json().catch(() => ({})); + if (!resp.ok || !payload?.success) { + throw new Error(payload?.error || '获取宿主机工作区列表失败'); + } + const data = payload.data || {}; + const items = Array.isArray(data.workspaces) ? data.workspaces : []; + this.hostWorkspaces = items.map((item: any) => ({ + workspace_id: String(item.workspace_id || ''), + label: String(item.label || item.workspace_id || '未命名工作区'), + path: String(item.path || ''), + is_current: !!item.is_current + })); + this.currentHostWorkspaceId = String(data.current_workspace_id || ''); + this.defaultHostWorkspaceId = String(data.default_workspace_id || ''); + } catch (error) { + console.warn('加载宿主机工作区失败:', error); + this.hostWorkspaces = []; + this.currentHostWorkspaceId = ''; + this.defaultHostWorkspaceId = ''; + } + }, + + async handleHostWorkspaceSwitch(workspaceId) { + const targetId = String(workspaceId || '').trim(); + if (!targetId || this.hostWorkspaceSwitching) { + return; + } + if (!this.versioningHostMode) { + return; + } + if (targetId === this.currentHostWorkspaceId) { + return; + } + + this.hostWorkspaceSwitching = true; + try { + const query = encodeURIComponent(targetId); + const resp = await fetch(`/api/host/workspaces/select?workspace_id=${query}`); + const payload = await resp.json().catch(() => ({})); + if (!resp.ok || !payload?.success) { + throw new Error(payload?.error || '切换宿主机工作区失败'); + } + const data = payload.data || {}; + this.currentHostWorkspaceId = String(data.current_workspace_id || targetId); + if (data.project_path) { + this.projectPath = String(data.project_path); + } + if (data.default_workspace_id) { + this.defaultHostWorkspaceId = String(data.default_workspace_id); + } + this.uiPushToast({ + title: '工作区已切换', + message: '正在刷新页面…', + type: 'success' + }); + window.setTimeout(() => { + window.location.reload(); + }, 260); + } catch (error) { + const message = error instanceof Error ? error.message : String(error || '切换失败'); + this.uiPushToast({ + title: '切换工作区失败', + message, + type: 'error' + }); + } finally { + this.hostWorkspaceSwitching = false; + } + }, + + async handleCreateHostWorkspace() { + if (!this.versioningHostMode) { + return; + } + if (this.hostWorkspaceSwitching || this.hostWorkspaceCreateSubmitting) { + return; + } + this.hostWorkspaceCreatePath = ''; + this.hostWorkspaceCreateLabel = ''; + this.hostWorkspaceCreateError = ''; + this.hostWorkspaceCreateDialogOpen = true; + }, + + closeHostWorkspaceCreateDialog() { + if (this.hostWorkspaceCreateSubmitting) { + return; + } + this.hostWorkspaceCreateDialogOpen = false; + this.hostWorkspaceCreateError = ''; + }, + + async submitHostWorkspaceCreate() { + if (!this.versioningHostMode) { + return; + } + if (this.hostWorkspaceSwitching || this.hostWorkspaceCreateSubmitting) { + return; + } + const workspacePath = String(this.hostWorkspaceCreatePath || '').trim(); + if (!workspacePath) { + this.hostWorkspaceCreateError = '路径不能为空'; + return; + } + const label = String(this.hostWorkspaceCreateLabel || '').trim(); + + this.hostWorkspaceCreateSubmitting = true; + this.hostWorkspaceCreateError = ''; + try { + const resp = await fetch('/api/host/workspaces/create', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + credentials: 'same-origin', + body: JSON.stringify({ + path: workspacePath, + label + }) + }); + const payload = await resp.json().catch(() => ({})); + if (!resp.ok || !payload?.success) { + throw new Error(payload?.error || '新建宿主机工作区失败'); + } + await this.fetchHostWorkspaces(); + const createdLabel = payload?.data?.workspace?.label || label || workspacePath; + this.hostWorkspaceCreateDialogOpen = false; + this.hostWorkspaceCreatePath = ''; + this.hostWorkspaceCreateLabel = ''; + this.uiPushToast({ + title: '工作区已创建', + message: createdLabel, + type: 'success' + }); + } catch (error) { + const message = error instanceof Error ? error.message : String(error || '新建失败'); + this.hostWorkspaceCreateError = message; + this.uiPushToast({ + title: '新建工作区失败', + message, + type: 'error' + }); + } finally { + this.hostWorkspaceCreateSubmitting = false; + } + }, + fetchTodoList() { return this.fileFetchTodoList(); }, @@ -1842,20 +1995,42 @@ export const uiMethods = { }, startConnectionHeartbeat() { - if (this.connectionHeartbeatTimer) { + if (this.connectionHeartbeatActive) { return; } + this.connectionHeartbeatActive = true; this.connectionHeartbeatFailCount = 0; - // 先做一次即时探活,再进入定时轮询 - this.checkConnectionHealth(); - this.connectionHeartbeatTimer = window.setInterval(() => { - this.checkConnectionHealth(); - }, this.connectionHeartbeatIntervalMs || 8000); + const runHeartbeat = async () => { + if (!this.connectionHeartbeatActive) { + return; + } + await this.checkConnectionHealth(); + if (!this.connectionHeartbeatActive) { + return; + } + const connectedInterval = + typeof this.connectionHeartbeatIntervalMs === 'number' && this.connectionHeartbeatIntervalMs > 0 + ? this.connectionHeartbeatIntervalMs + : 8000; + const disconnectedInterval = + typeof this.connectionHeartbeatDisconnectedIntervalMs === 'number' && + this.connectionHeartbeatDisconnectedIntervalMs > 0 + ? this.connectionHeartbeatDisconnectedIntervalMs + : 1000; + const nextInterval = this.isConnected ? connectedInterval : disconnectedInterval; + this.connectionHeartbeatTimer = window.setTimeout(() => { + runHeartbeat(); + }, nextInterval); + }; + + // 先做一次即时探活,再根据连接状态动态设置下次轮询间隔 + runHeartbeat(); }, stopConnectionHeartbeat() { + this.connectionHeartbeatActive = false; if (this.connectionHeartbeatTimer) { - clearInterval(this.connectionHeartbeatTimer); + clearTimeout(this.connectionHeartbeatTimer); this.connectionHeartbeatTimer = null; } }, @@ -1942,7 +2117,16 @@ export const uiMethods = { this.versioningHostMode = !!isHostMode; if (isHostMode) { this.fileMarkTreeUnavailable('宿主机模式下文件树不可用'); + await this.fetchHostWorkspaces(); } else { + this.hostWorkspaces = []; + this.currentHostWorkspaceId = ''; + this.defaultHostWorkspaceId = ''; + this.hostWorkspaceCreateDialogOpen = false; + this.hostWorkspaceCreatePath = ''; + this.hostWorkspaceCreateLabel = ''; + this.hostWorkspaceCreateError = ''; + this.hostWorkspaceCreateSubmitting = false; treePromise = this.fileFetchTree(); } diff --git a/static/src/app/state.ts b/static/src/app/state.ts index f5961bb..a1cdc89 100644 --- a/static/src/app/state.ts +++ b/static/src/app/state.ts @@ -82,6 +82,15 @@ export function dataState() { permissionMenuOpen: false, currentPermissionMode: 'unrestricted', versioningHostMode: false, + hostWorkspaces: [], + currentHostWorkspaceId: '', + defaultHostWorkspaceId: '', + hostWorkspaceSwitching: false, + hostWorkspaceCreateDialogOpen: false, + hostWorkspaceCreatePath: '', + hostWorkspaceCreateLabel: '', + hostWorkspaceCreateSubmitting: false, + hostWorkspaceCreateError: '', versioningEnabled: false, newConversationVersioningEnabled: false, versioningMode: 'overwrite', @@ -123,8 +132,10 @@ export function dataState() { conversationListRequestSeq: 0, conversationListRefreshToken: 0, connectionHeartbeatTimer: null, + connectionHeartbeatActive: false, connectionHeartbeatFailCount: 0, connectionHeartbeatIntervalMs: 8000, + connectionHeartbeatDisconnectedIntervalMs: 1000, // 工具控制菜单 icons: ICONS, diff --git a/static/src/components/overlay/HostWorkspaceCreateDialog.vue b/static/src/components/overlay/HostWorkspaceCreateDialog.vue new file mode 100644 index 0000000..b624d72 --- /dev/null +++ b/static/src/components/overlay/HostWorkspaceCreateDialog.vue @@ -0,0 +1,211 @@ + + + + + diff --git a/static/src/components/panels/LeftPanel.vue b/static/src/components/panels/LeftPanel.vue index cfbed8b..07d9c23 100644 --- a/static/src/components/panels/LeftPanel.vue +++ b/static/src/components/panels/LeftPanel.vue @@ -193,7 +193,54 @@
-
{{ fileTreeMessage }}
+