feat: add host workspace manager, mcp-tool-config skill, and UI enhancements

This commit is contained in:
JOJO 2026-04-28 13:04:51 +08:00
parent cfffc8e4ef
commit b151f8ff71
26 changed files with 1901 additions and 43 deletions

View File

@ -26,6 +26,8 @@ WEB_SECRET_KEY=replace-with-random-hex
# === 终端容器沙箱 ============================================================= # === 终端容器沙箱 =============================================================
# 模式docker / host # 模式docker / host
TERMINAL_SANDBOX_MODE=docker TERMINAL_SANDBOX_MODE=docker
# 宿主机模式工作区配置JSON 文件)
HOST_WORKSPACES_FILE=./config/host_workspaces.json
# 容器镜像及挂载路径 # 容器镜像及挂载路径
TERMINAL_SANDBOX_IMAGE=python:3.11-slim TERMINAL_SANDBOX_IMAGE=python:3.11-slim
TERMINAL_SANDBOX_MOUNT_PATH=/workspace TERMINAL_SANDBOX_MOUNT_PATH=/workspace

3
.gitignore vendored
View File

@ -38,3 +38,6 @@ ax_probe_chrome.py
# Ignore docs and runtime pid # Ignore docs and runtime pid
webapp.pid webapp.pid
doc/ doc/
# Host workspace config (local, use .example)
config/host_workspaces.json

View File

@ -124,7 +124,19 @@
配置: 配置:
```bash ```bash
TERMINAL_SANDBOX_MODE=host 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) #### Docker 容器模式 (Docker Mode)
@ -482,7 +494,7 @@ static/src/
**宿主机模式**: **宿主机模式**:
- `TERMINAL_SANDBOX_MODE=host`: 启用宿主机模式 - `TERMINAL_SANDBOX_MODE=host`: 启用宿主机模式
- `HOST_PROJECT_PATH`: 指定项目路径(可选) - `HOST_WORKSPACES_FILE`: 宿主机工作区 JSON 配置文件路径
- `LINUX_SAFETY`: Linux 安全保护开关 - `LINUX_SAFETY`: Linux 安全保护开关
**Docker 模式**: **Docker 模式**:

View File

@ -0,0 +1,7 @@
---
name: mcp-tool-config
description: 指导模型在宿主机模式下如何给自己配置新的mcp工具
---
Just Read This File
skills/mcp-tool-config/mcp-tool-config-guide.md

View File

@ -0,0 +1,202 @@
# 本项目新增 MCP 工具配置指南
> 最后更新2026-04-26
> 适用目录:`/Users/jojo/Desktop/agents/正在修复中/agents`
本文面向后续维护者,说明如何在当前项目里新增并接入一个 MCP 工具MCP Server
---
## 1. 先了解本项目里的 MCP 架构
当前实现是「**本项目作为 MCP 客户端桥接层**」:
1. 在后台配置一个或多个 MCP Serverstdio / streamable_http
2. 系统通过 `tools/list` 拉取远端工具
3. 本地自动生成工具别名:`mcp__<server_id>__<tool_name>`
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 <token>"
},
"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`

View File

@ -0,0 +1,10 @@
{
"default_workspace_id": "default",
"workspaces": [
{
"workspace_id": "default",
"label": "默认工作区",
"path": "/Users/jojo/Desktop/agents/正在修复中/agents/project"
}
]
}

View File

