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
TERMINAL_SANDBOX_MODE=docker
# 宿主机模式工作区配置JSON 文件)
HOST_WORKSPACES_FILE=./config/host_workspaces.json
# 容器镜像及挂载路径
TERMINAL_SANDBOX_IMAGE=python:3.11-slim
TERMINAL_SANDBOX_MOUNT_PATH=/workspace

3
.gitignore vendored
View File

@ -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

View File

@ -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 模式**:

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
# 宿主机模式工作区配置文件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",

View File

@ -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:

View File

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

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 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)}

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 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")

View File

@ -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),

View File

@ -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/<user>/project
# 宿主机免登录模式:根据 host_workspaces.json 选择路径,不创建 /users/<user>/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不影响正常对话加载

View File

@ -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

View File

@ -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)

View File

@ -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"
/>
<div
@ -411,6 +418,20 @@
@confirm="confirmVersioningRestore"
/>
</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" />
<NewUserTutorialPrompt
:visible="tutorialPromptVisible"
@ -645,12 +666,20 @@
:icon-style="iconStyle"
:agent-version="agentVersion"
:thinking-mode="thinkingMode"
:run-mode="resolvedRunMode"
:is-connected="isConnected"
:panel-menu-open="panelMenuOpen"
: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"
@select-panel="selectPanelMode"
@open-file-manager="openGuiFileManager"
@switch-host-workspace="handleHostWorkspaceSwitch"
@create-host-workspace="handleCreateHostWorkspace"
/>
</div>
</div>

View File

@ -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
};

View File

@ -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();
}

View File

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

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 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>
<div v-if="!fileTree.length" class="file-tree-empty">暂无文件</div>
<FileNode
@ -234,13 +281,25 @@ const props = defineProps<{
panelMode: 'files' | 'todo' | 'subAgents' | 'backgroundCommands';
runMode: 'fast' | 'thinking' | 'deep';
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: 'select-panel', mode: 'files' | 'todo' | 'subAgents' | 'backgroundCommands'): void;
(event: 'open-file-manager'): void;
(event: 'toggle-thinking-mode'): void;
(event: 'switch-host-workspace', workspaceId: string): void;
(event: 'create-host-workspace'): void;
}>();
const panelMenuWrapper = ref<HTMLElement | null>(null);
@ -306,6 +365,29 @@ const { fileTree, expandedFolders, todoList, fileTreeUnavailable, fileTreeMessag
const { subAgents } = storeToRefs(subAgentStore);
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) => {
subAgentStore.openSubAgent(agent);
};

View File

@ -489,6 +489,188 @@
background: rgba(255, 255, 255, 0.8);
border: 1px dashed var(--claude-border);
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 {

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,
)
from utils.conversation_manager import ConversationManager
from utils.host_workspace_debug import write_host_workspace_debug
from utils.media_store import MediaStore
AUTO_SHALLOW_PLACEHOLDER = "过早的工具结果已经被自动压缩"
@ -834,6 +835,12 @@ class ContextManager:
print(f"⚠️ 对话记录中的项目路径不可用,已回退至: {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")
permission_mode = metadata.get("permission_mode")
@ -909,12 +916,33 @@ class ContextManager:
meta = self.conversation_metadata or {}
stored_tree = meta.get("project_file_tree")
if stored_tree and stored_tree != "宿主机模式下文件树不可用":
self.project_snapshot = {
"file_tree": stored_tree,
"statistics": meta.get("project_statistics"),
"snapshot_at": meta.get("project_snapshot_at")
}
return self.project_snapshot
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 = {
"file_tree": stored_tree,
"statistics": meta.get("project_statistics"),
"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
# 首次生成并缓存
structure = self._get_project_structure_for_prompt()
@ -927,6 +955,12 @@ class ContextManager:
"snapshot_at": datetime.now().isoformat()
}
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:
self.conversation_manager.update_project_snapshot(
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)