From 981fd3170db2f6f34758f388dd9e3063d45dcdc0 Mon Sep 17 00:00:00 2001 From: Wenjie Zhang Date: Fri, 1 May 2026 21:00:58 +0800 Subject: [PATCH] =?UTF-8?q?feat(workspace):=20=E5=A2=9E=E5=BC=BA=E5=B7=A5?= =?UTF-8?q?=E4=BD=9C=E5=8C=BA=E5=8A=9F=E8=83=BD=EF=BC=8C=E6=96=B0=E5=A2=9E?= =?UTF-8?q?=E9=BB=98=E8=AE=A4=20agents=20=E6=96=87=E4=BB=B6=E5=88=9B?= =?UTF-8?q?=E5=BB=BA=E5=8F=8A=E8=B7=AF=E5=BE=84=E7=AE=A1=E7=90=86?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- AGENTS.md | 2 + .../yuxi/agents/backends/sandbox/__init__.py | 4 ++ .../yuxi/agents/backends/sandbox/paths.py | 41 +++++++++++++- backend/package/yuxi/services/chat_service.py | 45 ++++++++++++++-- .../yuxi/services/workspace_service.py | 6 ++- backend/package/yuxi/utils/paths.py | 4 ++ .../unit/services/test_chat_service_sync.py | 53 +++++++++++++++++++ .../unit/services/test_workspace_service.py | 40 ++++++++++++++ docs/develop-guides/roadmap.md | 2 +- .../components/workspace/WorkspaceSidebar.vue | 25 +++++++-- 10 files changed, 210 insertions(+), 12 deletions(-) create mode 100644 backend/test/unit/services/test_workspace_service.py diff --git a/AGENTS.md b/AGENTS.md index 6ee3826c..2d43c64b 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -101,6 +101,8 @@ make format # 格式化代码 - 尽量使用较新的语法,避免使用旧版本的语法(版本兼容到 3.12+) - 更新 [roadmap.md](docs/develop-guides/roadmap.md) 文档记录本次修改,多个类似的功能更新已经补充在一起 - 开发完成后务必在 docker 中进行测试,可以读取 .env 获取管理员账户和密码 +- 不允许把代码写得稀碎:不要为简单线性逻辑拆出一堆细碎 helper;优先写成职责清晰、结构完整、可一眼读懂的实现。 +- 拆函数必须服务于明确的复用、隔离副作用或降低认知负担;如果拆分后调用链更绕、上下文更分散,就应合并回更直接的实现。 **其他**: diff --git a/backend/package/yuxi/agents/backends/sandbox/__init__.py b/backend/package/yuxi/agents/backends/sandbox/__init__.py index d4417bfd..4dc1498d 100644 --- a/backend/package/yuxi/agents/backends/sandbox/__init__.py +++ b/backend/package/yuxi/agents/backends/sandbox/__init__.py @@ -2,10 +2,12 @@ from .backend import ProvisionerSandboxBackend from .paths import ( VIRTUAL_PATH_PREFIX, ensure_thread_dirs, + ensure_workspace_default_files, resolve_virtual_path, sandbox_outputs_dir, sandbox_uploads_dir, sandbox_user_data_dir, + sandbox_workspace_agents_prompt_file, sandbox_workspace_dir, virtual_path_for_thread_file, ) @@ -51,6 +53,7 @@ __all__ = [ "ProvisionerSandboxProvider", "VIRTUAL_PATH_PREFIX", "ensure_thread_dirs", + "ensure_workspace_default_files", "get_sandbox_provider", "init_sandbox_provider", "resolve_virtual_path", @@ -58,6 +61,7 @@ __all__ = [ "sandbox_outputs_dir", "sandbox_uploads_dir", "sandbox_user_data_dir", + "sandbox_workspace_agents_prompt_file", "sandbox_workspace_dir", "shutdown_sandbox_provider", "virtual_path_for_thread_file", diff --git a/backend/package/yuxi/agents/backends/sandbox/paths.py b/backend/package/yuxi/agents/backends/sandbox/paths.py index b7dd23b9..598d7fde 100644 --- a/backend/package/yuxi/agents/backends/sandbox/paths.py +++ b/backend/package/yuxi/agents/backends/sandbox/paths.py @@ -4,7 +4,15 @@ import re from pathlib import Path from yuxi import config as conf -from yuxi.utils.paths import OUTPUTS_DIR_NAME, UPLOADS_DIR_NAME, VIRTUAL_PATH_PREFIX, WORKSPACE_DIR_NAME +from yuxi.utils.logging_config import logger +from yuxi.utils.paths import ( + OUTPUTS_DIR_NAME, + UPLOADS_DIR_NAME, + VIRTUAL_PATH_PREFIX, + WORKSPACE_AGENTS_DIR_NAME, + WORKSPACE_AGENTS_PROMPT_FILE_NAME, + WORKSPACE_DIR_NAME, +) _SAFE_ID_RE = re.compile(r"^[A-Za-z0-9_-]+$") @@ -51,6 +59,33 @@ def sandbox_workspace_dir(thread_id: str, user_id: str) -> Path: return _global_user_data_dir(user_id) / WORKSPACE_DIR_NAME +def sandbox_workspace_agents_prompt_file(thread_id: str, user_id: str) -> Path: + return sandbox_workspace_dir(thread_id, user_id) / WORKSPACE_AGENTS_DIR_NAME / WORKSPACE_AGENTS_PROMPT_FILE_NAME + + +def ensure_workspace_default_files(workspace_dir: Path) -> None: + agents_dir = workspace_dir / WORKSPACE_AGENTS_DIR_NAME + agents_file = agents_dir / WORKSPACE_AGENTS_PROMPT_FILE_NAME + + try: + agents_dir.mkdir(parents=True, exist_ok=True) + except FileExistsError: + logger.warning("工作区默认 Agents 目录创建失败:路径已被文件占用") + return + except OSError as exc: + logger.warning(f"工作区默认 Agents 目录初始化失败: {exc}") + return + + try: + with agents_file.open("xb"): + pass + except FileExistsError: + if agents_file.is_dir(): + logger.warning("工作区默认 AGENTS.md 创建失败:路径已被目录占用") + except OSError as exc: + logger.warning(f"工作区默认 Agents 文件初始化失败: {exc}") + + def sandbox_uploads_dir(thread_id: str) -> Path: return _thread_root_dir(thread_id) / UPLOADS_DIR_NAME @@ -61,7 +96,9 @@ def sandbox_outputs_dir(thread_id: str) -> Path: def ensure_thread_dirs(thread_id: str, user_id: str) -> None: _global_user_data_dir(user_id).mkdir(parents=True, exist_ok=True) - sandbox_workspace_dir(thread_id, user_id).mkdir(parents=True, exist_ok=True) + workspace_dir = sandbox_workspace_dir(thread_id, user_id) + workspace_dir.mkdir(parents=True, exist_ok=True) + ensure_workspace_default_files(workspace_dir) sandbox_uploads_dir(thread_id).mkdir(parents=True, exist_ok=True) sandbox_outputs_dir(thread_id).mkdir(parents=True, exist_ok=True) diff --git a/backend/package/yuxi/services/chat_service.py b/backend/package/yuxi/services/chat_service.py index b9cb9def..3430a318 100644 --- a/backend/package/yuxi/services/chat_service.py +++ b/backend/package/yuxi/services/chat_service.py @@ -9,6 +9,7 @@ from typing import Any from langchain.messages import AIMessage, AIMessageChunk, HumanMessage from langgraph.types import Command from yuxi import config as conf +from yuxi.agents.backends.sandbox.paths import sandbox_workspace_agents_prompt_file from yuxi.agents.buildin import agent_manager from yuxi.agents.state import AgentStatePayload from yuxi.plugins.guard import content_guard @@ -30,6 +31,43 @@ from yuxi.utils.question_utils import ( normalize_questions as _normalize_interrupt_questions, ) +WORKSPACE_AGENTS_PROMPT_MAX_BYTES = 64 * 1024 + + +def _load_workspace_agents_prompt(thread_id: str, user_id: str) -> str: + prompt_file = sandbox_workspace_agents_prompt_file(thread_id, user_id) + try: + with prompt_file.open("rb") as buffer: + content = buffer.read(WORKSPACE_AGENTS_PROMPT_MAX_BYTES + 1) + except FileNotFoundError: + return "" + except IsADirectoryError: + logger.warning("读取工作区 AGENTS.md 失败: 路径是目录") + return "" + except OSError as exc: + logger.warning(f"读取工作区 AGENTS.md 失败: {exc}") + return "" + + prompt = content[:WORKSPACE_AGENTS_PROMPT_MAX_BYTES].decode("utf-8", errors="replace").strip() + if not prompt: + return "" + if len(content) > WORKSPACE_AGENTS_PROMPT_MAX_BYTES: + return f"{prompt}\n\n[AGENTS.md 内容已截断]" + return prompt + + +async def _build_agent_input_context(agent_config: dict, *, thread_id: str, user_id: str) -> dict: + input_context = dict(agent_config or {}) + agents_prompt = await asyncio.to_thread(_load_workspace_agents_prompt, thread_id, user_id) + + if agents_prompt: + agents_section = f"用户工作区 agents/AGENTS.md 内容:\n{agents_prompt}" + base_prompt = str(input_context.get("system_prompt") or "").rstrip() + input_context["system_prompt"] = f"{base_prompt}\n\n{agents_section}" if base_prompt else agents_section + + input_context.update({"user_id": user_id, "thread_id": thread_id}) + return input_context + def _build_state_files(attachments: list[dict]) -> dict: """将附件列表转换为 StateBackend 格式的 files 字典 @@ -560,7 +598,7 @@ async def agent_chat( thread_id = str(uuid.uuid4()) logger.warning(f"No thread_id provided, generated new thread_id: {thread_id}") - input_context = agent_config | {"user_id": user_id, "thread_id": thread_id} + input_context = await _build_agent_input_context(agent_config, thread_id=thread_id, user_id=user_id) langfuse_run = _build_langfuse_run_context( current_user=current_user, thread_id=thread_id, @@ -776,7 +814,7 @@ async def stream_agent_chat( thread_id = str(uuid.uuid4()) logger.warning(f"No thread_id provided, generated new thread_id: {thread_id}") - input_context = agent_config | {"user_id": user_id, "thread_id": thread_id} + input_context = await _build_agent_input_context(agent_config, thread_id=thread_id, user_id=user_id) langfuse_run = _build_langfuse_run_context( current_user=current_user, thread_id=thread_id, @@ -1011,8 +1049,7 @@ async def stream_agent_resume( return context = agent.context_schema() - context.update(agent_config or {}) - context.update({"user_id": user_id, "thread_id": thread_id}) + context.update(await _build_agent_input_context(agent_config or {}, thread_id=thread_id, user_id=user_id)) graph = await agent.get_graph(context=context) langfuse_run = _build_langfuse_run_context( current_user=current_user, diff --git a/backend/package/yuxi/services/workspace_service.py b/backend/package/yuxi/services/workspace_service.py index 081e218d..14fe0e74 100644 --- a/backend/package/yuxi/services/workspace_service.py +++ b/backend/package/yuxi/services/workspace_service.py @@ -12,7 +12,7 @@ import aiofiles from fastapi import HTTPException, UploadFile from fastapi.responses import FileResponse, StreamingResponse -from yuxi.agents.backends.sandbox.paths import _global_user_data_dir +from yuxi.agents.backends.sandbox.paths import _global_user_data_dir, ensure_workspace_default_files from yuxi.services.viewer_filesystem_service import _detect_preview_type from yuxi.storage.postgres.models_business import User from yuxi.utils.datetime_utils import utc_isoformat_from_timestamp @@ -25,7 +25,9 @@ def _workspace_root(user: User) -> Path: except ValueError as exc: raise HTTPException(status_code=403, detail="Access denied") from exc root.mkdir(parents=True, exist_ok=True) - return root.resolve() + resolved_root = root.resolve() + ensure_workspace_default_files(resolved_root) + return resolved_root def _normalize_workspace_path(path: str | None) -> PurePosixPath: diff --git a/backend/package/yuxi/utils/paths.py b/backend/package/yuxi/utils/paths.py index 8c91a0d9..304b5514 100644 --- a/backend/package/yuxi/utils/paths.py +++ b/backend/package/yuxi/utils/paths.py @@ -4,6 +4,8 @@ from yuxi import config VIRTUAL_PATH_PREFIX = config.sandbox_virtual_path_prefix WORKSPACE_DIR_NAME = "workspace" +WORKSPACE_AGENTS_DIR_NAME = "agents" +WORKSPACE_AGENTS_PROMPT_FILE_NAME = "AGENTS.md" UPLOADS_DIR_NAME = "uploads" OUTPUTS_DIR_NAME = "outputs" VIRTUAL_SKILLS_PATH = "/home/gem/skills" @@ -16,6 +18,8 @@ VIRTUAL_PATH_OUTPUTS = (Path(VIRTUAL_PATH_PREFIX) / OUTPUTS_DIR_NAME).as_posix() __all__ = [ "VIRTUAL_PATH_PREFIX", "WORKSPACE_DIR_NAME", + "WORKSPACE_AGENTS_DIR_NAME", + "WORKSPACE_AGENTS_PROMPT_FILE_NAME", "UPLOADS_DIR_NAME", "OUTPUTS_DIR_NAME", "VIRTUAL_PATH_WORKSPACE", diff --git a/backend/test/unit/services/test_chat_service_sync.py b/backend/test/unit/services/test_chat_service_sync.py index d00ac9c9..16a14763 100644 --- a/backend/test/unit/services/test_chat_service_sync.py +++ b/backend/test/unit/services/test_chat_service_sync.py @@ -8,6 +8,21 @@ from langchain.messages import AIMessage, HumanMessage from yuxi.services import chat_service as svc +def _empty_agents_prompt(_thread_id: str, _user_id: str) -> str: + return "" + + +class _FakeAgentConfigRepo: + def __init__(self, _db): + pass + + async def get_by_id(self, config_id: int): + return SimpleNamespace(id=config_id) + + async def get_or_create_default(self, *, department_id: str, agent_id: str, created_by: str): + return SimpleNamespace(id=999, department_id=department_id, agent_id=agent_id, created_by=created_by) + + class _FakeConvRepo: def __init__(self, _db): self.saved_messages: list[dict] = [] @@ -112,10 +127,12 @@ async def test_agent_chat_uses_invoke_messages_and_persists_langgraph_state(monk monkeypatch.setattr(svc, "_build_langfuse_run_context", fake_build_langfuse_run_context) monkeypatch.setattr(svc, "get_trace_info", fake_get_trace_info) monkeypatch.setattr(svc, "flush_langfuse", lambda: calls.setdefault("flushed", True)) + monkeypatch.setattr(svc, "_load_workspace_agents_prompt", _empty_agents_prompt) monkeypatch.setattr(svc.agent_manager, "get_agent", lambda agent_id: FakeAgent()) monkeypatch.setattr(svc, "get_agent_config_by_id", fake_get_agent_config_by_id) monkeypatch.setattr(svc, "ConversationRepository", _FakeConvRepo) + monkeypatch.setattr(svc, "AgentConfigRepository", _FakeAgentConfigRepo) monkeypatch.setattr(svc, "save_messages_from_langgraph_state", fake_save_messages_from_langgraph_state) monkeypatch.setattr(svc.content_guard, "check", fake_guard_check) @@ -193,10 +210,12 @@ async def test_agent_chat_sync_returns_finished_even_when_state_has_interrupt(mo ) monkeypatch.setattr(svc, "get_trace_info", lambda _run_context: {}) monkeypatch.setattr(svc, "flush_langfuse", lambda: None) + monkeypatch.setattr(svc, "_load_workspace_agents_prompt", _empty_agents_prompt) monkeypatch.setattr(svc.agent_manager, "get_agent", lambda agent_id: FakeAgent()) monkeypatch.setattr(svc, "get_agent_config_by_id", fake_get_agent_config_by_id) monkeypatch.setattr(svc, "ConversationRepository", _FakeConvRepo) + monkeypatch.setattr(svc, "AgentConfigRepository", _FakeAgentConfigRepo) monkeypatch.setattr(svc, "save_messages_from_langgraph_state", fake_save_messages_from_langgraph_state) monkeypatch.setattr(svc.content_guard, "check", fake_guard_check) @@ -214,3 +233,37 @@ async def test_agent_chat_sync_returns_finished_even_when_state_has_interrupt(mo assert result["response"] == "Need input later" assert result["thread_id"] == "thread-2" assert result["request_id"] == "req-2" + + +@pytest.mark.asyncio +async def test_build_agent_input_context_merges_workspace_agents_prompt(monkeypatch: pytest.MonkeyPatch): + def fake_agents_prompt(_thread_id: str, _user_id: str) -> str: + return "回答前先读取 AGENTS.md" + + monkeypatch.setattr(svc, "_load_workspace_agents_prompt", fake_agents_prompt) + + context = await svc._build_agent_input_context( + {"system_prompt": "原始系统提示词", "temperature": 0.1}, + thread_id="thread-1", + user_id="user-1", + ) + + assert context["system_prompt"] == "原始系统提示词\n\n用户工作区 agents/AGENTS.md 内容:\n回答前先读取 AGENTS.md" + assert context["temperature"] == 0.1 + assert context["thread_id"] == "thread-1" + assert context["user_id"] == "user-1" + + +@pytest.mark.asyncio +async def test_build_agent_input_context_keeps_prompt_when_workspace_agents_prompt_empty( + monkeypatch: pytest.MonkeyPatch, +): + monkeypatch.setattr(svc, "_load_workspace_agents_prompt", _empty_agents_prompt) + + context = await svc._build_agent_input_context( + {"system_prompt": "原始系统提示词"}, + thread_id="thread-1", + user_id="user-1", + ) + + assert context["system_prompt"] == "原始系统提示词" diff --git a/backend/test/unit/services/test_workspace_service.py b/backend/test/unit/services/test_workspace_service.py new file mode 100644 index 00000000..38d3b0ee --- /dev/null +++ b/backend/test/unit/services/test_workspace_service.py @@ -0,0 +1,40 @@ +from __future__ import annotations + +from pathlib import Path +from types import SimpleNamespace + +from yuxi.agents.backends.sandbox import paths as workspace_paths +from yuxi.services import workspace_service as svc + + +def test_workspace_root_creates_default_agents_prompt_file(tmp_path: Path, monkeypatch) -> None: + monkeypatch.setattr(workspace_paths.conf, "save_dir", str(tmp_path)) + + root = svc._workspace_root(SimpleNamespace(id="user-1")) + + agents_file = root / "agents" / "AGENTS.md" + assert agents_file.is_file() + assert agents_file.read_text(encoding="utf-8") == "" + + +def test_ensure_thread_dirs_creates_default_agents_prompt_file(tmp_path: Path, monkeypatch) -> None: + monkeypatch.setattr(workspace_paths.conf, "save_dir", str(tmp_path)) + + workspace_paths.ensure_thread_dirs("thread-1", "user-1") + + agents_file = tmp_path / "threads" / "shared" / "user-1" / "workspace" / "agents" / "AGENTS.md" + assert agents_file.is_file() + assert agents_file.read_text(encoding="utf-8") == "" + + +def test_workspace_root_keeps_existing_agents_prompt_file(tmp_path: Path, monkeypatch) -> None: + monkeypatch.setattr(workspace_paths.conf, "save_dir", str(tmp_path)) + agents_dir = tmp_path / "threads" / "shared" / "user-1" / "workspace" / "agents" + agents_dir.mkdir(parents=True) + agents_file = agents_dir / "AGENTS.md" + agents_file.write_text("保留已有内容", encoding="utf-8") + + root = svc._workspace_root(SimpleNamespace(id="user-1")) + + assert root == tmp_path / "threads" / "shared" / "user-1" / "workspace" + assert agents_file.read_text(encoding="utf-8") == "保留已有内容" diff --git a/docs/develop-guides/roadmap.md b/docs/develop-guides/roadmap.md index 10fd42d1..f7bdb033 100644 --- a/docs/develop-guides/roadmap.md +++ b/docs/develop-guides/roadmap.md @@ -37,7 +37,7 @@ ### 0.6.2 开发记录 -- 新增个人工作区预览与管理:提供独立于对话 thread 的用户级 workspace API,并增加“工作区”页面,用于浏览个人 workspace 文件、预览 Markdown/文本/代码/图片/PDF;支持新建文件夹、上传文件、下载文件、删除文件/文件夹和多选删除;知识库与团队空间入口先展示到占位层级。 +- 新增个人工作区预览与管理:提供独立于对话 thread 的用户级 workspace API,并增加“工作区”页面,用于浏览个人 workspace 文件、预览 Markdown/文本/代码/图片/PDF;支持新建文件夹、上传文件、下载文件、删除文件/文件夹和多选删除;知识库与团队空间入口先展示到占位层级;默认创建 `agents/AGENTS.md`,并在 Agent 执行时将其内容追加到系统提示词。 - 扩展管理界面交互逻辑重构:将 MCP / Subagents / Skills 三个标签页从「左侧边栏 + 右侧详情面板」布局重构为「卡片式网格布局 + 路由跳转二级页面」布局,工具标签页改为卡片网格布局 + 弹窗详情(保持弹窗内容不变)。新增共享组件 `ExtensionCard`、`ExtensionCardGrid`、`ExtensionToolbar`、`ExtensionDetailLayout`,详情页(`McpDetailView`、`SubagentDetailView`、`SkillDetailView`)使用居中宽度限制,路由规划为 `/extensions/mcp/:name`、`/extensions/subagent/:name`、`/extensions/skill/:slug`。 - 统一卡片样式:`ExtensionCard` 新增 `tags` prop 支持传入 `[{label, color}]` 数组,内部使用 `` 渲染,与知识库卡片标签风格统一;知识库列表页 `DataBaseView` 改用 `ExtensionCard` + `ExtensionCardGrid` 替代原有自定义卡片,移除冗余 card 样式。 - 调整应用主导航:`AppLayout` 从默认窄栏升级为默认展开的侧边栏,保留折叠态图标导航;侧边栏样式收敛为 14px 文本 + 18px 图标的标准紧凑密度,并统一导航项、任务中心、GitHub、用户信息的图标与文字对齐。折叠态改为仅通过显式按钮展开,避免空白区域误触发。 diff --git a/web/src/components/workspace/WorkspaceSidebar.vue b/web/src/components/workspace/WorkspaceSidebar.vue index 5be7eafb..832e3dff 100644 --- a/web/src/components/workspace/WorkspaceSidebar.vue +++ b/web/src/components/workspace/WorkspaceSidebar.vue @@ -4,7 +4,7 @@ +