@ -4,7 +4,9 @@ import os
# 默认项目路径,可通过环境变量覆盖以指向宿主机任意目录 # 默认项目路径,可通过环境变量覆盖以指向宿主机任意目录
DEFAULT_PROJECT_PATH = os.environ.get("DEFAULT_PROJECT_PATH", "./project") 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) HOST_PROJECT_PATH = os.environ.get("HOST_PROJECT_PATH", DEFAULT_PROJECT_PATH)
PROMPTS_DIR = "./prompts" PROMPTS_DIR = "./prompts"
DATA_DIR = "./data" DATA_DIR = "./data"
@ -26,6 +28,7 @@ API_USAGE_FILE = f"{DATA_DIR}/api_usage.json"
__all__ = [ __all__ = [
"DEFAULT_PROJECT_PATH", "DEFAULT_PROJECT_PATH",
"HOST_WORKSPACES_FILE",
"HOST_PROJECT_PATH", "HOST_PROJECT_PATH",
"PROMPTS_DIR", "PROMPTS_DIR",
"DATA_DIR", "DATA_DIR",

View File

@ -57,6 +57,7 @@ except ImportError:
from core.tool_config import TOOL_CATEGORIES from core.tool_config import TOOL_CATEGORIES
from utils.api_client import DeepSeekClient from utils.api_client import DeepSeekClient
from utils.context_manager import ContextManager 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 config.model_profiles import get_model_profile
from core.main_terminal_parts import ( from core.main_terminal_parts import (
@ -289,6 +290,123 @@ class MainTerminal(MainTerminalCommandMixin, MainTerminalContextMixin, MainTermi
except Exception: except Exception:
pass 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): def _ensure_conversation(self):
"""确保CLI模式下存在可用的对话ID""" """确保CLI模式下存在可用的对话ID"""
if self.context_manager.current_conversation_id: if self.context_manager.current_conversation_id:

View File

@ -73,6 +73,7 @@ from modules.container_monitor import collect_stats, inspect_state
from core.tool_config import TOOL_CATEGORIES from core.tool_config import TOOL_CATEGORIES
from utils.api_client import DeepSeekClient from utils.api_client import DeepSeekClient
from utils.context_manager import ContextManager, AUTO_SHALLOW_PLACEHOLDER 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.tool_result_formatter import format_tool_result_for_context
from utils.logger import setup_logger from utils.logger import setup_logger
from config.model_profiles import ( from config.model_profiles import (
@ -177,6 +178,19 @@ class MainTerminalContextMixin:
def build_messages(self, context: Dict, user_input: str) -> List[Dict]: 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 使用专用提示) # 加载系统提示Qwen3.5 使用专用提示)
current_model = getattr(self, "model_key", "kimi") 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" prompt_name = "main_system_qwenvl" if (model_supports_image(current_model) or model_supports_video(current_model)) else "main_system"

View File

@ -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",
]

View File

@ -7,6 +7,7 @@ from __future__ import annotations
import re import re
import shutil import shutil
import threading
from pathlib import Path from pathlib import Path
from typing import Dict, List, Optional, Sequence from typing import Dict, List, Optional, Sequence
@ -17,6 +18,18 @@ logger = setup_logger(__name__)
SKILL_FILE_NAME = "SKILL.md" SKILL_FILE_NAME = "SKILL.md"
SKILL_ID_PATTERN = re.compile(r"^[A-Za-z0-9][A-Za-z0-9_-]*$") 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: def ensure_agent_skills_dir(base_dir: Optional[str] = None) -> Path:
@ -190,7 +203,9 @@ def sync_workspace_skills(
catalog = get_skills_catalog(base_dir) catalog = get_skills_catalog(base_dir)
resolved = resolve_enabled_skills(enabled_skills, catalog) resolved = resolve_enabled_skills(enabled_skills, catalog)
lock = _get_sync_lock(skills_dir)
try: try:
with lock:
if skills_dir.exists(): if skills_dir.exists():
shutil.rmtree(skills_dir, ignore_errors=True) shutil.rmtree(skills_dir, ignore_errors=True)
skills_dir.mkdir(parents=True, exist_ok=True) skills_dir.mkdir(parents=True, exist_ok=True)
@ -199,7 +214,10 @@ def sync_workspace_skills(
src = global_root / skill_id src = global_root / skill_id
if not src.exists() or not src.is_dir(): if not src.exists() or not src.is_dir():
continue continue
shutil.copytree(src, skills_dir / skill_id) 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 { return {
"success": True, "success": True,
"copied": list(resolved), "copied": list(resolved),

View File

@ -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 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.personalization_manager import load_personalization_config
from modules.host_workspace_manager import resolve_host_workspace
from modules.user_manager import UserWorkspace from modules.user_manager import UserWorkspace
from config import ( from config import (
TERMINAL_SANDBOX_MODE, TERMINAL_SANDBOX_MODE,
HOST_PROJECT_PATH,
DATA_DIR, DATA_DIR,
LOGS_DIR, LOGS_DIR,
UPLOAD_QUARANTINE_SUBDIR, UPLOAD_QUARANTINE_SUBDIR,
@ -210,7 +210,9 @@ def host_login():
if not state.container_manager.has_capacity("host"): if not state.container_manager.has_capacity("host"):
return jsonify({"success": False, "error": "资源繁忙,请稍后再试"}), 503 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) host_path.mkdir(parents=True, exist_ok=True)
data_dir = Path(DATA_DIR).expanduser().resolve() data_dir = Path(DATA_DIR).expanduser().resolve()
data_dir.mkdir(parents=True, exist_ok=True) data_dir.mkdir(parents=True, exist_ok=True)
@ -229,6 +231,8 @@ def host_login():
session['username'] = 'host' session['username'] = 'host'
session['role'] = 'admin' session['role'] = 'admin'
session['host_mode'] = True 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) default_thinking = current_app.config.get('DEFAULT_THINKING_MODE', False)
session['thinking_mode'] = default_thinking session['thinking_mode'] = default_thinking
session['run_mode'] = current_app.config.get('DEFAULT_RUN_MODE', "deep" if default_thinking else "fast") session['run_mode'] = current_app.config.get('DEFAULT_RUN_MODE', "deep" if default_thinking else "fast")

View File

@ -277,10 +277,13 @@ async def _dispatch_completion_user_notice(
try: try:
from .tasks import task_manager from .tasks import task_manager
workspace_id = getattr(workspace, "workspace_id", None) or "default" workspace_id = getattr(workspace, "workspace_id", None) or "default"
host_mode = bool(getattr(workspace, "username", None) == "host")
session_data = { session_data = {
"username": username, "username": username,
"role": getattr(web_terminal, "user_role", "user"), "role": getattr(web_terminal, "user_role", "user"),
"is_api_user": getattr(web_terminal, "user_role", "") == "api", "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, "workspace_id": workspace_id,
"run_mode": getattr(web_terminal, "run_mode", None), "run_mode": getattr(web_terminal, "run_mode", None),
"thinking_mode": getattr(web_terminal, "thinking_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 from .tasks import task_manager
workspace_id = getattr(workspace, "workspace_id", None) or "default" workspace_id = getattr(workspace, "workspace_id", None) or "default"
host_mode = bool(getattr(workspace, "username", None) == "host")
session_data = { session_data = {
"username": username, "username": username,
"role": getattr(web_terminal, "user_role", "user"), "role": getattr(web_terminal, "user_role", "user"),
"is_api_user": getattr(web_terminal, "user_role", "") == "api", "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, "workspace_id": workspace_id,
"run_mode": getattr(web_terminal, "run_mode", None), "run_mode": getattr(web_terminal, "run_mode", None),
"thinking_mode": getattr(web_terminal, "thinking_mode", None), "thinking_mode": getattr(web_terminal, "thinking_mode", None),

View File

@ -9,11 +9,12 @@ from modules.gui_file_manager import GuiFileManager
from modules.upload_security import UploadQuarantineManager, UploadSecurityError from modules.upload_security import UploadQuarantineManager, UploadSecurityError
from modules.personalization_manager import load_personalization_config from modules.personalization_manager import load_personalization_config
from modules.skills_manager import sync_workspace_skills 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 import json
from pathlib import Path from pathlib import Path
from modules.usage_tracker import UsageTracker from modules.usage_tracker import UsageTracker
from config import ( from config import (
HOST_PROJECT_PATH,
DATA_DIR, DATA_DIR,
LOGS_DIR, LOGS_DIR,
TERMINAL_SANDBOX_MODE, TERMINAL_SANDBOX_MODE,
@ -81,11 +82,20 @@ def get_user_resources(username: Optional[str] = None, workspace_id: Optional[st
if not username: if not username:
return None, None return None, None
# 宿主机免登录模式:使用 HOST_PROJECT_PATH 直接进入,不创建 /users/<user>/project # 宿主机免登录模式:根据 host_workspaces.json 选择路径,不创建 /users/<user>/project
host_mode_session = bool(session.get("host_mode")) if has_request_context() else False host_mode_session = bool(session.get("host_mode")) if has_request_context() else False
sandbox_is_host = (TERMINAL_SANDBOX_MODE or "host").lower() == "host" sandbox_is_host = (TERMINAL_SANDBOX_MODE or "host").lower() == "host"
if host_mode_session and sandbox_is_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) project_path.mkdir(parents=True, exist_ok=True)
data_dir = Path(DATA_DIR).expanduser().resolve() data_dir = Path(DATA_DIR).expanduser().resolve()
data_dir.mkdir(parents=True, exist_ok=True) 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, quarantine_dir=quarantine_root,
) )
if not hasattr(workspace, "workspace_id"): if not hasattr(workspace, "workspace_id"):
workspace.workspace_id = "host" workspace.workspace_id = host_workspace.get("workspace_id") or "default"
term_key = "host" term_key = "host"
container_handle = state.container_manager.ensure_container("host", str(project_path), container_key=term_key, preferred_mode="host") container_handle = state.container_manager.ensure_container("host", str(project_path), container_key=term_key, preferred_mode="host")
usage_tracker = None # 宿主机模式不计配额 usage_tracker = None # 宿主机模式不计配额
terminal = state.user_terminals.get(term_key) 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: if not terminal:
run_mode = session.get('run_mode') if has_request_context() else None 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 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['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)
session['host_workspace_id'] = getattr(workspace, "workspace_id", None)
else: else:
terminal.update_container_session(container_handle) terminal.update_container_session(container_handle)
attach_user_broadcast(terminal, "host") 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" terminal.user_role = "admin"
if has_request_context(): if has_request_context():
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)
# 宿主机模式同样需要应用管理员策略(否则前端工具菜单会退化成静态基础分类) # 宿主机模式同样需要应用管理员策略(否则前端工具菜单会退化成静态基础分类)
try: 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}") debug_log(f"[admin_policy][host_mode] 应用失败: {exc}")
_ensure_workspace_skills_synced(terminal, workspace) _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 return terminal, workspace
is_api_user = bool(session.get("is_api_user")) if has_request_context() else False 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) load_result = terminal.load_conversation(conversation_id)
if not load_result.get("success"): if not load_result.get("success"):
raise RuntimeError(load_result.get("message", "对话加载失败")) 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: try:
conv_data = terminal.context_manager.conversation_manager.load_conversation(conversation_id) or {} conv_data = terminal.context_manager.conversation_manager.load_conversation(conversation_id) or {}
meta = conv_data.get("metadata", {}) or {} meta = conv_data.get("metadata", {}) or {}
@ -469,6 +553,25 @@ def ensure_conversation_loaded(
session['model_key'] = getattr(terminal, "model_key", None) session['model_key'] = getattr(terminal, "model_key", None)
except Exception: except Exception:
pass 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 # 应用对话级自定义 prompt / personalization仅 API
# 注意ensure_conversation_loaded 在 WebSocket/后台任务等多处复用,有些调用点拿不到 workspace # 注意ensure_conversation_loaded 在 WebSocket/后台任务等多处复用,有些调用点拿不到 workspace
# 因此这里允许 workspace 为空(仅跳过 override不影响正常对话加载 # 因此这里允许 workspace 为空(仅跳过 override不影响正常对话加载

View File

@ -2,10 +2,10 @@ from __future__ import annotations
import time import time
import re import re
from pathlib import Path 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 .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 ( from .state import (
PROJECT_STORAGE_CACHE, PROJECT_STORAGE_CACHE,
PROJECT_STORAGE_CACHE_TTL_SECONDS, PROJECT_STORAGE_CACHE_TTL_SECONDS,
@ -13,7 +13,14 @@ from .state import (
container_manager, container_manager,
user_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__) status_bp = Blueprint('status', __name__)
@ -54,6 +61,10 @@ def _load_app_changelog(project_root: Path) -> str:
return "" 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') @status_bp.route('/api/status')
@api_login_required @api_login_required
@with_terminal @with_terminal
@ -101,6 +112,249 @@ def get_status(terminal, workspace, username):
return jsonify(status) 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') @status_bp.route('/api/container-status')
@api_login_required @api_login_required
@with_terminal @with_terminal

View File

@ -15,6 +15,7 @@ from .context import get_user_resources, ensure_conversation_loaded
from .chat_flow import run_chat_task_sync from .chat_flow import run_chat_task_sync
from .state import stop_flags from .state import stop_flags
from .utils_common import debug_log from .utils_common import debug_log
from utils.host_workspace_debug import write_host_workspace_debug
class TaskRecord: 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) record = TaskRecord(task_id, username, workspace_id, message, conversation_id, model_key, thinking_mode, run_mode, max_iterations)
# 记录当前 session 快照,便于后台线程内使用 # 记录当前 session 快照,便于后台线程内使用
if session_data is not None: 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: else:
try: try:
record.session_data = { record.session_data = {
@ -132,6 +142,7 @@ class TaskManager:
"role": session.get("role"), "role": session.get("role"),
"is_api_user": session.get("is_api_user"), "is_api_user": session.get("is_api_user"),
"host_mode": session.get("host_mode"), "host_mode": session.get("host_mode"),
"host_workspace_id": session.get("host_workspace_id") or workspace_id,
"workspace_id": workspace_id, "workspace_id": workspace_id,
"run_mode": session.get("run_mode"), "run_mode": session.get("run_mode"),
"thinking_mode": session.get("thinking_mode"), "thinking_mode": session.get("thinking_mode"),
@ -217,6 +228,15 @@ class TaskManager:
for k, v in (rec.session_data or {}).items(): for k, v in (rec.session_data or {}).items():
if v is not None: if v is not None:
session[k] = v 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: except Exception:
pass pass
terminal, workspace = get_user_resources(username, workspace_id=workspace_id) terminal, workspace = get_user_resources(username, workspace_id=workspace_id)

View File

@ -93,10 +93,17 @@
:panel-menu-open="panelMenuOpen" :panel-menu-open="panelMenuOpen"
:panel-mode="panelMode" :panel-mode="panelMode"
:file-manager-disabled="policyUiBlocks.block_file_manager" :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" @toggle-panel-menu="togglePanelMenu"
@select-panel="selectPanelMode" @select-panel="selectPanelMode"
@open-file-manager="openGuiFileManager" @open-file-manager="openGuiFileManager"
@toggle-thinking-mode="handleQuickModeToggle" @toggle-thinking-mode="handleQuickModeToggle"
@switch-host-workspace="handleHostWorkspaceSwitch"
@create-host-workspace="handleCreateHostWorkspace"
/> />
<div <div
@ -411,6 +418,20 @@
@confirm="confirmVersioningRestore" @confirm="confirmVersioningRestore"
/> />
</transition> </transition>
<transition name="overlay-fade">
<HostWorkspaceCreateDialog
v-if="hostWorkspaceCreateDialogOpen"
:open="hostWorkspaceCreateDialogOpen"
:path-value="hostWorkspaceCreatePath"
:label-value="hostWorkspaceCreateLabel"
:submitting="hostWorkspaceCreateSubmitting"
:error-message="hostWorkspaceCreateError"
@close="closeHostWorkspaceCreateDialog"
@submit="submitHostWorkspaceCreate"
@update:path-value="hostWorkspaceCreatePath = $event"
@update:label-value="hostWorkspaceCreateLabel = $event"
/>
</transition>
<TutorialOverlay v-if="tutorialStore.running" /> <TutorialOverlay v-if="tutorialStore.running" />
<NewUserTutorialPrompt <NewUserTutorialPrompt
:visible="tutorialPromptVisible" :visible="tutorialPromptVisible"
@ -645,12 +666,20 @@
:icon-style="iconStyle" :icon-style="iconStyle"
:agent-version="agentVersion" :agent-version="agentVersion"
:thinking-mode="thinkingMode" :thinking-mode="thinkingMode"
:run-mode="resolvedRunMode"
:is-connected="isConnected" :is-connected="isConnected"
:panel-menu-open="panelMenuOpen" :panel-menu-open="panelMenuOpen"
:panel-mode="panelMode" :panel-mode="panelMode"
:host-workspace-enabled="versioningHostMode"
:host-workspaces="hostWorkspaces"
:host-workspace-id="currentHostWorkspaceId"
:host-workspace-default-id="defaultHostWorkspaceId"
:host-workspace-switching="hostWorkspaceSwitching"
@toggle-panel-menu="togglePanelMenu" @toggle-panel-menu="togglePanelMenu"
@select-panel="selectPanelMode" @select-panel="selectPanelMode"
@open-file-manager="openGuiFileManager" @open-file-manager="openGuiFileManager"
@switch-host-workspace="handleHostWorkspaceSwitch"
@create-host-workspace="handleCreateHostWorkspace"
/> />
</div> </div>
</div> </div>

View File

@ -24,6 +24,9 @@ const BackgroundCommandDialog = defineAsyncComponent(
const VersioningDialog = defineAsyncComponent( const VersioningDialog = defineAsyncComponent(
() => import('../components/overlay/VersioningDialog.vue') () => import('../components/overlay/VersioningDialog.vue')
); );
const HostWorkspaceCreateDialog = defineAsyncComponent(
() => import('../components/overlay/HostWorkspaceCreateDialog.vue')
);
const TutorialOverlay = defineAsyncComponent( const TutorialOverlay = defineAsyncComponent(
() => import('../components/overlay/TutorialOverlay.vue') () => import('../components/overlay/TutorialOverlay.vue')
); );
@ -46,6 +49,7 @@ export const appComponents = {
SubAgentActivityDialog, SubAgentActivityDialog,
BackgroundCommandDialog, BackgroundCommandDialog,
VersioningDialog, VersioningDialog,
HostWorkspaceCreateDialog,
TutorialOverlay, TutorialOverlay,
NewUserTutorialPrompt NewUserTutorialPrompt
}; };

View File

@ -488,6 +488,159 @@ export const uiMethods = {
await this.updateTutorialPromptStatus(true); 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() { fetchTodoList() {
return this.fileFetchTodoList(); return this.fileFetchTodoList();
}, },
@ -1842,20 +1995,42 @@ export const uiMethods = {
}, },
startConnectionHeartbeat() { startConnectionHeartbeat() {
if (this.connectionHeartbeatTimer) { if (this.connectionHeartbeatActive) {
return; return;
} }
this.connectionHeartbeatActive = true;
this.connectionHeartbeatFailCount = 0; this.connectionHeartbeatFailCount = 0;
// 先做一次即时探活,再进入定时轮询 const runHeartbeat = async () => {
this.checkConnectionHealth(); if (!this.connectionHeartbeatActive) {
this.connectionHeartbeatTimer = window.setInterval(() => { return;
this.checkConnectionHealth(); }
}, this.connectionHeartbeatIntervalMs || 8000); 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() { stopConnectionHeartbeat() {
this.connectionHeartbeatActive = false;
if (this.connectionHeartbeatTimer) { if (this.connectionHeartbeatTimer) {
clearInterval(this.connectionHeartbeatTimer); clearTimeout(this.connectionHeartbeatTimer);
this.connectionHeartbeatTimer = null; this.connectionHeartbeatTimer = null;
} }
}, },
@ -1942,7 +2117,16 @@ export const uiMethods = {
this.versioningHostMode = !!isHostMode; this.versioningHostMode = !!isHostMode;
if (isHostMode) { if (isHostMode) {
this.fileMarkTreeUnavailable('宿主机模式下文件树不可用'); this.fileMarkTreeUnavailable('宿主机模式下文件树不可用');
await this.fetchHostWorkspaces();
} else { } else {
this.hostWorkspaces = [];
this.currentHostWorkspaceId = '';
this.defaultHostWorkspaceId = '';
this.hostWorkspaceCreateDialogOpen = false;
this.hostWorkspaceCreatePath = '';
this.hostWorkspaceCreateLabel = '';
this.hostWorkspaceCreateError = '';
this.hostWorkspaceCreateSubmitting = false;
treePromise = this.fileFetchTree(); treePromise = this.fileFetchTree();
} }

View File

@ -82,6 +82,15 @@ export function dataState() {
permissionMenuOpen: false, permissionMenuOpen: false,
currentPermissionMode: 'unrestricted', currentPermissionMode: 'unrestricted',
versioningHostMode: false, versioningHostMode: false,
hostWorkspaces: [],
currentHostWorkspaceId: '',
defaultHostWorkspaceId: '',
hostWorkspaceSwitching: false,
hostWorkspaceCreateDialogOpen: false,
hostWorkspaceCreatePath: '',
hostWorkspaceCreateLabel: '',
hostWorkspaceCreateSubmitting: false,
hostWorkspaceCreateError: '',
versioningEnabled: false, versioningEnabled: false,
newConversationVersioningEnabled: false, newConversationVersioningEnabled: false,
versioningMode: 'overwrite', versioningMode: 'overwrite',
@ -123,8 +132,10 @@ export function dataState() {
conversationListRequestSeq: 0, conversationListRequestSeq: 0,
conversationListRefreshToken: 0, conversationListRefreshToken: 0,
connectionHeartbeatTimer: null, connectionHeartbeatTimer: null,
connectionHeartbeatActive: false,
connectionHeartbeatFailCount: 0, connectionHeartbeatFailCount: 0,
connectionHeartbeatIntervalMs: 8000, connectionHeartbeatIntervalMs: 8000,
connectionHeartbeatDisconnectedIntervalMs: 1000,
// 工具控制菜单 // 工具控制菜单
icons: ICONS, icons: ICONS,

View File

@ -0,0 +1,211 @@
<template>
<div
class="host-workspace-dialog-overlay"
role="dialog"
aria-modal="true"
aria-label="新建工作区"
@click.self="handleOverlayClose"
>
<form class="host-workspace-dialog" @submit.prevent="$emit('submit')">
<div class="host-workspace-dialog__header">
<div class="host-workspace-dialog__title">新建工作区</div>
<button
type="button"
class="host-workspace-dialog__close"
:disabled="submitting"
@click="$emit('close')"
>
关闭
</button>
</div>
<div class="host-workspace-dialog__body">
<label class="host-workspace-dialog__field">
<span class="host-workspace-dialog__label">工作区路径</span>
<input
:value="pathValue"
type="text"
class="host-workspace-dialog__input"
placeholder="请输入绝对路径或相对仓库路径"
autocomplete="off"
:disabled="submitting"
@input="$emit('update:pathValue', ($event.target as HTMLInputElement).value)"
/>
</label>
<label class="host-workspace-dialog__field">
<span class="host-workspace-dialog__label">工作区名称可选</span>
<input
:value="labelValue"
type="text"
class="host-workspace-dialog__input"
placeholder="例如客户A项目"
autocomplete="off"
:disabled="submitting"
@input="$emit('update:labelValue', ($event.target as HTMLInputElement).value)"
/>
</label>
<div v-if="errorMessage" class="host-workspace-dialog__error">{{ errorMessage }}</div>
</div>
<div class="host-workspace-dialog__actions">
<button type="button" class="host-workspace-dialog__btn ghost" :disabled="submitting" @click="$emit('close')">
取消
</button>
<button type="submit" class="host-workspace-dialog__btn primary" :disabled="submitting">
{{ submitting ? '创建中...' : '创建工作区' }}
</button>
</div>
</form>
</div>
</template>
<script setup lang="ts">
defineOptions({ name: 'HostWorkspaceCreateDialog' });
const props = defineProps<{
open: boolean;
pathValue: string;
labelValue: string;
submitting?: boolean;
errorMessage?: string;
}>();
const emits = defineEmits<{
(event: 'close'): void;
(event: 'submit'): void;
(event: 'update:pathValue', value: string): void;
(event: 'update:labelValue', value: string): void;
}>();
const handleOverlayClose = () => {
if (props.submitting) return;
emits('close');
};
</script>
<style scoped>
.host-workspace-dialog-overlay {
position: fixed;
inset: 0;
z-index: 1300;
display: flex;
align-items: center;
justify-content: center;
padding: 20px;
background: var(--theme-overlay-scrim);
backdrop-filter: blur(10px);
}
.host-workspace-dialog {
width: min(520px, 96vw);
border-radius: 18px;
border: 1px solid var(--theme-control-border-strong);
background: var(--theme-surface-card);
box-shadow: var(--theme-shadow-soft);
display: flex;
flex-direction: column;
}
.host-workspace-dialog__header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
padding: 16px 18px;
border-bottom: 1px solid var(--theme-control-border);
}
.host-workspace-dialog__title {
font-size: 16px;
font-weight: 700;
color: var(--claude-text);
}
.host-workspace-dialog__close {
border: 1px solid var(--theme-control-border-strong);
background: transparent;
color: var(--claude-text-secondary);
border-radius: 10px;
padding: 6px 12px;
cursor: pointer;
}
.host-workspace-dialog__close:disabled {
opacity: 0.6;
cursor: not-allowed;
}
.host-workspace-dialog__body {
padding: 16px 18px;
display: grid;
gap: 12px;
}
.host-workspace-dialog__field {
display: grid;
gap: 6px;
}
.host-workspace-dialog__label {
font-size: 13px;
color: var(--claude-text-secondary);
}
.host-workspace-dialog__input {
border: 1px solid var(--theme-control-border-strong);
border-radius: 10px;
background: var(--theme-surface-soft);
color: var(--claude-text);
padding: 10px 12px;
font-size: 14px;
line-height: 1.4;
outline: none;
}
.host-workspace-dialog__input:focus {
border-color: var(--claude-accent);
box-shadow: 0 0 0 2px color-mix(in srgb, var(--claude-accent) 20%, transparent);
}
.host-workspace-dialog__input:disabled {
opacity: 0.7;
}
.host-workspace-dialog__error {
font-size: 13px;
color: #ef4444;
}
.host-workspace-dialog__actions {
padding: 16px 18px;
border-top: 1px solid var(--theme-control-border);
display: flex;
justify-content: flex-end;
gap: 10px;
}
.host-workspace-dialog__btn {
border-radius: 10px;
padding: 8px 14px;
border: 1px solid var(--theme-control-border-strong);
cursor: pointer;
}
.host-workspace-dialog__btn.ghost {
background: transparent;
color: var(--claude-text-secondary);
}
.host-workspace-dialog__btn.primary {
background: var(--claude-accent);
border-color: var(--claude-accent-strong);
color: #fff;
}
.host-workspace-dialog__btn:disabled {
opacity: 0.65;
cursor: not-allowed;
}
</style>

View File

@ -193,7 +193,54 @@
</div> </div>
</div> </div>
<div v-else class="file-tree" @contextmenu.prevent> <div v-else class="file-tree" @contextmenu.prevent>
<div v-if="fileTreeUnavailable" class="file-tree-empty">{{ fileTreeMessage }}</div> <template v-if="fileTreeUnavailable">
<div v-if="hostWorkspaceEnabled" class="host-workspace-section">
<div class="host-workspace-header-box">
<button
type="button"
class="host-workspace-add-btn"
:disabled="hostWorkspaceSwitching"
@click="handleCreateHostWorkspace"
title="新建工作区"
>
+
</button>
<div class="host-workspace-title">切换工作区</div>
</div>
<div class="host-workspace-list" v-if="hostWorkspaceOptions.length">
<button
v-for="item in hostWorkspaceOptions"
:key="item.workspace_id"
type="button"
class="host-workspace-card"
:class="{ active: item.workspace_id === hostWorkspaceId }"
:disabled="hostWorkspaceSwitching || item.workspace_id === hostWorkspaceId"
@click="handleHostWorkspaceClick(item.workspace_id)"
>
<div class="host-workspace-card-head">
<span class="host-workspace-label">{{ item.label }}</span>
<span class="host-workspace-badges">
<span
v-if="item.workspace_id === hostWorkspaceDefaultId"
class="host-workspace-badge default"
>
默认工作区
</span>
<span v-if="item.workspace_id === hostWorkspaceId" class="host-workspace-badge current">
当前
</span>
</span>
</div>
<div class="host-workspace-path">
{{ item.path || '(未配置路径)' }}
</div>
</button>
</div>
<div v-else class="file-tree-empty">暂无工作区点击右侧 + 新建</div>
<div class="host-workspace-hint" v-if="hostWorkspaceSwitching">正在切换请稍候</div>
</div>
<div v-else class="file-tree-empty">{{ fileTreeMessage }}</div>
</template>
<template v-else> <template v-else>
<div v-if="!fileTree.length" class="file-tree-empty">暂无文件</div> <div v-if="!fileTree.length" class="file-tree-empty">暂无文件</div>
<FileNode <FileNode
@ -234,13 +281,25 @@ const props = defineProps<{
panelMode: 'files' | 'todo' | 'subAgents' | 'backgroundCommands'; panelMode: 'files' | 'todo' | 'subAgents' | 'backgroundCommands';
runMode: 'fast' | 'thinking' | 'deep'; runMode: 'fast' | 'thinking' | 'deep';
fileManagerDisabled?: boolean; fileManagerDisabled?: boolean;
hostWorkspaceEnabled?: boolean;
hostWorkspaces?: Array<{
workspace_id: string;
label: string;
path?: string;
is_current?: boolean;
}>;
hostWorkspaceId?: string;
hostWorkspaceDefaultId?: string;
hostWorkspaceSwitching?: boolean;
}>(); }>();
defineEmits<{ const emits = defineEmits<{
(event: 'toggle-panel-menu'): void; (event: 'toggle-panel-menu'): void;
(event: 'select-panel', mode: 'files' | 'todo' | 'subAgents' | 'backgroundCommands'): void; (event: 'select-panel', mode: 'files' | 'todo' | 'subAgents' | 'backgroundCommands'): void;
(event: 'open-file-manager'): void; (event: 'open-file-manager'): void;
(event: 'toggle-thinking-mode'): void; (event: 'toggle-thinking-mode'): void;
(event: 'switch-host-workspace', workspaceId: string): void;
(event: 'create-host-workspace'): void;
}>(); }>();
const panelMenuWrapper = ref<HTMLElement | null>(null); const panelMenuWrapper = ref<HTMLElement | null>(null);
@ -306,6 +365,29 @@ const { fileTree, expandedFolders, todoList, fileTreeUnavailable, fileTreeMessag
const { subAgents } = storeToRefs(subAgentStore); const { subAgents } = storeToRefs(subAgentStore);
const { commands: backgroundCommands } = storeToRefs(backgroundCommandStore); const { commands: backgroundCommands } = storeToRefs(backgroundCommandStore);
const hostWorkspaceOptions = computed(() => {
const list = Array.isArray(props.hostWorkspaces) ? props.hostWorkspaces : [];
return list.filter((item) => item && item.workspace_id);
});
const handleHostWorkspaceClick = (workspaceId: string) => {
const targetId = String(workspaceId || '').trim();
if (!targetId || targetId === props.hostWorkspaceId) {
return;
}
if (props.hostWorkspaceSwitching) {
return;
}
emits('switch-host-workspace', targetId);
};
const handleCreateHostWorkspace = () => {
if (props.hostWorkspaceSwitching) {
return;
}
emits('create-host-workspace');
};
const openSubAgent = (agent: any) => { const openSubAgent = (agent: any) => {
subAgentStore.openSubAgent(agent); subAgentStore.openSubAgent(agent);
}; };

View File

@ -489,6 +489,188 @@
background: rgba(255, 255, 255, 0.8); background: rgba(255, 255, 255, 0.8);
border: 1px dashed var(--claude-border); border: 1px dashed var(--claude-border);
text-align: center; text-align: center;
[data-theme="dark"] & {
background: rgba(20, 20, 20, 0.9);
border-color: rgba(255, 255, 255, 0.12);
}
}
.file-tree-empty--host {
text-align: left;
}
.host-workspace-section {
display: flex;
flex-direction: column;
gap: 10px;
}
.host-workspace-switcher {
display: flex;
flex-direction: column;
gap: 10px;
}
.host-workspace-header-box {
width: min(100%, 360px);
margin: 0 auto;
border-radius: 12px;
border: 1px dashed var(--claude-border-strong);
background: var(--theme-surface-muted);
padding: 10px 12px;
position: relative;
display: flex;
align-items: center;
justify-content: center;
}
.host-workspace-add-btn {
position: absolute;
right: 10px;
width: 22px;
height: 22px;
appearance: none;
-webkit-appearance: none;
border: none;
background: transparent;
padding: 0;
color: var(--claude-text);
font-size: 22px;
font-weight: 500;
line-height: 1;
display: inline-flex;
align-items: center;
justify-content: center;
cursor: pointer;
box-shadow: none;
&:hover:not(:disabled) {
color: var(--claude-accent);
}
&:disabled {
opacity: 0.6;
cursor: not-allowed;
}
}
.host-workspace-title {
font-size: 14px;
font-weight: 600;
color: var(--claude-text);
text-align: center;
}
.host-workspace-list {
display: flex;
flex-direction: column;
gap: 8px;
}
.host-workspace-card {
width: 100%;
border: 1px solid var(--theme-control-border);
background: var(--theme-surface-soft);
border-radius: 10px;
padding: 10px 12px;
text-align: left;
color: var(--claude-text);
cursor: pointer;
transition: border-color 0.2s ease, transform 0.2s ease;
&:hover:not(:disabled) {
border-color: var(--claude-accent);
transform: translateY(-1px);
}
&:disabled {
cursor: default;
opacity: 0.95;
}
&.active {
border-color: var(--claude-accent);
background: var(--claude-highlight);
}
}
.host-workspace-card-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
margin-bottom: 6px;
}
.host-workspace-label {
font-size: 13px;
font-weight: 600;
color: var(--claude-text);
}
.host-workspace-badges {
display: inline-flex;
align-items: center;
gap: 6px;
}
.host-workspace-badge {
border-radius: 999px;
padding: 2px 8px;
font-size: 11px;
line-height: 1.2;
border: 1px solid var(--theme-control-border);
color: var(--claude-text-secondary);
background: var(--theme-surface-muted);
&.default {
color: var(--claude-deep-strong);
border-color: rgba(208, 122, 20, 0.35);
background: rgba(242, 169, 59, 0.14);
}
&.current {
color: #2f6f4e;
border-color: rgba(47, 111, 78, 0.35);
background: rgba(118, 176, 134, 0.18);
}
}
[data-theme="dark"] .host-workspace-header-box {
background: rgba(30, 30, 30, 0.78);
border-color: rgba(160, 160, 160, 0.45);
}
[data-theme="dark"] .host-workspace-card {
&.active {
background: rgba(255, 255, 255, 0.08);
}
}
[data-theme="dark"] .host-workspace-badge {
&.default {
color: #f7c56f;
border-color: rgba(247, 197, 111, 0.35);
background: rgba(247, 197, 111, 0.16);
}
&.current {
color: #8edbb4;
border-color: rgba(142, 219, 180, 0.34);
background: rgba(16, 185, 129, 0.2);
}
}
.host-workspace-path {
font-size: 12px;
color: var(--claude-text-secondary);
word-break: break-all;
}
.host-workspace-hint {
font-size: 12px;
color: var(--claude-text-secondary);
} }
.todo-task { .todo-task {

View File

@ -0,0 +1,69 @@
import json
import tempfile
import unittest
from pathlib import Path
from modules.host_workspace_manager import (
create_host_workspace,
load_host_workspace_catalog,
resolve_host_workspace,
)
class HostWorkspaceManagerTest(unittest.TestCase):
def test_create_default_when_config_missing(self):
with tempfile.TemporaryDirectory() as tmpdir:
cfg = Path(tmpdir) / "host_workspaces.json"
self.assertFalse(cfg.exists())
catalog = load_host_workspace_catalog(config_path=cfg)
self.assertTrue(cfg.exists())
self.assertTrue(catalog["workspaces"])
self.assertEqual(catalog["default_workspace_id"], catalog["workspaces"][0]["workspace_id"])
def test_resolve_selected_workspace(self):
with tempfile.TemporaryDirectory() as tmpdir:
ws_a = Path(tmpdir) / "a"
ws_b = Path(tmpdir) / "b"
payload = {
"default_workspace_id": "a",
"workspaces": [
{"workspace_id": "a", "label": "A", "path": str(ws_a)},
{"workspace_id": "b", "label": "B", "path": str(ws_b)},
],
}
cfg = Path(tmpdir) / "host_workspaces.json"
cfg.write_text(json.dumps(payload, ensure_ascii=False), encoding="utf-8")
_, current = resolve_host_workspace("b", config_path=cfg)
self.assertEqual(current["workspace_id"], "b")
_, current_fallback = resolve_host_workspace("not-exist", config_path=cfg)
self.assertEqual(current_fallback["workspace_id"], "a")
def test_create_workspace_persisted(self):
with tempfile.TemporaryDirectory() as tmpdir:
cfg = Path(tmpdir) / "host_workspaces.json"
cfg.write_text(
json.dumps(
{
"default_workspace_id": "default",
"workspaces": [
{"workspace_id": "default", "label": "默认", "path": str(Path(tmpdir) / "project")}
],
},
ensure_ascii=False,
),
encoding="utf-8",
)
result = create_host_workspace(str(Path(tmpdir) / "repo-a"), label="Repo A", config_path=cfg)
self.assertTrue(result["created"])
self.assertEqual(result["workspace"]["label"], "Repo A")
catalog = load_host_workspace_catalog(config_path=cfg)
labels = [item["label"] for item in catalog["workspaces"]]
self.assertIn("Repo A", labels)
if __name__ == "__main__":
unittest.main()

View File

@ -55,6 +55,7 @@ except ImportError:
model_supports_video, model_supports_video,
) )
from utils.conversation_manager import ConversationManager from utils.conversation_manager import ConversationManager
from utils.host_workspace_debug import write_host_workspace_debug
from utils.media_store import MediaStore from utils.media_store import MediaStore
AUTO_SHALLOW_PLACEHOLDER = "过早的工具结果已经被自动压缩" AUTO_SHALLOW_PLACEHOLDER = "过早的工具结果已经被自动压缩"
@ -834,6 +835,12 @@ class ContextManager:
print(f"⚠️ 对话记录中的项目路径不可用,已回退至: {resolved_project_path}") print(f"⚠️ 对话记录中的项目路径不可用,已回退至: {resolved_project_path}")
self.project_path = resolved_project_path self.project_path = resolved_project_path
write_host_workspace_debug(
"context_manager.load_conversation_by_id.set_project_path",
conversation_id=conversation_id,
metadata_project_path=str(stored_path_obj) if stored_path_obj else None,
resolved_project_path=str(resolved_project_path),
)
run_mode = metadata.get("run_mode") run_mode = metadata.get("run_mode")
permission_mode = metadata.get("permission_mode") permission_mode = metadata.get("permission_mode")
@ -909,11 +916,32 @@ class ContextManager:
meta = self.conversation_metadata or {} meta = self.conversation_metadata or {}
stored_tree = meta.get("project_file_tree") stored_tree = meta.get("project_file_tree")
if stored_tree and stored_tree != "宿主机模式下文件树不可用": if stored_tree and stored_tree != "宿主机模式下文件树不可用":
use_stored_snapshot = True
stored_path = meta.get("project_path")
if isinstance(stored_path, str) and stored_path.strip():
try:
stored_path_obj = Path(stored_path.strip()).expanduser().resolve()
if stored_path_obj != Path(self.project_path).resolve():
use_stored_snapshot = False
write_host_workspace_debug(
"context_manager.project_snapshot.discard_stale_snapshot",
conversation_id=self.current_conversation_id,
metadata_project_path=str(stored_path_obj),
current_project_path=str(Path(self.project_path).resolve()),
)
except Exception:
pass
if use_stored_snapshot:
self.project_snapshot = { self.project_snapshot = {
"file_tree": stored_tree, "file_tree": stored_tree,
"statistics": meta.get("project_statistics"), "statistics": meta.get("project_statistics"),
"snapshot_at": meta.get("project_snapshot_at") "snapshot_at": meta.get("project_snapshot_at")
} }
write_host_workspace_debug(
"context_manager.project_snapshot.use_metadata_snapshot",
conversation_id=self.current_conversation_id,
project_path=str(self.project_path),
)
return self.project_snapshot return self.project_snapshot
# 首次生成并缓存 # 首次生成并缓存
@ -927,6 +955,12 @@ class ContextManager:
"snapshot_at": datetime.now().isoformat() "snapshot_at": datetime.now().isoformat()
} }
self.project_snapshot = snapshot self.project_snapshot = snapshot
write_host_workspace_debug(
"context_manager.project_snapshot.generated_new",
conversation_id=self.current_conversation_id,
project_path=str(self.project_path),
total_files=snapshot.get("statistics", {}).get("total_files"),
)
if self.current_conversation_id: if self.current_conversation_id:
self.conversation_manager.update_project_snapshot( self.conversation_manager.update_project_snapshot(
self.current_conversation_id, self.current_conversation_id,

View File

@ -0,0 +1,37 @@
"""宿主机工作区切换调试日志JSON Lines"""
from __future__ import annotations
import json
import threading
from datetime import datetime
from pathlib import Path
from typing import Any
from config import LOGS_DIR
_LOG_FILE = Path(LOGS_DIR).expanduser().resolve() / "host_workspace_debug.log"
_LOCK = threading.Lock()
def write_host_workspace_debug(event: str, **payload: Any) -> None:
"""写入一条宿主机工作区调试日志。"""
try:
record = {
"ts": datetime.now().isoformat(),
"event": event,
**payload,
}
line = json.dumps(record, ensure_ascii=False, default=str)
with _LOCK:
_LOG_FILE.parent.mkdir(parents=True, exist_ok=True)
with _LOG_FILE.open("a", encoding="utf-8") as fp:
fp.write(line + "\n")
except Exception:
# 调试日志不应影响主流程
return
def get_host_workspace_debug_log_path() -> str:
return str(_LOG_FILE)