feat: add host workspace manager, mcp-tool-config skill, and UI enhancements
This commit is contained in:
parent
cfffc8e4ef
commit
b151f8ff71
@ -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
3
.gitignore
vendored
@ -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
|
||||
|
||||
16
README.md
16
README.md
@ -124,7 +124,19 @@
|
||||
配置:
|
||||
```bash
|
||||
TERMINAL_SANDBOX_MODE=host
|
||||
HOST_PROJECT_PATH=/path/to/project # 可选,指定项目路径
|
||||
HOST_WORKSPACES_FILE=./config/host_workspaces.json
|
||||
```
|
||||
|
||||
`HOST_WORKSPACES_FILE` 对应 JSON 示例:
|
||||
|
||||
```json
|
||||
{
|
||||
"default_workspace_id": "default",
|
||||
"workspaces": [
|
||||
{ "workspace_id": "default", "label": "默认工作区", "path": "./project" },
|
||||
{ "workspace_id": "my-repo", "label": "我的仓库", "path": "/Users/you/code/my-repo" }
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
#### Docker 容器模式 (Docker Mode)
|
||||
@ -482,7 +494,7 @@ static/src/
|
||||
|
||||
**宿主机模式**:
|
||||
- `TERMINAL_SANDBOX_MODE=host`: 启用宿主机模式
|
||||
- `HOST_PROJECT_PATH`: 指定项目路径(可选)
|
||||
- `HOST_WORKSPACES_FILE`: 宿主机工作区 JSON 配置文件路径
|
||||
- `LINUX_SAFETY`: Linux 安全保护开关
|
||||
|
||||
**Docker 模式**:
|
||||
|
||||
7
agentskills/mcp-tool-config/SKILL.md
Normal file
7
agentskills/mcp-tool-config/SKILL.md
Normal file
@ -0,0 +1,7 @@
|
||||
---
|
||||
name: mcp-tool-config
|
||||
description: 指导模型在宿主机模式下,如何给自己配置新的mcp工具
|
||||
---
|
||||
|
||||
Just Read This File
|
||||
skills/mcp-tool-config/mcp-tool-config-guide.md
|
||||
202
agentskills/mcp-tool-config/mcp-tool-config-guide.md
Normal file
202
agentskills/mcp-tool-config/mcp-tool-config-guide.md
Normal file
@ -0,0 +1,202 @@
|
||||
# 本项目新增 MCP 工具配置指南
|
||||
|
||||
> 最后更新:2026-04-26
|
||||
> 适用目录:`/Users/jojo/Desktop/agents/正在修复中/agents`
|
||||
|
||||
本文面向后续维护者,说明如何在当前项目里新增并接入一个 MCP 工具(MCP Server)。
|
||||
|
||||
---
|
||||
|
||||
## 1. 先了解本项目里的 MCP 架构
|
||||
|
||||
当前实现是「**本项目作为 MCP 客户端桥接层**」:
|
||||
|
||||
1. 在后台配置一个或多个 MCP Server(stdio / streamable_http)
|
||||
2. 系统通过 `tools/list` 拉取远端工具
|
||||
3. 本地自动生成工具别名:`mcp__<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`
|
||||
10
config/host_workspaces.json.example
Normal file
10
config/host_workspaces.json.example
Normal file
@ -0,0 +1,10 @@
|
||||
{
|
||||
"default_workspace_id": "default",
|
||||
"workspaces": [
|
||||
{
|
||||
"workspace_id": "default",
|
||||
"label": "默认工作区",
|
||||
"path": "/Users/jojo/Desktop/agents/正在修复中/agents/project"
|
||||
}
|
||||
]
|
||||
}
|
||||
@ -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",
|
||||
|
||||
@ -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:
|
||||
|
||||
@ -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"
|
||||
|
||||
239
modules/host_workspace_manager.py
Normal file
239
modules/host_workspace_manager.py
Normal 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",
|
||||
]
|
||||
@ -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)}
|
||||
|
||||
@ -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")
|
||||
|
||||
@ -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),
|
||||
|
||||
@ -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,不影响正常对话加载)。
|
||||
|
||||
260
server/status.py
260
server/status.py
@ -2,10 +2,10 @@ from __future__ import annotations
|
||||
import time
|
||||
import re
|
||||
from pathlib import Path
|
||||
from flask import Blueprint, jsonify, request, send_file
|
||||
from flask import Blueprint, jsonify, request, send_file, session
|
||||
|
||||
from .auth_helpers import api_login_required, resolve_admin_policy
|
||||
from .context import with_terminal
|
||||
from .context import with_terminal, attach_user_broadcast
|
||||
from .state import (
|
||||
PROJECT_STORAGE_CACHE,
|
||||
PROJECT_STORAGE_CACHE_TTL_SECONDS,
|
||||
@ -13,7 +13,14 @@ from .state import (
|
||||
container_manager,
|
||||
user_manager,
|
||||
)
|
||||
from config import AGENT_VERSION
|
||||
from config import AGENT_VERSION, TERMINAL_SANDBOX_MODE
|
||||
from modules.host_workspace_manager import (
|
||||
create_host_workspace,
|
||||
load_host_workspace_catalog,
|
||||
resolve_host_workspace,
|
||||
)
|
||||
from utils.host_workspace_debug import write_host_workspace_debug
|
||||
from . import state
|
||||
|
||||
status_bp = Blueprint('status', __name__)
|
||||
|
||||
@ -54,6 +61,10 @@ def _load_app_changelog(project_root: Path) -> str:
|
||||
return ""
|
||||
|
||||
|
||||
def _is_host_mode_request() -> bool:
|
||||
return bool(session.get("host_mode")) and (TERMINAL_SANDBOX_MODE or "").lower() == "host"
|
||||
|
||||
|
||||
@status_bp.route('/api/status')
|
||||
@api_login_required
|
||||
@with_terminal
|
||||
@ -101,6 +112,249 @@ def get_status(terminal, workspace, username):
|
||||
return jsonify(status)
|
||||
|
||||
|
||||
@status_bp.route('/api/host/workspaces')
|
||||
@api_login_required
|
||||
def list_host_workspaces():
|
||||
if not _is_host_mode_request():
|
||||
return jsonify({"success": False, "error": "仅宿主机模式可用"}), 403
|
||||
|
||||
catalog, current = resolve_host_workspace(session.get("host_workspace_id"))
|
||||
current_id = current.get("workspace_id")
|
||||
session['host_workspace_id'] = current_id
|
||||
session['workspace_id'] = current_id
|
||||
|
||||
workspaces = []
|
||||
for item in catalog.get("workspaces") or []:
|
||||
workspaces.append({
|
||||
"workspace_id": item.get("workspace_id"),
|
||||
"label": item.get("label"),
|
||||
"path": item.get("path"),
|
||||
"is_current": item.get("workspace_id") == current_id,
|
||||
})
|
||||
|
||||
return jsonify({
|
||||
"success": True,
|
||||
"data": {
|
||||
"source_path": catalog.get("source_path"),
|
||||
"default_workspace_id": catalog.get("default_workspace_id"),
|
||||
"current_workspace_id": current_id,
|
||||
"workspaces": workspaces,
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
@status_bp.route('/api/host/workspaces/select', methods=['GET', 'POST'])
|
||||
@api_login_required
|
||||
def select_host_workspace():
|
||||
if not _is_host_mode_request():
|
||||
return jsonify({"success": False, "error": "仅宿主机模式可用"}), 403
|
||||
|
||||
payload = request.get_json(silent=True) if request.method != "GET" else None
|
||||
workspace_id = (
|
||||
request.args.get("workspace_id")
|
||||
or (payload or {}).get("workspace_id")
|
||||
or ""
|
||||
).strip()
|
||||
if not workspace_id:
|
||||
return jsonify({"success": False, "error": "缺少 workspace_id"}), 400
|
||||
write_host_workspace_debug(
|
||||
"status.select_host_workspace.request",
|
||||
workspace_id=workspace_id,
|
||||
current_session_workspace_id=session.get("host_workspace_id"),
|
||||
)
|
||||
|
||||
catalog = load_host_workspace_catalog()
|
||||
current_id = (session.get("host_workspace_id") or "").strip()
|
||||
target = next(
|
||||
(item for item in (catalog.get("workspaces") or []) if item.get("workspace_id") == workspace_id),
|
||||
None,
|
||||
)
|
||||
if not target:
|
||||
return jsonify({"success": False, "error": "workspace_id 不存在"}), 404
|
||||
target_path = str(Path(target.get("path") or "").expanduser().resolve())
|
||||
write_host_workspace_debug(
|
||||
"status.select_host_workspace.target",
|
||||
workspace_id=workspace_id,
|
||||
target_path=target_path,
|
||||
current_workspace_id=current_id,
|
||||
)
|
||||
|
||||
if current_id == workspace_id:
|
||||
session['workspace_id'] = workspace_id
|
||||
try:
|
||||
container_handle = state.container_manager.ensure_container(
|
||||
"host",
|
||||
target_path,
|
||||
container_key="host",
|
||||
preferred_mode="host",
|
||||
)
|
||||
host_terminal = state.user_terminals.get("host")
|
||||
if host_terminal:
|
||||
host_terminal.update_project_path(target_path)
|
||||
host_terminal.update_container_session(container_handle)
|
||||
attach_user_broadcast(host_terminal, "host")
|
||||
host_terminal.username = "host"
|
||||
host_terminal.user_role = "admin"
|
||||
write_host_workspace_debug(
|
||||
"status.select_host_workspace.same_id_reapply",
|
||||
terminal_id=id(host_terminal),
|
||||
workspace_id=workspace_id,
|
||||
project_path=str(getattr(host_terminal, "project_path", "")),
|
||||
context_project_path=str(getattr(getattr(host_terminal, "context_manager", None), "project_path", "")),
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
return jsonify({
|
||||
"success": True,
|
||||
"data": {
|
||||
"current_workspace_id": workspace_id,
|
||||
"project_path": target_path,
|
||||
"default_workspace_id": catalog.get("default_workspace_id"),
|
||||
"reloaded": False,
|
||||
}
|
||||
})
|
||||
|
||||
try:
|
||||
from .tasks import task_manager
|
||||
|
||||
running = [
|
||||
rec for rec in task_manager.list_tasks("host")
|
||||
if rec.status in {"pending", "running", "cancel_requested"}
|
||||
]
|
||||
if running:
|
||||
return jsonify({
|
||||
"success": False,
|
||||
"error": "存在运行中的任务,请先停止后再切换工作区",
|
||||
}), 409
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
previous_workspace_id = session.get("workspace_id")
|
||||
session['host_workspace_id'] = workspace_id
|
||||
session['workspace_id'] = workspace_id
|
||||
|
||||
try:
|
||||
container_handle = state.container_manager.ensure_container(
|
||||
"host",
|
||||
target_path,
|
||||
container_key="host",
|
||||
preferred_mode="host",
|
||||
)
|
||||
except RuntimeError as exc:
|
||||
if current_id:
|
||||
session['host_workspace_id'] = current_id
|
||||
if previous_workspace_id is not None:
|
||||
session['workspace_id'] = previous_workspace_id
|
||||
elif current_id:
|
||||
session['workspace_id'] = current_id
|
||||
write_host_workspace_debug(
|
||||
"status.select_host_workspace.ensure_container_failed",
|
||||
workspace_id=workspace_id,
|
||||
target_path=target_path,
|
||||
error=str(exc),
|
||||
)
|
||||
return jsonify({"success": False, "error": str(exc)}), 503
|
||||
|
||||
host_terminal = state.user_terminals.get("host")
|
||||
if host_terminal:
|
||||
try:
|
||||
host_terminal.update_project_path(target_path)
|
||||
host_terminal.update_container_session(container_handle)
|
||||
attach_user_broadcast(host_terminal, "host")
|
||||
host_terminal.username = "host"
|
||||
host_terminal.user_role = "admin"
|
||||
write_host_workspace_debug(
|
||||
"status.select_host_workspace.updated_terminal",
|
||||
terminal_id=id(host_terminal),
|
||||
workspace_id=workspace_id,
|
||||
project_path=str(getattr(host_terminal, "project_path", "")),
|
||||
context_project_path=str(getattr(getattr(host_terminal, "context_manager", None), "project_path", "")),
|
||||
current_conversation_id=getattr(getattr(host_terminal, "context_manager", None), "current_conversation_id", None),
|
||||
)
|
||||
except Exception:
|
||||
try:
|
||||
if getattr(host_terminal, "terminal_manager", None):
|
||||
host_terminal.terminal_manager.close_all()
|
||||
except Exception:
|
||||
pass
|
||||
state.user_terminals.pop("host", None)
|
||||
write_host_workspace_debug(
|
||||
"status.select_host_workspace.drop_terminal_after_error",
|
||||
workspace_id=workspace_id,
|
||||
)
|
||||
|
||||
write_host_workspace_debug(
|
||||
"status.select_host_workspace.success",
|
||||
workspace_id=workspace_id,
|
||||
target_path=target_path,
|
||||
)
|
||||
return jsonify({
|
||||
"success": True,
|
||||
"data": {
|
||||
"current_workspace_id": workspace_id,
|
||||
"project_path": target_path,
|
||||
"default_workspace_id": catalog.get("default_workspace_id"),
|
||||
"reloaded": True,
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
@status_bp.route('/api/host/workspaces/create', methods=['GET', 'POST'])
|
||||
@api_login_required
|
||||
def create_host_workspace_api():
|
||||
if not _is_host_mode_request():
|
||||
return jsonify({"success": False, "error": "仅宿主机模式可用"}), 403
|
||||
|
||||
payload = request.get_json(silent=True) if request.method != "GET" else None
|
||||
workspace_path = (
|
||||
request.args.get("path")
|
||||
or (payload or {}).get("path")
|
||||
or ""
|
||||
).strip()
|
||||
label = (
|
||||
request.args.get("label")
|
||||
or (payload or {}).get("label")
|
||||
or ""
|
||||
).strip()
|
||||
set_default_raw = (
|
||||
request.args.get("set_default")
|
||||
or (payload or {}).get("set_default")
|
||||
or ""
|
||||
)
|
||||
set_default = str(set_default_raw).lower() in {"1", "true", "yes", "on"}
|
||||
|
||||
if not workspace_path:
|
||||
return jsonify({"success": False, "error": "缺少 path"}), 400
|
||||
|
||||
try:
|
||||
result = create_host_workspace(workspace_path, label=label, set_default=set_default)
|
||||
except Exception as exc:
|
||||
return jsonify({"success": False, "error": str(exc)}), 500
|
||||
|
||||
catalog = result.get("catalog") or {}
|
||||
workspaces = []
|
||||
current_id = (session.get("host_workspace_id") or "").strip()
|
||||
for item in catalog.get("workspaces") or []:
|
||||
workspaces.append({
|
||||
"workspace_id": item.get("workspace_id"),
|
||||
"label": item.get("label"),
|
||||
"path": item.get("path"),
|
||||
"is_current": item.get("workspace_id") == current_id,
|
||||
})
|
||||
|
||||
return jsonify({
|
||||
"success": True,
|
||||
"data": {
|
||||
"created": bool(result.get("created")),
|
||||
"workspace": result.get("workspace") or {},
|
||||
"source_path": catalog.get("source_path"),
|
||||
"default_workspace_id": catalog.get("default_workspace_id"),
|
||||
"current_workspace_id": current_id,
|
||||
"workspaces": workspaces,
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
@status_bp.route('/api/container-status')
|
||||
@api_login_required
|
||||
@with_terminal
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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
|
||||
};
|
||||
|
||||
@ -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();
|
||||
}
|
||||
|
||||
|
||||
@ -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,
|
||||
|
||||
211
static/src/components/overlay/HostWorkspaceCreateDialog.vue
Normal file
211
static/src/components/overlay/HostWorkspaceCreateDialog.vue
Normal 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>
|
||||
@ -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);
|
||||
};
|
||||
|
||||
@ -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 {
|
||||
|
||||
69
test/test_host_workspace_manager.py
Normal file
69
test/test_host_workspace_manager.py
Normal 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()
|
||||
@ -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,
|
||||
|
||||
37
utils/host_workspace_debug.py
Normal file
37
utils/host_workspace_debug.py
Normal 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)
|
||||
|
||||
Loading…
Reference in New Issue
Block a user