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
|
# 模式:docker / host
|
||||||
TERMINAL_SANDBOX_MODE=docker
|
TERMINAL_SANDBOX_MODE=docker
|
||||||
|
# 宿主机模式工作区配置(JSON 文件)
|
||||||
|
HOST_WORKSPACES_FILE=./config/host_workspaces.json
|
||||||
# 容器镜像及挂载路径
|
# 容器镜像及挂载路径
|
||||||
TERMINAL_SANDBOX_IMAGE=python:3.11-slim
|
TERMINAL_SANDBOX_IMAGE=python:3.11-slim
|
||||||
TERMINAL_SANDBOX_MOUNT_PATH=/workspace
|
TERMINAL_SANDBOX_MOUNT_PATH=/workspace
|
||||||
|
|||||||
3
.gitignore
vendored
3
.gitignore
vendored
@ -38,3 +38,6 @@ ax_probe_chrome.py
|
|||||||
# Ignore docs and runtime pid
|
# Ignore docs and runtime pid
|
||||||
webapp.pid
|
webapp.pid
|
||||||
doc/
|
doc/
|
||||||
|
|
||||||
|
# Host workspace config (local, use .example)
|
||||||
|
config/host_workspaces.json
|
||||||
|
|||||||
16
README.md
16
README.md
@ -124,7 +124,19 @@
|
|||||||
配置:
|
配置:
|
||||||
```bash
|
```bash
|
||||||
TERMINAL_SANDBOX_MODE=host
|
TERMINAL_SANDBOX_MODE=host
|
||||||
HOST_PROJECT_PATH=/path/to/project # 可选,指定项目路径
|
HOST_WORKSPACES_FILE=./config/host_workspaces.json
|
||||||
|
```
|
||||||
|
|
||||||
|
`HOST_WORKSPACES_FILE` 对应 JSON 示例:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"default_workspace_id": "default",
|
||||||
|
"workspaces": [
|
||||||
|
{ "workspace_id": "default", "label": "默认工作区", "path": "./project" },
|
||||||
|
{ "workspace_id": "my-repo", "label": "我的仓库", "path": "/Users/you/code/my-repo" }
|
||||||
|
]
|
||||||
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
#### Docker 容器模式 (Docker Mode)
|
#### Docker 容器模式 (Docker Mode)
|
||||||
@ -482,7 +494,7 @@ static/src/
|
|||||||
|
|
||||||
**宿主机模式**:
|
**宿主机模式**:
|
||||||
- `TERMINAL_SANDBOX_MODE=host`: 启用宿主机模式
|
- `TERMINAL_SANDBOX_MODE=host`: 启用宿主机模式
|
||||||
- `HOST_PROJECT_PATH`: 指定项目路径(可选)
|
- `HOST_WORKSPACES_FILE`: 宿主机工作区 JSON 配置文件路径
|
||||||
- `LINUX_SAFETY`: Linux 安全保护开关
|
- `LINUX_SAFETY`: Linux 安全保护开关
|
||||||
|
|
||||||
**Docker 模式**:
|
**Docker 模式**:
|
||||||
|
|||||||
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 = os.environ.get("DEFAULT_PROJECT_PATH", "./project")
|
||||||
# 当终端运行在宿主机模式时,可显式指定工作目录;未设置时回退到 DEFAULT_PROJECT_PATH
|
# 宿主机模式工作区配置文件(JSON)
|
||||||
|
HOST_WORKSPACES_FILE = os.environ.get("HOST_WORKSPACES_FILE", "./config/host_workspaces.json")
|
||||||
|
# 兼容旧配置:若仍有模块读取 HOST_PROJECT_PATH,保留该键(实际宿主机路径选择改由 JSON 管理)
|
||||||
HOST_PROJECT_PATH = os.environ.get("HOST_PROJECT_PATH", DEFAULT_PROJECT_PATH)
|
HOST_PROJECT_PATH = os.environ.get("HOST_PROJECT_PATH", DEFAULT_PROJECT_PATH)
|
||||||
PROMPTS_DIR = "./prompts"
|
PROMPTS_DIR = "./prompts"
|
||||||
DATA_DIR = "./data"
|
DATA_DIR = "./data"
|
||||||
@ -26,6 +28,7 @@ API_USAGE_FILE = f"{DATA_DIR}/api_usage.json"
|
|||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
"DEFAULT_PROJECT_PATH",
|
"DEFAULT_PROJECT_PATH",
|
||||||
|
"HOST_WORKSPACES_FILE",
|
||||||
"HOST_PROJECT_PATH",
|
"HOST_PROJECT_PATH",
|
||||||
"PROMPTS_DIR",
|
"PROMPTS_DIR",
|
||||||
"DATA_DIR",
|
"DATA_DIR",
|
||||||
|
|||||||
@ -57,6 +57,7 @@ except ImportError:
|
|||||||
from core.tool_config import TOOL_CATEGORIES
|
from core.tool_config import TOOL_CATEGORIES
|
||||||
from utils.api_client import DeepSeekClient
|
from utils.api_client import DeepSeekClient
|
||||||
from utils.context_manager import ContextManager
|
from utils.context_manager import ContextManager
|
||||||
|
from utils.host_workspace_debug import write_host_workspace_debug
|
||||||
from config.model_profiles import get_model_profile
|
from config.model_profiles import get_model_profile
|
||||||
|
|
||||||
from core.main_terminal_parts import (
|
from core.main_terminal_parts import (
|
||||||
@ -289,6 +290,123 @@ class MainTerminal(MainTerminalCommandMixin, MainTerminalContextMixin, MainTermi
|
|||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
def update_project_path(self, project_path: str):
|
||||||
|
"""更新当前终端绑定的工作目录(宿主机工作区切换场景)。"""
|
||||||
|
try:
|
||||||
|
resolved_path = str(Path(project_path).expanduser().resolve())
|
||||||
|
except Exception:
|
||||||
|
resolved_path = str(project_path)
|
||||||
|
|
||||||
|
try:
|
||||||
|
current_resolved = str(Path(self.project_path).expanduser().resolve())
|
||||||
|
except Exception:
|
||||||
|
current_resolved = str(self.project_path)
|
||||||
|
try:
|
||||||
|
current_context_resolved = str(
|
||||||
|
Path(getattr(getattr(self, "context_manager", None), "project_path", "")).expanduser().resolve()
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
current_context_resolved = str(
|
||||||
|
getattr(getattr(self, "context_manager", None), "project_path", "")
|
||||||
|
)
|
||||||
|
|
||||||
|
write_host_workspace_debug(
|
||||||
|
"terminal.update_project_path.begin",
|
||||||
|
terminal_id=id(self),
|
||||||
|
current_project_path=current_resolved,
|
||||||
|
target_project_path=resolved_path,
|
||||||
|
)
|
||||||
|
|
||||||
|
if resolved_path == current_resolved and current_context_resolved == resolved_path:
|
||||||
|
write_host_workspace_debug(
|
||||||
|
"terminal.update_project_path.skip_same_path",
|
||||||
|
terminal_id=id(self),
|
||||||
|
project_path=resolved_path,
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
|
self.project_path = resolved_path
|
||||||
|
|
||||||
|
# API 客户端
|
||||||
|
if getattr(self, "api_client", None):
|
||||||
|
try:
|
||||||
|
self.api_client.project_path = resolved_path
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
# 上下文管理器(影响 file tree / AGENTS.md 注入 / prompt)
|
||||||
|
if getattr(self, "context_manager", None):
|
||||||
|
try:
|
||||||
|
new_path = Path(resolved_path).resolve()
|
||||||
|
self.context_manager.project_path = new_path
|
||||||
|
self.context_manager.initial_project_path = new_path
|
||||||
|
self.context_manager.project_snapshot = None
|
||||||
|
self.context_manager._host_runtime_cache = None
|
||||||
|
metadata = getattr(self.context_manager, "conversation_metadata", None)
|
||||||
|
if isinstance(metadata, dict):
|
||||||
|
metadata["project_path"] = str(new_path)
|
||||||
|
try:
|
||||||
|
workspace_root = Path(
|
||||||
|
getattr(self.context_manager.conversation_manager, "workspace_root", "")
|
||||||
|
).resolve()
|
||||||
|
metadata["project_relative_path"] = new_path.relative_to(workspace_root).as_posix()
|
||||||
|
except Exception:
|
||||||
|
metadata["project_relative_path"] = None
|
||||||
|
# 清理旧工作区文件树快照,避免 prompt 继续展示切换前内容
|
||||||
|
metadata.pop("project_file_tree", None)
|
||||||
|
metadata.pop("project_statistics", None)
|
||||||
|
metadata.pop("project_snapshot_at", None)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
# 文件/终端操作器
|
||||||
|
if getattr(self, "file_manager", None):
|
||||||
|
try:
|
||||||
|
self.file_manager.project_path = Path(resolved_path).resolve()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
if getattr(self, "terminal_ops", None):
|
||||||
|
try:
|
||||||
|
self.terminal_ops.project_path = Path(resolved_path).resolve()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
# 关闭已有实时终端会话,确保后续 pwd/cwd 以新工作区启动
|
||||||
|
if getattr(self, "terminal_manager", None):
|
||||||
|
try:
|
||||||
|
self.terminal_manager.project_path = Path(resolved_path).resolve()
|
||||||
|
self.terminal_manager.close_all()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
# 其它依赖 project_path 的模块
|
||||||
|
if getattr(self, "ocr_client", None):
|
||||||
|
try:
|
||||||
|
self.ocr_client.project_path = Path(resolved_path).resolve()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
if getattr(self, "sub_agent_manager", None):
|
||||||
|
try:
|
||||||
|
self.sub_agent_manager.project_path = Path(resolved_path).resolve()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
if getattr(self, "background_command_manager", None):
|
||||||
|
try:
|
||||||
|
self.background_command_manager.project_path = Path(resolved_path).resolve()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
# 强制下次请求重新同步 skills(含 AGENTS.md 场景)
|
||||||
|
self._skills_synced_project_path = None
|
||||||
|
|
||||||
|
write_host_workspace_debug(
|
||||||
|
"terminal.update_project_path.done",
|
||||||
|
terminal_id=id(self),
|
||||||
|
project_path=self.project_path,
|
||||||
|
context_project_path=str(getattr(getattr(self, "context_manager", None), "project_path", "")),
|
||||||
|
current_conversation_id=getattr(getattr(self, "context_manager", None), "current_conversation_id", None),
|
||||||
|
)
|
||||||
|
|
||||||
def _ensure_conversation(self):
|
def _ensure_conversation(self):
|
||||||
"""确保CLI模式下存在可用的对话ID"""
|
"""确保CLI模式下存在可用的对话ID"""
|
||||||
if self.context_manager.current_conversation_id:
|
if self.context_manager.current_conversation_id:
|
||||||
|
|||||||
@ -73,6 +73,7 @@ from modules.container_monitor import collect_stats, inspect_state
|
|||||||
from core.tool_config import TOOL_CATEGORIES
|
from core.tool_config import TOOL_CATEGORIES
|
||||||
from utils.api_client import DeepSeekClient
|
from utils.api_client import DeepSeekClient
|
||||||
from utils.context_manager import ContextManager, AUTO_SHALLOW_PLACEHOLDER
|
from utils.context_manager import ContextManager, AUTO_SHALLOW_PLACEHOLDER
|
||||||
|
from utils.host_workspace_debug import write_host_workspace_debug
|
||||||
from utils.tool_result_formatter import format_tool_result_for_context
|
from utils.tool_result_formatter import format_tool_result_for_context
|
||||||
from utils.logger import setup_logger
|
from utils.logger import setup_logger
|
||||||
from config.model_profiles import (
|
from config.model_profiles import (
|
||||||
@ -177,6 +178,19 @@ class MainTerminalContextMixin:
|
|||||||
|
|
||||||
def build_messages(self, context: Dict, user_input: str) -> List[Dict]:
|
def build_messages(self, context: Dict, user_input: str) -> List[Dict]:
|
||||||
"""构建消息列表(添加终端内容注入)"""
|
"""构建消息列表(添加终端内容注入)"""
|
||||||
|
try:
|
||||||
|
file_tree_preview = (context.get("project_info", {}).get("file_tree") or "").splitlines()
|
||||||
|
write_host_workspace_debug(
|
||||||
|
"main_terminal.build_messages.context_snapshot",
|
||||||
|
terminal_id=id(self),
|
||||||
|
terminal_project_path=str(getattr(self, "project_path", "")),
|
||||||
|
context_project_path=str(getattr(getattr(self, "context_manager", None), "project_path", "")),
|
||||||
|
project_info_path=str(context.get("project_info", {}).get("path", "")),
|
||||||
|
file_tree_first_line=file_tree_preview[0] if file_tree_preview else "",
|
||||||
|
current_conversation_id=getattr(getattr(self, "context_manager", None), "current_conversation_id", None),
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
# 加载系统提示(Qwen3.5 使用专用提示)
|
# 加载系统提示(Qwen3.5 使用专用提示)
|
||||||
current_model = getattr(self, "model_key", "kimi")
|
current_model = getattr(self, "model_key", "kimi")
|
||||||
prompt_name = "main_system_qwenvl" if (model_supports_image(current_model) or model_supports_video(current_model)) else "main_system"
|
prompt_name = "main_system_qwenvl" if (model_supports_image(current_model) or model_supports_video(current_model)) else "main_system"
|
||||||
|
|||||||
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 re
|
||||||
import shutil
|
import shutil
|
||||||
|
import threading
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Dict, List, Optional, Sequence
|
from typing import Dict, List, Optional, Sequence
|
||||||
|
|
||||||
@ -17,6 +18,18 @@ logger = setup_logger(__name__)
|
|||||||
|
|
||||||
SKILL_FILE_NAME = "SKILL.md"
|
SKILL_FILE_NAME = "SKILL.md"
|
||||||
SKILL_ID_PATTERN = re.compile(r"^[A-Za-z0-9][A-Za-z0-9_-]*$")
|
SKILL_ID_PATTERN = re.compile(r"^[A-Za-z0-9][A-Za-z0-9_-]*$")
|
||||||
|
_SYNC_LOCK_GUARD = threading.Lock()
|
||||||
|
_SYNC_LOCKS: Dict[str, threading.Lock] = {}
|
||||||
|
|
||||||
|
|
||||||
|
def _get_sync_lock(skills_dir: Path) -> threading.Lock:
|
||||||
|
key = str(skills_dir.resolve())
|
||||||
|
with _SYNC_LOCK_GUARD:
|
||||||
|
lock = _SYNC_LOCKS.get(key)
|
||||||
|
if lock is None:
|
||||||
|
lock = threading.Lock()
|
||||||
|
_SYNC_LOCKS[key] = lock
|
||||||
|
return lock
|
||||||
|
|
||||||
|
|
||||||
def ensure_agent_skills_dir(base_dir: Optional[str] = None) -> Path:
|
def ensure_agent_skills_dir(base_dir: Optional[str] = None) -> Path:
|
||||||
@ -190,7 +203,9 @@ def sync_workspace_skills(
|
|||||||
catalog = get_skills_catalog(base_dir)
|
catalog = get_skills_catalog(base_dir)
|
||||||
resolved = resolve_enabled_skills(enabled_skills, catalog)
|
resolved = resolve_enabled_skills(enabled_skills, catalog)
|
||||||
|
|
||||||
|
lock = _get_sync_lock(skills_dir)
|
||||||
try:
|
try:
|
||||||
|
with lock:
|
||||||
if skills_dir.exists():
|
if skills_dir.exists():
|
||||||
shutil.rmtree(skills_dir, ignore_errors=True)
|
shutil.rmtree(skills_dir, ignore_errors=True)
|
||||||
skills_dir.mkdir(parents=True, exist_ok=True)
|
skills_dir.mkdir(parents=True, exist_ok=True)
|
||||||
@ -199,7 +214,10 @@ def sync_workspace_skills(
|
|||||||
src = global_root / skill_id
|
src = global_root / skill_id
|
||||||
if not src.exists() or not src.is_dir():
|
if not src.exists() or not src.is_dir():
|
||||||
continue
|
continue
|
||||||
shutil.copytree(src, skills_dir / skill_id)
|
dst = skills_dir / skill_id
|
||||||
|
if dst.exists() and not dst.is_dir():
|
||||||
|
dst.unlink(missing_ok=True)
|
||||||
|
shutil.copytree(src, dst, dirs_exist_ok=True)
|
||||||
return {
|
return {
|
||||||
"success": True,
|
"success": True,
|
||||||
"copied": list(resolved),
|
"copied": list(resolved),
|
||||||
|
|||||||
@ -6,10 +6,10 @@ from datetime import datetime
|
|||||||
from flask import Blueprint, request, jsonify, session, redirect, send_from_directory, abort, current_app, make_response
|
from flask import Blueprint, request, jsonify, session, redirect, send_from_directory, abort, current_app, make_response
|
||||||
|
|
||||||
from modules.personalization_manager import load_personalization_config
|
from modules.personalization_manager import load_personalization_config
|
||||||
|
from modules.host_workspace_manager import resolve_host_workspace
|
||||||
from modules.user_manager import UserWorkspace
|
from modules.user_manager import UserWorkspace
|
||||||
from config import (
|
from config import (
|
||||||
TERMINAL_SANDBOX_MODE,
|
TERMINAL_SANDBOX_MODE,
|
||||||
HOST_PROJECT_PATH,
|
|
||||||
DATA_DIR,
|
DATA_DIR,
|
||||||
LOGS_DIR,
|
LOGS_DIR,
|
||||||
UPLOAD_QUARANTINE_SUBDIR,
|
UPLOAD_QUARANTINE_SUBDIR,
|
||||||
@ -210,7 +210,9 @@ def host_login():
|
|||||||
if not state.container_manager.has_capacity("host"):
|
if not state.container_manager.has_capacity("host"):
|
||||||
return jsonify({"success": False, "error": "资源繁忙,请稍后再试"}), 503
|
return jsonify({"success": False, "error": "资源繁忙,请稍后再试"}), 503
|
||||||
|
|
||||||
host_path = Path(HOST_PROJECT_PATH).expanduser().resolve()
|
_, host_workspace = resolve_host_workspace()
|
||||||
|
host_workspace_id = host_workspace.get("workspace_id") or "default"
|
||||||
|
host_path = Path(host_workspace.get("path") or "").expanduser().resolve()
|
||||||
host_path.mkdir(parents=True, exist_ok=True)
|
host_path.mkdir(parents=True, exist_ok=True)
|
||||||
data_dir = Path(DATA_DIR).expanduser().resolve()
|
data_dir = Path(DATA_DIR).expanduser().resolve()
|
||||||
data_dir.mkdir(parents=True, exist_ok=True)
|
data_dir.mkdir(parents=True, exist_ok=True)
|
||||||
@ -229,6 +231,8 @@ def host_login():
|
|||||||
session['username'] = 'host'
|
session['username'] = 'host'
|
||||||
session['role'] = 'admin'
|
session['role'] = 'admin'
|
||||||
session['host_mode'] = True
|
session['host_mode'] = True
|
||||||
|
session['host_workspace_id'] = host_workspace_id
|
||||||
|
session['workspace_id'] = host_workspace_id
|
||||||
default_thinking = current_app.config.get('DEFAULT_THINKING_MODE', False)
|
default_thinking = current_app.config.get('DEFAULT_THINKING_MODE', False)
|
||||||
session['thinking_mode'] = default_thinking
|
session['thinking_mode'] = default_thinking
|
||||||
session['run_mode'] = current_app.config.get('DEFAULT_RUN_MODE', "deep" if default_thinking else "fast")
|
session['run_mode'] = current_app.config.get('DEFAULT_RUN_MODE', "deep" if default_thinking else "fast")
|
||||||
|
|||||||
@ -277,10 +277,13 @@ async def _dispatch_completion_user_notice(
|
|||||||
try:
|
try:
|
||||||
from .tasks import task_manager
|
from .tasks import task_manager
|
||||||
workspace_id = getattr(workspace, "workspace_id", None) or "default"
|
workspace_id = getattr(workspace, "workspace_id", None) or "default"
|
||||||
|
host_mode = bool(getattr(workspace, "username", None) == "host")
|
||||||
session_data = {
|
session_data = {
|
||||||
"username": username,
|
"username": username,
|
||||||
"role": getattr(web_terminal, "user_role", "user"),
|
"role": getattr(web_terminal, "user_role", "user"),
|
||||||
"is_api_user": getattr(web_terminal, "user_role", "") == "api",
|
"is_api_user": getattr(web_terminal, "user_role", "") == "api",
|
||||||
|
"host_mode": host_mode,
|
||||||
|
"host_workspace_id": workspace_id if host_mode else None,
|
||||||
"workspace_id": workspace_id,
|
"workspace_id": workspace_id,
|
||||||
"run_mode": getattr(web_terminal, "run_mode", None),
|
"run_mode": getattr(web_terminal, "run_mode", None),
|
||||||
"thinking_mode": getattr(web_terminal, "thinking_mode", None),
|
"thinking_mode": getattr(web_terminal, "thinking_mode", None),
|
||||||
@ -563,10 +566,13 @@ async def poll_sub_agent_completion(*, web_terminal, workspace, conversation_id,
|
|||||||
})
|
})
|
||||||
from .tasks import task_manager
|
from .tasks import task_manager
|
||||||
workspace_id = getattr(workspace, "workspace_id", None) or "default"
|
workspace_id = getattr(workspace, "workspace_id", None) or "default"
|
||||||
|
host_mode = bool(getattr(workspace, "username", None) == "host")
|
||||||
session_data = {
|
session_data = {
|
||||||
"username": username,
|
"username": username,
|
||||||
"role": getattr(web_terminal, "user_role", "user"),
|
"role": getattr(web_terminal, "user_role", "user"),
|
||||||
"is_api_user": getattr(web_terminal, "user_role", "") == "api",
|
"is_api_user": getattr(web_terminal, "user_role", "") == "api",
|
||||||
|
"host_mode": host_mode,
|
||||||
|
"host_workspace_id": workspace_id if host_mode else None,
|
||||||
"workspace_id": workspace_id,
|
"workspace_id": workspace_id,
|
||||||
"run_mode": getattr(web_terminal, "run_mode", None),
|
"run_mode": getattr(web_terminal, "run_mode", None),
|
||||||
"thinking_mode": getattr(web_terminal, "thinking_mode", None),
|
"thinking_mode": getattr(web_terminal, "thinking_mode", None),
|
||||||
|
|||||||
@ -9,11 +9,12 @@ from modules.gui_file_manager import GuiFileManager
|
|||||||
from modules.upload_security import UploadQuarantineManager, UploadSecurityError
|
from modules.upload_security import UploadQuarantineManager, UploadSecurityError
|
||||||
from modules.personalization_manager import load_personalization_config
|
from modules.personalization_manager import load_personalization_config
|
||||||
from modules.skills_manager import sync_workspace_skills
|
from modules.skills_manager import sync_workspace_skills
|
||||||
|
from modules.host_workspace_manager import resolve_host_workspace
|
||||||
|
from utils.host_workspace_debug import write_host_workspace_debug
|
||||||
import json
|
import json
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from modules.usage_tracker import UsageTracker
|
from modules.usage_tracker import UsageTracker
|
||||||
from config import (
|
from config import (
|
||||||
HOST_PROJECT_PATH,
|
|
||||||
DATA_DIR,
|
DATA_DIR,
|
||||||
LOGS_DIR,
|
LOGS_DIR,
|
||||||
TERMINAL_SANDBOX_MODE,
|
TERMINAL_SANDBOX_MODE,
|
||||||
@ -81,11 +82,20 @@ def get_user_resources(username: Optional[str] = None, workspace_id: Optional[st
|
|||||||
if not username:
|
if not username:
|
||||||
return None, None
|
return None, None
|
||||||
|
|
||||||
# 宿主机免登录模式:使用 HOST_PROJECT_PATH 直接进入,不创建 /users/<user>/project
|
# 宿主机免登录模式:根据 host_workspaces.json 选择路径,不创建 /users/<user>/project
|
||||||
host_mode_session = bool(session.get("host_mode")) if has_request_context() else False
|
host_mode_session = bool(session.get("host_mode")) if has_request_context() else False
|
||||||
sandbox_is_host = (TERMINAL_SANDBOX_MODE or "host").lower() == "host"
|
sandbox_is_host = (TERMINAL_SANDBOX_MODE or "host").lower() == "host"
|
||||||
if host_mode_session and sandbox_is_host:
|
if host_mode_session and sandbox_is_host:
|
||||||
project_path = Path(HOST_PROJECT_PATH).expanduser().resolve()
|
selected_workspace_id = session.get("host_workspace_id") if has_request_context() else None
|
||||||
|
_, host_workspace = resolve_host_workspace(selected_workspace_id)
|
||||||
|
project_path = Path(host_workspace.get("path") or "").expanduser().resolve()
|
||||||
|
write_host_workspace_debug(
|
||||||
|
"context.get_user_resources.host.selected_workspace",
|
||||||
|
selected_workspace_id=selected_workspace_id,
|
||||||
|
resolved_workspace_id=host_workspace.get("workspace_id"),
|
||||||
|
project_path=str(project_path),
|
||||||
|
username=username,
|
||||||
|
)
|
||||||
project_path.mkdir(parents=True, exist_ok=True)
|
project_path.mkdir(parents=True, exist_ok=True)
|
||||||
data_dir = Path(DATA_DIR).expanduser().resolve()
|
data_dir = Path(DATA_DIR).expanduser().resolve()
|
||||||
data_dir.mkdir(parents=True, exist_ok=True)
|
data_dir.mkdir(parents=True, exist_ok=True)
|
||||||
@ -110,12 +120,65 @@ def get_user_resources(username: Optional[str] = None, workspace_id: Optional[st
|
|||||||
quarantine_dir=quarantine_root,
|
quarantine_dir=quarantine_root,
|
||||||
)
|
)
|
||||||
if not hasattr(workspace, "workspace_id"):
|
if not hasattr(workspace, "workspace_id"):
|
||||||
workspace.workspace_id = "host"
|
workspace.workspace_id = host_workspace.get("workspace_id") or "default"
|
||||||
|
|
||||||
term_key = "host"
|
term_key = "host"
|
||||||
container_handle = state.container_manager.ensure_container("host", str(project_path), container_key=term_key, preferred_mode="host")
|
container_handle = state.container_manager.ensure_container("host", str(project_path), container_key=term_key, preferred_mode="host")
|
||||||
usage_tracker = None # 宿主机模式不计配额
|
usage_tracker = None # 宿主机模式不计配额
|
||||||
terminal = state.user_terminals.get(term_key)
|
terminal = state.user_terminals.get(term_key)
|
||||||
|
target_project_path = str(project_path)
|
||||||
|
if terminal:
|
||||||
|
should_recreate_terminal = False
|
||||||
|
try:
|
||||||
|
current_project_path = str(Path(getattr(terminal, "project_path", "")).expanduser().resolve())
|
||||||
|
except Exception:
|
||||||
|
current_project_path = str(getattr(terminal, "project_path", ""))
|
||||||
|
try:
|
||||||
|
current_context_project_path = str(
|
||||||
|
Path(getattr(getattr(terminal, "context_manager", None), "project_path", "")).expanduser().resolve()
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
current_context_project_path = str(
|
||||||
|
getattr(getattr(terminal, "context_manager", None), "project_path", "")
|
||||||
|
)
|
||||||
|
if current_project_path != target_project_path or current_context_project_path != target_project_path:
|
||||||
|
write_host_workspace_debug(
|
||||||
|
"context.get_user_resources.host.path_mismatch",
|
||||||
|
terminal_id=id(terminal),
|
||||||
|
current_project_path=current_project_path,
|
||||||
|
current_context_project_path=current_context_project_path,
|
||||||
|
target_project_path=target_project_path,
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
if hasattr(terminal, "update_project_path"):
|
||||||
|
terminal.update_project_path(target_project_path)
|
||||||
|
else:
|
||||||
|
should_recreate_terminal = True
|
||||||
|
except Exception as exc:
|
||||||
|
debug_log(f"[HostWorkspace] update_project_path 失败,回退重建终端: {exc}")
|
||||||
|
should_recreate_terminal = True
|
||||||
|
|
||||||
|
if not should_recreate_terminal:
|
||||||
|
try:
|
||||||
|
updated_project_path = str(Path(getattr(terminal, "project_path", "")).expanduser().resolve())
|
||||||
|
except Exception:
|
||||||
|
updated_project_path = str(getattr(terminal, "project_path", ""))
|
||||||
|
if updated_project_path != target_project_path:
|
||||||
|
should_recreate_terminal = True
|
||||||
|
|
||||||
|
if should_recreate_terminal:
|
||||||
|
write_host_workspace_debug(
|
||||||
|
"context.get_user_resources.host.recreate_terminal",
|
||||||
|
terminal_id=id(terminal),
|
||||||
|
target_project_path=target_project_path,
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
if getattr(terminal, "terminal_manager", None):
|
||||||
|
terminal.terminal_manager.close_all()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
state.user_terminals.pop(term_key, None)
|
||||||
|
terminal = None
|
||||||
if not terminal:
|
if not terminal:
|
||||||
run_mode = session.get('run_mode') if has_request_context() else None
|
run_mode = session.get('run_mode') if has_request_context() else None
|
||||||
thinking_mode_flag = session.get('thinking_mode') if has_request_context() else None
|
thinking_mode_flag = session.get('thinking_mode') if has_request_context() else None
|
||||||
@ -142,6 +205,7 @@ def get_user_resources(username: Optional[str] = None, workspace_id: Optional[st
|
|||||||
session['run_mode'] = terminal.run_mode
|
session['run_mode'] = terminal.run_mode
|
||||||
session['thinking_mode'] = terminal.thinking_mode
|
session['thinking_mode'] = terminal.thinking_mode
|
||||||
session['workspace_id'] = getattr(workspace, "workspace_id", None)
|
session['workspace_id'] = getattr(workspace, "workspace_id", None)
|
||||||
|
session['host_workspace_id'] = getattr(workspace, "workspace_id", None)
|
||||||
else:
|
else:
|
||||||
terminal.update_container_session(container_handle)
|
terminal.update_container_session(container_handle)
|
||||||
attach_user_broadcast(terminal, "host")
|
attach_user_broadcast(terminal, "host")
|
||||||
@ -149,6 +213,7 @@ def get_user_resources(username: Optional[str] = None, workspace_id: Optional[st
|
|||||||
terminal.user_role = "admin"
|
terminal.user_role = "admin"
|
||||||
if has_request_context():
|
if has_request_context():
|
||||||
session['workspace_id'] = getattr(workspace, "workspace_id", None)
|
session['workspace_id'] = getattr(workspace, "workspace_id", None)
|
||||||
|
session['host_workspace_id'] = getattr(workspace, "workspace_id", None)
|
||||||
|
|
||||||
# 宿主机模式同样需要应用管理员策略(否则前端工具菜单会退化成静态基础分类)
|
# 宿主机模式同样需要应用管理员策略(否则前端工具菜单会退化成静态基础分类)
|
||||||
try:
|
try:
|
||||||
@ -191,6 +256,15 @@ def get_user_resources(username: Optional[str] = None, workspace_id: Optional[st
|
|||||||
debug_log(f"[admin_policy][host_mode] 应用失败: {exc}")
|
debug_log(f"[admin_policy][host_mode] 应用失败: {exc}")
|
||||||
|
|
||||||
_ensure_workspace_skills_synced(terminal, workspace)
|
_ensure_workspace_skills_synced(terminal, workspace)
|
||||||
|
write_host_workspace_debug(
|
||||||
|
"context.get_user_resources.host.return",
|
||||||
|
terminal_id=id(terminal),
|
||||||
|
terminal_project_path=str(getattr(terminal, "project_path", "")),
|
||||||
|
context_project_path=str(getattr(getattr(terminal, "context_manager", None), "project_path", "")),
|
||||||
|
workspace_project_path=str(getattr(workspace, "project_path", "")),
|
||||||
|
workspace_id=getattr(workspace, "workspace_id", None),
|
||||||
|
current_conversation_id=getattr(getattr(terminal, "context_manager", None), "current_conversation_id", None),
|
||||||
|
)
|
||||||
return terminal, workspace
|
return terminal, workspace
|
||||||
|
|
||||||
is_api_user = bool(session.get("is_api_user")) if has_request_context() else False
|
is_api_user = bool(session.get("is_api_user")) if has_request_context() else False
|
||||||
@ -449,6 +523,16 @@ def ensure_conversation_loaded(
|
|||||||
load_result = terminal.load_conversation(conversation_id)
|
load_result = terminal.load_conversation(conversation_id)
|
||||||
if not load_result.get("success"):
|
if not load_result.get("success"):
|
||||||
raise RuntimeError(load_result.get("message", "对话加载失败"))
|
raise RuntimeError(load_result.get("message", "对话加载失败"))
|
||||||
|
write_host_workspace_debug(
|
||||||
|
"context.ensure_conversation_loaded.after_load",
|
||||||
|
terminal_id=id(terminal),
|
||||||
|
conversation_id=conversation_id,
|
||||||
|
terminal_project_path=str(getattr(terminal, "project_path", "")),
|
||||||
|
context_project_path=str(getattr(getattr(terminal, "context_manager", None), "project_path", "")),
|
||||||
|
metadata_project_path=(
|
||||||
|
getattr(getattr(terminal, "context_manager", None), "conversation_metadata", {}) or {}
|
||||||
|
).get("project_path"),
|
||||||
|
)
|
||||||
try:
|
try:
|
||||||
conv_data = terminal.context_manager.conversation_manager.load_conversation(conversation_id) or {}
|
conv_data = terminal.context_manager.conversation_manager.load_conversation(conversation_id) or {}
|
||||||
meta = conv_data.get("metadata", {}) or {}
|
meta = conv_data.get("metadata", {}) or {}
|
||||||
@ -469,6 +553,25 @@ def ensure_conversation_loaded(
|
|||||||
session['model_key'] = getattr(terminal, "model_key", None)
|
session['model_key'] = getattr(terminal, "model_key", None)
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
|
if workspace is not None:
|
||||||
|
try:
|
||||||
|
workspace_project_path = str(Path(workspace.project_path).expanduser().resolve())
|
||||||
|
terminal.update_project_path(workspace_project_path)
|
||||||
|
write_host_workspace_debug(
|
||||||
|
"context.ensure_conversation_loaded.reapply_workspace_path",
|
||||||
|
terminal_id=id(terminal),
|
||||||
|
conversation_id=conversation_id,
|
||||||
|
workspace_project_path=workspace_project_path,
|
||||||
|
terminal_project_path=str(getattr(terminal, "project_path", "")),
|
||||||
|
context_project_path=str(getattr(getattr(terminal, "context_manager", None), "project_path", "")),
|
||||||
|
)
|
||||||
|
except Exception as exc:
|
||||||
|
write_host_workspace_debug(
|
||||||
|
"context.ensure_conversation_loaded.reapply_workspace_path_failed",
|
||||||
|
terminal_id=id(terminal),
|
||||||
|
conversation_id=conversation_id,
|
||||||
|
error=str(exc),
|
||||||
|
)
|
||||||
# 应用对话级自定义 prompt / personalization(仅 API)。
|
# 应用对话级自定义 prompt / personalization(仅 API)。
|
||||||
# 注意:ensure_conversation_loaded 在 WebSocket/后台任务等多处复用,有些调用点拿不到 workspace;
|
# 注意:ensure_conversation_loaded 在 WebSocket/后台任务等多处复用,有些调用点拿不到 workspace;
|
||||||
# 因此这里允许 workspace 为空(仅跳过 override,不影响正常对话加载)。
|
# 因此这里允许 workspace 为空(仅跳过 override,不影响正常对话加载)。
|
||||||
|
|||||||
260
server/status.py
260
server/status.py
@ -2,10 +2,10 @@ from __future__ import annotations
|
|||||||
import time
|
import time
|
||||||
import re
|
import re
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from flask import Blueprint, jsonify, request, send_file
|
from flask import Blueprint, jsonify, request, send_file, session
|
||||||
|
|
||||||
from .auth_helpers import api_login_required, resolve_admin_policy
|
from .auth_helpers import api_login_required, resolve_admin_policy
|
||||||
from .context import with_terminal
|
from .context import with_terminal, attach_user_broadcast
|
||||||
from .state import (
|
from .state import (
|
||||||
PROJECT_STORAGE_CACHE,
|
PROJECT_STORAGE_CACHE,
|
||||||
PROJECT_STORAGE_CACHE_TTL_SECONDS,
|
PROJECT_STORAGE_CACHE_TTL_SECONDS,
|
||||||
@ -13,7 +13,14 @@ from .state import (
|
|||||||
container_manager,
|
container_manager,
|
||||||
user_manager,
|
user_manager,
|
||||||
)
|
)
|
||||||
from config import AGENT_VERSION
|
from config import AGENT_VERSION, TERMINAL_SANDBOX_MODE
|
||||||
|
from modules.host_workspace_manager import (
|
||||||
|
create_host_workspace,
|
||||||
|
load_host_workspace_catalog,
|
||||||
|
resolve_host_workspace,
|
||||||
|
)
|
||||||
|
from utils.host_workspace_debug import write_host_workspace_debug
|
||||||
|
from . import state
|
||||||
|
|
||||||
status_bp = Blueprint('status', __name__)
|
status_bp = Blueprint('status', __name__)
|
||||||
|
|
||||||
@ -54,6 +61,10 @@ def _load_app_changelog(project_root: Path) -> str:
|
|||||||
return ""
|
return ""
|
||||||
|
|
||||||
|
|
||||||
|
def _is_host_mode_request() -> bool:
|
||||||
|
return bool(session.get("host_mode")) and (TERMINAL_SANDBOX_MODE or "").lower() == "host"
|
||||||
|
|
||||||
|
|
||||||
@status_bp.route('/api/status')
|
@status_bp.route('/api/status')
|
||||||
@api_login_required
|
@api_login_required
|
||||||
@with_terminal
|
@with_terminal
|
||||||
@ -101,6 +112,249 @@ def get_status(terminal, workspace, username):
|
|||||||
return jsonify(status)
|
return jsonify(status)
|
||||||
|
|
||||||
|
|
||||||
|
@status_bp.route('/api/host/workspaces')
|
||||||
|
@api_login_required
|
||||||
|
def list_host_workspaces():
|
||||||
|
if not _is_host_mode_request():
|
||||||
|
return jsonify({"success": False, "error": "仅宿主机模式可用"}), 403
|
||||||
|
|
||||||
|
catalog, current = resolve_host_workspace(session.get("host_workspace_id"))
|
||||||
|
current_id = current.get("workspace_id")
|
||||||
|
session['host_workspace_id'] = current_id
|
||||||
|
session['workspace_id'] = current_id
|
||||||
|
|
||||||
|
workspaces = []
|
||||||
|
for item in catalog.get("workspaces") or []:
|
||||||
|
workspaces.append({
|
||||||
|
"workspace_id": item.get("workspace_id"),
|
||||||
|
"label": item.get("label"),
|
||||||
|
"path": item.get("path"),
|
||||||
|
"is_current": item.get("workspace_id") == current_id,
|
||||||
|
})
|
||||||
|
|
||||||
|
return jsonify({
|
||||||
|
"success": True,
|
||||||
|
"data": {
|
||||||
|
"source_path": catalog.get("source_path"),
|
||||||
|
"default_workspace_id": catalog.get("default_workspace_id"),
|
||||||
|
"current_workspace_id": current_id,
|
||||||
|
"workspaces": workspaces,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
@status_bp.route('/api/host/workspaces/select', methods=['GET', 'POST'])
|
||||||
|
@api_login_required
|
||||||
|
def select_host_workspace():
|
||||||
|
if not _is_host_mode_request():
|
||||||
|
return jsonify({"success": False, "error": "仅宿主机模式可用"}), 403
|
||||||
|
|
||||||
|
payload = request.get_json(silent=True) if request.method != "GET" else None
|
||||||
|
workspace_id = (
|
||||||
|
request.args.get("workspace_id")
|
||||||
|
or (payload or {}).get("workspace_id")
|
||||||
|
or ""
|
||||||
|
).strip()
|
||||||
|
if not workspace_id:
|
||||||
|
return jsonify({"success": False, "error": "缺少 workspace_id"}), 400
|
||||||
|
write_host_workspace_debug(
|
||||||
|
"status.select_host_workspace.request",
|
||||||
|
workspace_id=workspace_id,
|
||||||
|
current_session_workspace_id=session.get("host_workspace_id"),
|
||||||
|
)
|
||||||
|
|
||||||
|
catalog = load_host_workspace_catalog()
|
||||||
|
current_id = (session.get("host_workspace_id") or "").strip()
|
||||||
|
target = next(
|
||||||
|
(item for item in (catalog.get("workspaces") or []) if item.get("workspace_id") == workspace_id),
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
if not target:
|
||||||
|
return jsonify({"success": False, "error": "workspace_id 不存在"}), 404
|
||||||
|
target_path = str(Path(target.get("path") or "").expanduser().resolve())
|
||||||
|
write_host_workspace_debug(
|
||||||
|
"status.select_host_workspace.target",
|
||||||
|
workspace_id=workspace_id,
|
||||||
|
target_path=target_path,
|
||||||
|
current_workspace_id=current_id,
|
||||||
|
)
|
||||||
|
|
||||||
|
if current_id == workspace_id:
|
||||||
|
session['workspace_id'] = workspace_id
|
||||||
|
try:
|
||||||
|
container_handle = state.container_manager.ensure_container(
|
||||||
|
"host",
|
||||||
|
target_path,
|
||||||
|
container_key="host",
|
||||||
|
preferred_mode="host",
|
||||||
|
)
|
||||||
|
host_terminal = state.user_terminals.get("host")
|
||||||
|
if host_terminal:
|
||||||
|
host_terminal.update_project_path(target_path)
|
||||||
|
host_terminal.update_container_session(container_handle)
|
||||||
|
attach_user_broadcast(host_terminal, "host")
|
||||||
|
host_terminal.username = "host"
|
||||||
|
host_terminal.user_role = "admin"
|
||||||
|
write_host_workspace_debug(
|
||||||
|
"status.select_host_workspace.same_id_reapply",
|
||||||
|
terminal_id=id(host_terminal),
|
||||||
|
workspace_id=workspace_id,
|
||||||
|
project_path=str(getattr(host_terminal, "project_path", "")),
|
||||||
|
context_project_path=str(getattr(getattr(host_terminal, "context_manager", None), "project_path", "")),
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
return jsonify({
|
||||||
|
"success": True,
|
||||||
|
"data": {
|
||||||
|
"current_workspace_id": workspace_id,
|
||||||
|
"project_path": target_path,
|
||||||
|
"default_workspace_id": catalog.get("default_workspace_id"),
|
||||||
|
"reloaded": False,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
try:
|
||||||
|
from .tasks import task_manager
|
||||||
|
|
||||||
|
running = [
|
||||||
|
rec for rec in task_manager.list_tasks("host")
|
||||||
|
if rec.status in {"pending", "running", "cancel_requested"}
|
||||||
|
]
|
||||||
|
if running:
|
||||||
|
return jsonify({
|
||||||
|
"success": False,
|
||||||
|
"error": "存在运行中的任务,请先停止后再切换工作区",
|
||||||
|
}), 409
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
previous_workspace_id = session.get("workspace_id")
|
||||||
|
session['host_workspace_id'] = workspace_id
|
||||||
|
session['workspace_id'] = workspace_id
|
||||||
|
|
||||||
|
try:
|
||||||
|
container_handle = state.container_manager.ensure_container(
|
||||||
|
"host",
|
||||||
|
target_path,
|
||||||
|
container_key="host",
|
||||||
|
preferred_mode="host",
|
||||||
|
)
|
||||||
|
except RuntimeError as exc:
|
||||||
|
if current_id:
|
||||||
|
session['host_workspace_id'] = current_id
|
||||||
|
if previous_workspace_id is not None:
|
||||||
|
session['workspace_id'] = previous_workspace_id
|
||||||
|
elif current_id:
|
||||||
|
session['workspace_id'] = current_id
|
||||||
|
write_host_workspace_debug(
|
||||||
|
"status.select_host_workspace.ensure_container_failed",
|
||||||
|
workspace_id=workspace_id,
|
||||||
|
target_path=target_path,
|
||||||
|
error=str(exc),
|
||||||
|
)
|
||||||
|
return jsonify({"success": False, "error": str(exc)}), 503
|
||||||
|
|
||||||
|
host_terminal = state.user_terminals.get("host")
|
||||||
|
if host_terminal:
|
||||||
|
try:
|
||||||
|
host_terminal.update_project_path(target_path)
|
||||||
|
host_terminal.update_container_session(container_handle)
|
||||||
|
attach_user_broadcast(host_terminal, "host")
|
||||||
|
host_terminal.username = "host"
|
||||||
|
host_terminal.user_role = "admin"
|
||||||
|
write_host_workspace_debug(
|
||||||
|
"status.select_host_workspace.updated_terminal",
|
||||||
|
terminal_id=id(host_terminal),
|
||||||
|
workspace_id=workspace_id,
|
||||||
|
project_path=str(getattr(host_terminal, "project_path", "")),
|
||||||
|
context_project_path=str(getattr(getattr(host_terminal, "context_manager", None), "project_path", "")),
|
||||||
|
current_conversation_id=getattr(getattr(host_terminal, "context_manager", None), "current_conversation_id", None),
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
try:
|
||||||
|
if getattr(host_terminal, "terminal_manager", None):
|
||||||
|
host_terminal.terminal_manager.close_all()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
state.user_terminals.pop("host", None)
|
||||||
|
write_host_workspace_debug(
|
||||||
|
"status.select_host_workspace.drop_terminal_after_error",
|
||||||
|
workspace_id=workspace_id,
|
||||||
|
)
|
||||||
|
|
||||||
|
write_host_workspace_debug(
|
||||||
|
"status.select_host_workspace.success",
|
||||||
|
workspace_id=workspace_id,
|
||||||
|
target_path=target_path,
|
||||||
|
)
|
||||||
|
return jsonify({
|
||||||
|
"success": True,
|
||||||
|
"data": {
|
||||||
|
"current_workspace_id": workspace_id,
|
||||||
|
"project_path": target_path,
|
||||||
|
"default_workspace_id": catalog.get("default_workspace_id"),
|
||||||
|
"reloaded": True,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
@status_bp.route('/api/host/workspaces/create', methods=['GET', 'POST'])
|
||||||
|
@api_login_required
|
||||||
|
def create_host_workspace_api():
|
||||||
|
if not _is_host_mode_request():
|
||||||
|
return jsonify({"success": False, "error": "仅宿主机模式可用"}), 403
|
||||||
|
|
||||||
|
payload = request.get_json(silent=True) if request.method != "GET" else None
|
||||||
|
workspace_path = (
|
||||||
|
request.args.get("path")
|
||||||
|
or (payload or {}).get("path")
|
||||||
|
or ""
|
||||||
|
).strip()
|
||||||
|
label = (
|
||||||
|
request.args.get("label")
|
||||||
|
or (payload or {}).get("label")
|
||||||
|
or ""
|
||||||
|
).strip()
|
||||||
|
set_default_raw = (
|
||||||
|
request.args.get("set_default")
|
||||||
|
or (payload or {}).get("set_default")
|
||||||
|
or ""
|
||||||
|
)
|
||||||
|
set_default = str(set_default_raw).lower() in {"1", "true", "yes", "on"}
|
||||||
|
|
||||||
|
if not workspace_path:
|
||||||
|
return jsonify({"success": False, "error": "缺少 path"}), 400
|
||||||
|
|
||||||
|
try:
|
||||||
|
result = create_host_workspace(workspace_path, label=label, set_default=set_default)
|
||||||
|
except Exception as exc:
|
||||||
|
return jsonify({"success": False, "error": str(exc)}), 500
|
||||||
|
|
||||||
|
catalog = result.get("catalog") or {}
|
||||||
|
workspaces = []
|
||||||
|
current_id = (session.get("host_workspace_id") or "").strip()
|
||||||
|
for item in catalog.get("workspaces") or []:
|
||||||
|
workspaces.append({
|
||||||
|
"workspace_id": item.get("workspace_id"),
|
||||||
|
"label": item.get("label"),
|
||||||
|
"path": item.get("path"),
|
||||||
|
"is_current": item.get("workspace_id") == current_id,
|
||||||
|
})
|
||||||
|
|
||||||
|
return jsonify({
|
||||||
|
"success": True,
|
||||||
|
"data": {
|
||||||
|
"created": bool(result.get("created")),
|
||||||
|
"workspace": result.get("workspace") or {},
|
||||||
|
"source_path": catalog.get("source_path"),
|
||||||
|
"default_workspace_id": catalog.get("default_workspace_id"),
|
||||||
|
"current_workspace_id": current_id,
|
||||||
|
"workspaces": workspaces,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
@status_bp.route('/api/container-status')
|
@status_bp.route('/api/container-status')
|
||||||
@api_login_required
|
@api_login_required
|
||||||
@with_terminal
|
@with_terminal
|
||||||
|
|||||||
@ -15,6 +15,7 @@ from .context import get_user_resources, ensure_conversation_loaded
|
|||||||
from .chat_flow import run_chat_task_sync
|
from .chat_flow import run_chat_task_sync
|
||||||
from .state import stop_flags
|
from .state import stop_flags
|
||||||
from .utils_common import debug_log
|
from .utils_common import debug_log
|
||||||
|
from utils.host_workspace_debug import write_host_workspace_debug
|
||||||
|
|
||||||
|
|
||||||
class TaskRecord:
|
class TaskRecord:
|
||||||
@ -124,7 +125,16 @@ class TaskManager:
|
|||||||
record = TaskRecord(task_id, username, workspace_id, message, conversation_id, model_key, thinking_mode, run_mode, max_iterations)
|
record = TaskRecord(task_id, username, workspace_id, message, conversation_id, model_key, thinking_mode, run_mode, max_iterations)
|
||||||
# 记录当前 session 快照,便于后台线程内使用
|
# 记录当前 session 快照,便于后台线程内使用
|
||||||
if session_data is not None:
|
if session_data is not None:
|
||||||
record.session_data = dict(session_data)
|
snapshot = dict(session_data)
|
||||||
|
snapshot.setdefault("workspace_id", workspace_id)
|
||||||
|
try:
|
||||||
|
snapshot.setdefault("host_mode", session.get("host_mode"))
|
||||||
|
if snapshot.get("host_mode"):
|
||||||
|
snapshot.setdefault("host_workspace_id", session.get("host_workspace_id") or workspace_id)
|
||||||
|
except Exception:
|
||||||
|
if snapshot.get("host_mode"):
|
||||||
|
snapshot.setdefault("host_workspace_id", workspace_id)
|
||||||
|
record.session_data = snapshot
|
||||||
else:
|
else:
|
||||||
try:
|
try:
|
||||||
record.session_data = {
|
record.session_data = {
|
||||||
@ -132,6 +142,7 @@ class TaskManager:
|
|||||||
"role": session.get("role"),
|
"role": session.get("role"),
|
||||||
"is_api_user": session.get("is_api_user"),
|
"is_api_user": session.get("is_api_user"),
|
||||||
"host_mode": session.get("host_mode"),
|
"host_mode": session.get("host_mode"),
|
||||||
|
"host_workspace_id": session.get("host_workspace_id") or workspace_id,
|
||||||
"workspace_id": workspace_id,
|
"workspace_id": workspace_id,
|
||||||
"run_mode": session.get("run_mode"),
|
"run_mode": session.get("run_mode"),
|
||||||
"thinking_mode": session.get("thinking_mode"),
|
"thinking_mode": session.get("thinking_mode"),
|
||||||
@ -217,6 +228,15 @@ class TaskManager:
|
|||||||
for k, v in (rec.session_data or {}).items():
|
for k, v in (rec.session_data or {}).items():
|
||||||
if v is not None:
|
if v is not None:
|
||||||
session[k] = v
|
session[k] = v
|
||||||
|
if session.get("host_mode"):
|
||||||
|
session["workspace_id"] = workspace_id
|
||||||
|
session["host_workspace_id"] = session.get("host_workspace_id") or workspace_id
|
||||||
|
write_host_workspace_debug(
|
||||||
|
"tasks.run_chat_task.apply_host_session",
|
||||||
|
task_id=rec.task_id,
|
||||||
|
workspace_id=workspace_id,
|
||||||
|
host_workspace_id=session.get("host_workspace_id"),
|
||||||
|
)
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
terminal, workspace = get_user_resources(username, workspace_id=workspace_id)
|
terminal, workspace = get_user_resources(username, workspace_id=workspace_id)
|
||||||
|
|||||||
@ -93,10 +93,17 @@
|
|||||||
:panel-menu-open="panelMenuOpen"
|
:panel-menu-open="panelMenuOpen"
|
||||||
:panel-mode="panelMode"
|
:panel-mode="panelMode"
|
||||||
:file-manager-disabled="policyUiBlocks.block_file_manager"
|
:file-manager-disabled="policyUiBlocks.block_file_manager"
|
||||||
|
:host-workspace-enabled="versioningHostMode"
|
||||||
|
:host-workspaces="hostWorkspaces"
|
||||||
|
:host-workspace-id="currentHostWorkspaceId"
|
||||||
|
:host-workspace-default-id="defaultHostWorkspaceId"
|
||||||
|
:host-workspace-switching="hostWorkspaceSwitching"
|
||||||
@toggle-panel-menu="togglePanelMenu"
|
@toggle-panel-menu="togglePanelMenu"
|
||||||
@select-panel="selectPanelMode"
|
@select-panel="selectPanelMode"
|
||||||
@open-file-manager="openGuiFileManager"
|
@open-file-manager="openGuiFileManager"
|
||||||
@toggle-thinking-mode="handleQuickModeToggle"
|
@toggle-thinking-mode="handleQuickModeToggle"
|
||||||
|
@switch-host-workspace="handleHostWorkspaceSwitch"
|
||||||
|
@create-host-workspace="handleCreateHostWorkspace"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<div
|
<div
|
||||||
@ -411,6 +418,20 @@
|
|||||||
@confirm="confirmVersioningRestore"
|
@confirm="confirmVersioningRestore"
|
||||||
/>
|
/>
|
||||||
</transition>
|
</transition>
|
||||||
|
<transition name="overlay-fade">
|
||||||
|
<HostWorkspaceCreateDialog
|
||||||
|
v-if="hostWorkspaceCreateDialogOpen"
|
||||||
|
:open="hostWorkspaceCreateDialogOpen"
|
||||||
|
:path-value="hostWorkspaceCreatePath"
|
||||||
|
:label-value="hostWorkspaceCreateLabel"
|
||||||
|
:submitting="hostWorkspaceCreateSubmitting"
|
||||||
|
:error-message="hostWorkspaceCreateError"
|
||||||
|
@close="closeHostWorkspaceCreateDialog"
|
||||||
|
@submit="submitHostWorkspaceCreate"
|
||||||
|
@update:path-value="hostWorkspaceCreatePath = $event"
|
||||||
|
@update:label-value="hostWorkspaceCreateLabel = $event"
|
||||||
|
/>
|
||||||
|
</transition>
|
||||||
<TutorialOverlay v-if="tutorialStore.running" />
|
<TutorialOverlay v-if="tutorialStore.running" />
|
||||||
<NewUserTutorialPrompt
|
<NewUserTutorialPrompt
|
||||||
:visible="tutorialPromptVisible"
|
:visible="tutorialPromptVisible"
|
||||||
@ -645,12 +666,20 @@
|
|||||||
:icon-style="iconStyle"
|
:icon-style="iconStyle"
|
||||||
:agent-version="agentVersion"
|
:agent-version="agentVersion"
|
||||||
:thinking-mode="thinkingMode"
|
:thinking-mode="thinkingMode"
|
||||||
|
:run-mode="resolvedRunMode"
|
||||||
:is-connected="isConnected"
|
:is-connected="isConnected"
|
||||||
:panel-menu-open="panelMenuOpen"
|
:panel-menu-open="panelMenuOpen"
|
||||||
:panel-mode="panelMode"
|
:panel-mode="panelMode"
|
||||||
|
:host-workspace-enabled="versioningHostMode"
|
||||||
|
:host-workspaces="hostWorkspaces"
|
||||||
|
:host-workspace-id="currentHostWorkspaceId"
|
||||||
|
:host-workspace-default-id="defaultHostWorkspaceId"
|
||||||
|
:host-workspace-switching="hostWorkspaceSwitching"
|
||||||
@toggle-panel-menu="togglePanelMenu"
|
@toggle-panel-menu="togglePanelMenu"
|
||||||
@select-panel="selectPanelMode"
|
@select-panel="selectPanelMode"
|
||||||
@open-file-manager="openGuiFileManager"
|
@open-file-manager="openGuiFileManager"
|
||||||
|
@switch-host-workspace="handleHostWorkspaceSwitch"
|
||||||
|
@create-host-workspace="handleCreateHostWorkspace"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -24,6 +24,9 @@ const BackgroundCommandDialog = defineAsyncComponent(
|
|||||||
const VersioningDialog = defineAsyncComponent(
|
const VersioningDialog = defineAsyncComponent(
|
||||||
() => import('../components/overlay/VersioningDialog.vue')
|
() => import('../components/overlay/VersioningDialog.vue')
|
||||||
);
|
);
|
||||||
|
const HostWorkspaceCreateDialog = defineAsyncComponent(
|
||||||
|
() => import('../components/overlay/HostWorkspaceCreateDialog.vue')
|
||||||
|
);
|
||||||
const TutorialOverlay = defineAsyncComponent(
|
const TutorialOverlay = defineAsyncComponent(
|
||||||
() => import('../components/overlay/TutorialOverlay.vue')
|
() => import('../components/overlay/TutorialOverlay.vue')
|
||||||
);
|
);
|
||||||
@ -46,6 +49,7 @@ export const appComponents = {
|
|||||||
SubAgentActivityDialog,
|
SubAgentActivityDialog,
|
||||||
BackgroundCommandDialog,
|
BackgroundCommandDialog,
|
||||||
VersioningDialog,
|
VersioningDialog,
|
||||||
|
HostWorkspaceCreateDialog,
|
||||||
TutorialOverlay,
|
TutorialOverlay,
|
||||||
NewUserTutorialPrompt
|
NewUserTutorialPrompt
|
||||||
};
|
};
|
||||||
|
|||||||
@ -488,6 +488,159 @@ export const uiMethods = {
|
|||||||
await this.updateTutorialPromptStatus(true);
|
await this.updateTutorialPromptStatus(true);
|
||||||
},
|
},
|
||||||
|
|
||||||
|
async fetchHostWorkspaces() {
|
||||||
|
if (!this.versioningHostMode) {
|
||||||
|
this.hostWorkspaces = [];
|
||||||
|
this.currentHostWorkspaceId = '';
|
||||||
|
this.defaultHostWorkspaceId = '';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const resp = await fetch('/api/host/workspaces');
|
||||||
|
const payload = await resp.json().catch(() => ({}));
|
||||||
|
if (!resp.ok || !payload?.success) {
|
||||||
|
throw new Error(payload?.error || '获取宿主机工作区列表失败');
|
||||||
|
}
|
||||||
|
const data = payload.data || {};
|
||||||
|
const items = Array.isArray(data.workspaces) ? data.workspaces : [];
|
||||||
|
this.hostWorkspaces = items.map((item: any) => ({
|
||||||
|
workspace_id: String(item.workspace_id || ''),
|
||||||
|
label: String(item.label || item.workspace_id || '未命名工作区'),
|
||||||
|
path: String(item.path || ''),
|
||||||
|
is_current: !!item.is_current
|
||||||
|
}));
|
||||||
|
this.currentHostWorkspaceId = String(data.current_workspace_id || '');
|
||||||
|
this.defaultHostWorkspaceId = String(data.default_workspace_id || '');
|
||||||
|
} catch (error) {
|
||||||
|
console.warn('加载宿主机工作区失败:', error);
|
||||||
|
this.hostWorkspaces = [];
|
||||||
|
this.currentHostWorkspaceId = '';
|
||||||
|
this.defaultHostWorkspaceId = '';
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
async handleHostWorkspaceSwitch(workspaceId) {
|
||||||
|
const targetId = String(workspaceId || '').trim();
|
||||||
|
if (!targetId || this.hostWorkspaceSwitching) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!this.versioningHostMode) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (targetId === this.currentHostWorkspaceId) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.hostWorkspaceSwitching = true;
|
||||||
|
try {
|
||||||
|
const query = encodeURIComponent(targetId);
|
||||||
|
const resp = await fetch(`/api/host/workspaces/select?workspace_id=${query}`);
|
||||||
|
const payload = await resp.json().catch(() => ({}));
|
||||||
|
if (!resp.ok || !payload?.success) {
|
||||||
|
throw new Error(payload?.error || '切换宿主机工作区失败');
|
||||||
|
}
|
||||||
|
const data = payload.data || {};
|
||||||
|
this.currentHostWorkspaceId = String(data.current_workspace_id || targetId);
|
||||||
|
if (data.project_path) {
|
||||||
|
this.projectPath = String(data.project_path);
|
||||||
|
}
|
||||||
|
if (data.default_workspace_id) {
|
||||||
|
this.defaultHostWorkspaceId = String(data.default_workspace_id);
|
||||||
|
}
|
||||||
|
this.uiPushToast({
|
||||||
|
title: '工作区已切换',
|
||||||
|
message: '正在刷新页面…',
|
||||||
|
type: 'success'
|
||||||
|
});
|
||||||
|
window.setTimeout(() => {
|
||||||
|
window.location.reload();
|
||||||
|
}, 260);
|
||||||
|
} catch (error) {
|
||||||
|
const message = error instanceof Error ? error.message : String(error || '切换失败');
|
||||||
|
this.uiPushToast({
|
||||||
|
title: '切换工作区失败',
|
||||||
|
message,
|
||||||
|
type: 'error'
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
this.hostWorkspaceSwitching = false;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
async handleCreateHostWorkspace() {
|
||||||
|
if (!this.versioningHostMode) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (this.hostWorkspaceSwitching || this.hostWorkspaceCreateSubmitting) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.hostWorkspaceCreatePath = '';
|
||||||
|
this.hostWorkspaceCreateLabel = '';
|
||||||
|
this.hostWorkspaceCreateError = '';
|
||||||
|
this.hostWorkspaceCreateDialogOpen = true;
|
||||||
|
},
|
||||||
|
|
||||||
|
closeHostWorkspaceCreateDialog() {
|
||||||
|
if (this.hostWorkspaceCreateSubmitting) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.hostWorkspaceCreateDialogOpen = false;
|
||||||
|
this.hostWorkspaceCreateError = '';
|
||||||
|
},
|
||||||
|
|
||||||
|
async submitHostWorkspaceCreate() {
|
||||||
|
if (!this.versioningHostMode) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (this.hostWorkspaceSwitching || this.hostWorkspaceCreateSubmitting) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const workspacePath = String(this.hostWorkspaceCreatePath || '').trim();
|
||||||
|
if (!workspacePath) {
|
||||||
|
this.hostWorkspaceCreateError = '路径不能为空';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const label = String(this.hostWorkspaceCreateLabel || '').trim();
|
||||||
|
|
||||||
|
this.hostWorkspaceCreateSubmitting = true;
|
||||||
|
this.hostWorkspaceCreateError = '';
|
||||||
|
try {
|
||||||
|
const resp = await fetch('/api/host/workspaces/create', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
credentials: 'same-origin',
|
||||||
|
body: JSON.stringify({
|
||||||
|
path: workspacePath,
|
||||||
|
label
|
||||||
|
})
|
||||||
|
});
|
||||||
|
const payload = await resp.json().catch(() => ({}));
|
||||||
|
if (!resp.ok || !payload?.success) {
|
||||||
|
throw new Error(payload?.error || '新建宿主机工作区失败');
|
||||||
|
}
|
||||||
|
await this.fetchHostWorkspaces();
|
||||||
|
const createdLabel = payload?.data?.workspace?.label || label || workspacePath;
|
||||||
|
this.hostWorkspaceCreateDialogOpen = false;
|
||||||
|
this.hostWorkspaceCreatePath = '';
|
||||||
|
this.hostWorkspaceCreateLabel = '';
|
||||||
|
this.uiPushToast({
|
||||||
|
title: '工作区已创建',
|
||||||
|
message: createdLabel,
|
||||||
|
type: 'success'
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
const message = error instanceof Error ? error.message : String(error || '新建失败');
|
||||||
|
this.hostWorkspaceCreateError = message;
|
||||||
|
this.uiPushToast({
|
||||||
|
title: '新建工作区失败',
|
||||||
|
message,
|
||||||
|
type: 'error'
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
this.hostWorkspaceCreateSubmitting = false;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
fetchTodoList() {
|
fetchTodoList() {
|
||||||
return this.fileFetchTodoList();
|
return this.fileFetchTodoList();
|
||||||
},
|
},
|
||||||
@ -1842,20 +1995,42 @@ export const uiMethods = {
|
|||||||
},
|
},
|
||||||
|
|
||||||
startConnectionHeartbeat() {
|
startConnectionHeartbeat() {
|
||||||
if (this.connectionHeartbeatTimer) {
|
if (this.connectionHeartbeatActive) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
this.connectionHeartbeatActive = true;
|
||||||
this.connectionHeartbeatFailCount = 0;
|
this.connectionHeartbeatFailCount = 0;
|
||||||
// 先做一次即时探活,再进入定时轮询
|
const runHeartbeat = async () => {
|
||||||
this.checkConnectionHealth();
|
if (!this.connectionHeartbeatActive) {
|
||||||
this.connectionHeartbeatTimer = window.setInterval(() => {
|
return;
|
||||||
this.checkConnectionHealth();
|
}
|
||||||
}, this.connectionHeartbeatIntervalMs || 8000);
|
await this.checkConnectionHealth();
|
||||||
|
if (!this.connectionHeartbeatActive) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const connectedInterval =
|
||||||
|
typeof this.connectionHeartbeatIntervalMs === 'number' && this.connectionHeartbeatIntervalMs > 0
|
||||||
|
? this.connectionHeartbeatIntervalMs
|
||||||
|
: 8000;
|
||||||
|
const disconnectedInterval =
|
||||||
|
typeof this.connectionHeartbeatDisconnectedIntervalMs === 'number' &&
|
||||||
|
this.connectionHeartbeatDisconnectedIntervalMs > 0
|
||||||
|
? this.connectionHeartbeatDisconnectedIntervalMs
|
||||||
|
: 1000;
|
||||||
|
const nextInterval = this.isConnected ? connectedInterval : disconnectedInterval;
|
||||||
|
this.connectionHeartbeatTimer = window.setTimeout(() => {
|
||||||
|
runHeartbeat();
|
||||||
|
}, nextInterval);
|
||||||
|
};
|
||||||
|
|
||||||
|
// 先做一次即时探活,再根据连接状态动态设置下次轮询间隔
|
||||||
|
runHeartbeat();
|
||||||
},
|
},
|
||||||
|
|
||||||
stopConnectionHeartbeat() {
|
stopConnectionHeartbeat() {
|
||||||
|
this.connectionHeartbeatActive = false;
|
||||||
if (this.connectionHeartbeatTimer) {
|
if (this.connectionHeartbeatTimer) {
|
||||||
clearInterval(this.connectionHeartbeatTimer);
|
clearTimeout(this.connectionHeartbeatTimer);
|
||||||
this.connectionHeartbeatTimer = null;
|
this.connectionHeartbeatTimer = null;
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@ -1942,7 +2117,16 @@ export const uiMethods = {
|
|||||||
this.versioningHostMode = !!isHostMode;
|
this.versioningHostMode = !!isHostMode;
|
||||||
if (isHostMode) {
|
if (isHostMode) {
|
||||||
this.fileMarkTreeUnavailable('宿主机模式下文件树不可用');
|
this.fileMarkTreeUnavailable('宿主机模式下文件树不可用');
|
||||||
|
await this.fetchHostWorkspaces();
|
||||||
} else {
|
} else {
|
||||||
|
this.hostWorkspaces = [];
|
||||||
|
this.currentHostWorkspaceId = '';
|
||||||
|
this.defaultHostWorkspaceId = '';
|
||||||
|
this.hostWorkspaceCreateDialogOpen = false;
|
||||||
|
this.hostWorkspaceCreatePath = '';
|
||||||
|
this.hostWorkspaceCreateLabel = '';
|
||||||
|
this.hostWorkspaceCreateError = '';
|
||||||
|
this.hostWorkspaceCreateSubmitting = false;
|
||||||
treePromise = this.fileFetchTree();
|
treePromise = this.fileFetchTree();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -82,6 +82,15 @@ export function dataState() {
|
|||||||
permissionMenuOpen: false,
|
permissionMenuOpen: false,
|
||||||
currentPermissionMode: 'unrestricted',
|
currentPermissionMode: 'unrestricted',
|
||||||
versioningHostMode: false,
|
versioningHostMode: false,
|
||||||
|
hostWorkspaces: [],
|
||||||
|
currentHostWorkspaceId: '',
|
||||||
|
defaultHostWorkspaceId: '',
|
||||||
|
hostWorkspaceSwitching: false,
|
||||||
|
hostWorkspaceCreateDialogOpen: false,
|
||||||
|
hostWorkspaceCreatePath: '',
|
||||||
|
hostWorkspaceCreateLabel: '',
|
||||||
|
hostWorkspaceCreateSubmitting: false,
|
||||||
|
hostWorkspaceCreateError: '',
|
||||||
versioningEnabled: false,
|
versioningEnabled: false,
|
||||||
newConversationVersioningEnabled: false,
|
newConversationVersioningEnabled: false,
|
||||||
versioningMode: 'overwrite',
|
versioningMode: 'overwrite',
|
||||||
@ -123,8 +132,10 @@ export function dataState() {
|
|||||||
conversationListRequestSeq: 0,
|
conversationListRequestSeq: 0,
|
||||||
conversationListRefreshToken: 0,
|
conversationListRefreshToken: 0,
|
||||||
connectionHeartbeatTimer: null,
|
connectionHeartbeatTimer: null,
|
||||||
|
connectionHeartbeatActive: false,
|
||||||
connectionHeartbeatFailCount: 0,
|
connectionHeartbeatFailCount: 0,
|
||||||
connectionHeartbeatIntervalMs: 8000,
|
connectionHeartbeatIntervalMs: 8000,
|
||||||
|
connectionHeartbeatDisconnectedIntervalMs: 1000,
|
||||||
|
|
||||||
// 工具控制菜单
|
// 工具控制菜单
|
||||||
icons: ICONS,
|
icons: ICONS,
|
||||||
|
|||||||
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>
|
</div>
|
||||||
<div v-else class="file-tree" @contextmenu.prevent>
|
<div v-else class="file-tree" @contextmenu.prevent>
|
||||||
<div v-if="fileTreeUnavailable" class="file-tree-empty">{{ fileTreeMessage }}</div>
|
<template v-if="fileTreeUnavailable">
|
||||||
|
<div v-if="hostWorkspaceEnabled" class="host-workspace-section">
|
||||||
|
<div class="host-workspace-header-box">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="host-workspace-add-btn"
|
||||||
|
:disabled="hostWorkspaceSwitching"
|
||||||
|
@click="handleCreateHostWorkspace"
|
||||||
|
title="新建工作区"
|
||||||
|
>
|
||||||
|
+
|
||||||
|
</button>
|
||||||
|
<div class="host-workspace-title">切换工作区</div>
|
||||||
|
</div>
|
||||||
|
<div class="host-workspace-list" v-if="hostWorkspaceOptions.length">
|
||||||
|
<button
|
||||||
|
v-for="item in hostWorkspaceOptions"
|
||||||
|
:key="item.workspace_id"
|
||||||
|
type="button"
|
||||||
|
class="host-workspace-card"
|
||||||
|
:class="{ active: item.workspace_id === hostWorkspaceId }"
|
||||||
|
:disabled="hostWorkspaceSwitching || item.workspace_id === hostWorkspaceId"
|
||||||
|
@click="handleHostWorkspaceClick(item.workspace_id)"
|
||||||
|
>
|
||||||
|
<div class="host-workspace-card-head">
|
||||||
|
<span class="host-workspace-label">{{ item.label }}</span>
|
||||||
|
<span class="host-workspace-badges">
|
||||||
|
<span
|
||||||
|
v-if="item.workspace_id === hostWorkspaceDefaultId"
|
||||||
|
class="host-workspace-badge default"
|
||||||
|
>
|
||||||
|
默认工作区
|
||||||
|
</span>
|
||||||
|
<span v-if="item.workspace_id === hostWorkspaceId" class="host-workspace-badge current">
|
||||||
|
当前
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div class="host-workspace-path">
|
||||||
|
{{ item.path || '(未配置路径)' }}
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div v-else class="file-tree-empty">暂无工作区,点击右侧 + 新建</div>
|
||||||
|
<div class="host-workspace-hint" v-if="hostWorkspaceSwitching">正在切换,请稍候…</div>
|
||||||
|
</div>
|
||||||
|
<div v-else class="file-tree-empty">{{ fileTreeMessage }}</div>
|
||||||
|
</template>
|
||||||
<template v-else>
|
<template v-else>
|
||||||
<div v-if="!fileTree.length" class="file-tree-empty">暂无文件</div>
|
<div v-if="!fileTree.length" class="file-tree-empty">暂无文件</div>
|
||||||
<FileNode
|
<FileNode
|
||||||
@ -234,13 +281,25 @@ const props = defineProps<{
|
|||||||
panelMode: 'files' | 'todo' | 'subAgents' | 'backgroundCommands';
|
panelMode: 'files' | 'todo' | 'subAgents' | 'backgroundCommands';
|
||||||
runMode: 'fast' | 'thinking' | 'deep';
|
runMode: 'fast' | 'thinking' | 'deep';
|
||||||
fileManagerDisabled?: boolean;
|
fileManagerDisabled?: boolean;
|
||||||
|
hostWorkspaceEnabled?: boolean;
|
||||||
|
hostWorkspaces?: Array<{
|
||||||
|
workspace_id: string;
|
||||||
|
label: string;
|
||||||
|
path?: string;
|
||||||
|
is_current?: boolean;
|
||||||
|
}>;
|
||||||
|
hostWorkspaceId?: string;
|
||||||
|
hostWorkspaceDefaultId?: string;
|
||||||
|
hostWorkspaceSwitching?: boolean;
|
||||||
}>();
|
}>();
|
||||||
|
|
||||||
defineEmits<{
|
const emits = defineEmits<{
|
||||||
(event: 'toggle-panel-menu'): void;
|
(event: 'toggle-panel-menu'): void;
|
||||||
(event: 'select-panel', mode: 'files' | 'todo' | 'subAgents' | 'backgroundCommands'): void;
|
(event: 'select-panel', mode: 'files' | 'todo' | 'subAgents' | 'backgroundCommands'): void;
|
||||||
(event: 'open-file-manager'): void;
|
(event: 'open-file-manager'): void;
|
||||||
(event: 'toggle-thinking-mode'): void;
|
(event: 'toggle-thinking-mode'): void;
|
||||||
|
(event: 'switch-host-workspace', workspaceId: string): void;
|
||||||
|
(event: 'create-host-workspace'): void;
|
||||||
}>();
|
}>();
|
||||||
|
|
||||||
const panelMenuWrapper = ref<HTMLElement | null>(null);
|
const panelMenuWrapper = ref<HTMLElement | null>(null);
|
||||||
@ -306,6 +365,29 @@ const { fileTree, expandedFolders, todoList, fileTreeUnavailable, fileTreeMessag
|
|||||||
const { subAgents } = storeToRefs(subAgentStore);
|
const { subAgents } = storeToRefs(subAgentStore);
|
||||||
const { commands: backgroundCommands } = storeToRefs(backgroundCommandStore);
|
const { commands: backgroundCommands } = storeToRefs(backgroundCommandStore);
|
||||||
|
|
||||||
|
const hostWorkspaceOptions = computed(() => {
|
||||||
|
const list = Array.isArray(props.hostWorkspaces) ? props.hostWorkspaces : [];
|
||||||
|
return list.filter((item) => item && item.workspace_id);
|
||||||
|
});
|
||||||
|
|
||||||
|
const handleHostWorkspaceClick = (workspaceId: string) => {
|
||||||
|
const targetId = String(workspaceId || '').trim();
|
||||||
|
if (!targetId || targetId === props.hostWorkspaceId) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (props.hostWorkspaceSwitching) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
emits('switch-host-workspace', targetId);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCreateHostWorkspace = () => {
|
||||||
|
if (props.hostWorkspaceSwitching) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
emits('create-host-workspace');
|
||||||
|
};
|
||||||
|
|
||||||
const openSubAgent = (agent: any) => {
|
const openSubAgent = (agent: any) => {
|
||||||
subAgentStore.openSubAgent(agent);
|
subAgentStore.openSubAgent(agent);
|
||||||
};
|
};
|
||||||
|
|||||||
@ -489,6 +489,188 @@
|
|||||||
background: rgba(255, 255, 255, 0.8);
|
background: rgba(255, 255, 255, 0.8);
|
||||||
border: 1px dashed var(--claude-border);
|
border: 1px dashed var(--claude-border);
|
||||||
text-align: center;
|
text-align: center;
|
||||||
|
|
||||||
|
[data-theme="dark"] & {
|
||||||
|
background: rgba(20, 20, 20, 0.9);
|
||||||
|
border-color: rgba(255, 255, 255, 0.12);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.file-tree-empty--host {
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
|
||||||
|
.host-workspace-section {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.host-workspace-switcher {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.host-workspace-header-box {
|
||||||
|
width: min(100%, 360px);
|
||||||
|
margin: 0 auto;
|
||||||
|
border-radius: 12px;
|
||||||
|
border: 1px dashed var(--claude-border-strong);
|
||||||
|
background: var(--theme-surface-muted);
|
||||||
|
padding: 10px 12px;
|
||||||
|
position: relative;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.host-workspace-add-btn {
|
||||||
|
position: absolute;
|
||||||
|
right: 10px;
|
||||||
|
width: 22px;
|
||||||
|
height: 22px;
|
||||||
|
appearance: none;
|
||||||
|
-webkit-appearance: none;
|
||||||
|
border: none;
|
||||||
|
background: transparent;
|
||||||
|
padding: 0;
|
||||||
|
color: var(--claude-text);
|
||||||
|
font-size: 22px;
|
||||||
|
font-weight: 500;
|
||||||
|
line-height: 1;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
cursor: pointer;
|
||||||
|
box-shadow: none;
|
||||||
|
|
||||||
|
&:hover:not(:disabled) {
|
||||||
|
color: var(--claude-accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
&:disabled {
|
||||||
|
opacity: 0.6;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.host-workspace-title {
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--claude-text);
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.host-workspace-list {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.host-workspace-card {
|
||||||
|
width: 100%;
|
||||||
|
border: 1px solid var(--theme-control-border);
|
||||||
|
background: var(--theme-surface-soft);
|
||||||
|
border-radius: 10px;
|
||||||
|
padding: 10px 12px;
|
||||||
|
text-align: left;
|
||||||
|
color: var(--claude-text);
|
||||||
|
cursor: pointer;
|
||||||
|
transition: border-color 0.2s ease, transform 0.2s ease;
|
||||||
|
|
||||||
|
&:hover:not(:disabled) {
|
||||||
|
border-color: var(--claude-accent);
|
||||||
|
transform: translateY(-1px);
|
||||||
|
}
|
||||||
|
|
||||||
|
&:disabled {
|
||||||
|
cursor: default;
|
||||||
|
opacity: 0.95;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.active {
|
||||||
|
border-color: var(--claude-accent);
|
||||||
|
background: var(--claude-highlight);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.host-workspace-card-head {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 8px;
|
||||||
|
margin-bottom: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.host-workspace-label {
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--claude-text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.host-workspace-badges {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.host-workspace-badge {
|
||||||
|
border-radius: 999px;
|
||||||
|
padding: 2px 8px;
|
||||||
|
font-size: 11px;
|
||||||
|
line-height: 1.2;
|
||||||
|
border: 1px solid var(--theme-control-border);
|
||||||
|
color: var(--claude-text-secondary);
|
||||||
|
background: var(--theme-surface-muted);
|
||||||
|
|
||||||
|
&.default {
|
||||||
|
color: var(--claude-deep-strong);
|
||||||
|
border-color: rgba(208, 122, 20, 0.35);
|
||||||
|
background: rgba(242, 169, 59, 0.14);
|
||||||
|
}
|
||||||
|
|
||||||
|
&.current {
|
||||||
|
color: #2f6f4e;
|
||||||
|
border-color: rgba(47, 111, 78, 0.35);
|
||||||
|
background: rgba(118, 176, 134, 0.18);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-theme="dark"] .host-workspace-header-box {
|
||||||
|
background: rgba(30, 30, 30, 0.78);
|
||||||
|
border-color: rgba(160, 160, 160, 0.45);
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-theme="dark"] .host-workspace-card {
|
||||||
|
&.active {
|
||||||
|
background: rgba(255, 255, 255, 0.08);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-theme="dark"] .host-workspace-badge {
|
||||||
|
&.default {
|
||||||
|
color: #f7c56f;
|
||||||
|
border-color: rgba(247, 197, 111, 0.35);
|
||||||
|
background: rgba(247, 197, 111, 0.16);
|
||||||
|
}
|
||||||
|
|
||||||
|
&.current {
|
||||||
|
color: #8edbb4;
|
||||||
|
border-color: rgba(142, 219, 180, 0.34);
|
||||||
|
background: rgba(16, 185, 129, 0.2);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.host-workspace-path {
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--claude-text-secondary);
|
||||||
|
word-break: break-all;
|
||||||
|
}
|
||||||
|
|
||||||
|
.host-workspace-hint {
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--claude-text-secondary);
|
||||||
}
|
}
|
||||||
|
|
||||||
.todo-task {
|
.todo-task {
|
||||||
|
|||||||
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,
|
model_supports_video,
|
||||||
)
|
)
|
||||||
from utils.conversation_manager import ConversationManager
|
from utils.conversation_manager import ConversationManager
|
||||||
|
from utils.host_workspace_debug import write_host_workspace_debug
|
||||||
from utils.media_store import MediaStore
|
from utils.media_store import MediaStore
|
||||||
|
|
||||||
AUTO_SHALLOW_PLACEHOLDER = "过早的工具结果已经被自动压缩"
|
AUTO_SHALLOW_PLACEHOLDER = "过早的工具结果已经被自动压缩"
|
||||||
@ -834,6 +835,12 @@ class ContextManager:
|
|||||||
print(f"⚠️ 对话记录中的项目路径不可用,已回退至: {resolved_project_path}")
|
print(f"⚠️ 对话记录中的项目路径不可用,已回退至: {resolved_project_path}")
|
||||||
|
|
||||||
self.project_path = resolved_project_path
|
self.project_path = resolved_project_path
|
||||||
|
write_host_workspace_debug(
|
||||||
|
"context_manager.load_conversation_by_id.set_project_path",
|
||||||
|
conversation_id=conversation_id,
|
||||||
|
metadata_project_path=str(stored_path_obj) if stored_path_obj else None,
|
||||||
|
resolved_project_path=str(resolved_project_path),
|
||||||
|
)
|
||||||
|
|
||||||
run_mode = metadata.get("run_mode")
|
run_mode = metadata.get("run_mode")
|
||||||
permission_mode = metadata.get("permission_mode")
|
permission_mode = metadata.get("permission_mode")
|
||||||
@ -909,11 +916,32 @@ class ContextManager:
|
|||||||
meta = self.conversation_metadata or {}
|
meta = self.conversation_metadata or {}
|
||||||
stored_tree = meta.get("project_file_tree")
|
stored_tree = meta.get("project_file_tree")
|
||||||
if stored_tree and stored_tree != "宿主机模式下文件树不可用":
|
if stored_tree and stored_tree != "宿主机模式下文件树不可用":
|
||||||
|
use_stored_snapshot = True
|
||||||
|
stored_path = meta.get("project_path")
|
||||||
|
if isinstance(stored_path, str) and stored_path.strip():
|
||||||
|
try:
|
||||||
|
stored_path_obj = Path(stored_path.strip()).expanduser().resolve()
|
||||||
|
if stored_path_obj != Path(self.project_path).resolve():
|
||||||
|
use_stored_snapshot = False
|
||||||
|
write_host_workspace_debug(
|
||||||
|
"context_manager.project_snapshot.discard_stale_snapshot",
|
||||||
|
conversation_id=self.current_conversation_id,
|
||||||
|
metadata_project_path=str(stored_path_obj),
|
||||||
|
current_project_path=str(Path(self.project_path).resolve()),
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
if use_stored_snapshot:
|
||||||
self.project_snapshot = {
|
self.project_snapshot = {
|
||||||
"file_tree": stored_tree,
|
"file_tree": stored_tree,
|
||||||
"statistics": meta.get("project_statistics"),
|
"statistics": meta.get("project_statistics"),
|
||||||
"snapshot_at": meta.get("project_snapshot_at")
|
"snapshot_at": meta.get("project_snapshot_at")
|
||||||
}
|
}
|
||||||
|
write_host_workspace_debug(
|
||||||
|
"context_manager.project_snapshot.use_metadata_snapshot",
|
||||||
|
conversation_id=self.current_conversation_id,
|
||||||
|
project_path=str(self.project_path),
|
||||||
|
)
|
||||||
return self.project_snapshot
|
return self.project_snapshot
|
||||||
|
|
||||||
# 首次生成并缓存
|
# 首次生成并缓存
|
||||||
@ -927,6 +955,12 @@ class ContextManager:
|
|||||||
"snapshot_at": datetime.now().isoformat()
|
"snapshot_at": datetime.now().isoformat()
|
||||||
}
|
}
|
||||||
self.project_snapshot = snapshot
|
self.project_snapshot = snapshot
|
||||||
|
write_host_workspace_debug(
|
||||||
|
"context_manager.project_snapshot.generated_new",
|
||||||
|
conversation_id=self.current_conversation_id,
|
||||||
|
project_path=str(self.project_path),
|
||||||
|
total_files=snapshot.get("statistics", {}).get("total_files"),
|
||||||
|
)
|
||||||
if self.current_conversation_id:
|
if self.current_conversation_id:
|
||||||
self.conversation_manager.update_project_snapshot(
|
self.conversation_manager.update_project_snapshot(
|
||||||
self.current_conversation_id,
|
self.current_conversation_id,
|
||||||
|
|||||||
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