feat(workspace): 增强工作区功能,新增默认 agents 文件创建及路径管理

This commit is contained in:
Wenjie Zhang 2026-05-01 21:00:58 +08:00
parent 6fcfb090de
commit 981fd3170d
10 changed files with 210 additions and 12 deletions

View File

@ -101,6 +101,8 @@ make format # 格式化代码
- 尽量使用较新的语法,避免使用旧版本的语法(版本兼容到 3.12+
- 更新 [roadmap.md](docs/develop-guides/roadmap.md) 文档记录本次修改,多个类似的功能更新已经补充在一起
- 开发完成后务必在 docker 中进行测试,可以读取 .env 获取管理员账户和密码
- 不允许把代码写得稀碎:不要为简单线性逻辑拆出一堆细碎 helper优先写成职责清晰、结构完整、可一眼读懂的实现。
- 拆函数必须服务于明确的复用、隔离副作用或降低认知负担;如果拆分后调用链更绕、上下文更分散,就应合并回更直接的实现。
**其他**

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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"] == "原始系统提示词"

View File

@ -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") == "保留已有内容"

View File

@ -37,7 +37,7 @@
### 0.6.2 开发记录
<!-- 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}]` 数组,内部使用 `<a-tag bordered=false size=small>` 渲染,与知识库卡片标签风格统一;知识库列表页 `DataBaseView` 改用 `ExtensionCard` + `ExtensionCardGrid` 替代原有自定义卡片,移除冗余 card 样式。
- 调整应用主导航:`AppLayout` 从默认窄栏升级为默认展开的侧边栏,保留折叠态图标导航;侧边栏样式收敛为 14px 文本 + 18px 图标的标准紧凑密度并统一导航项、任务中心、GitHub、用户信息的图标与文字对齐。折叠态改为仅通过显式按钮展开避免空白区域误触发。

View File

@ -4,7 +4,7 @@
<button
type="button"
class="workspace-nav-item"
:class="{ active: activeKey === 'personal' && currentPath !== savedArtifactsPath }"
:class="{ active: activeKey === 'personal' && !isQuickAccessPath(currentPath) }"
@click="$emit('select-personal')"
>
<FolderKanban :size="16" />
@ -17,12 +17,21 @@
<button
type="button"
class="workspace-nav-item secondary"
:class="{ active: activeKey === 'personal' && currentPath === savedArtifactsPath }"
:class="{ active: activeKey === 'personal' && isSameOrChildPath(currentPath, savedArtifactsPath) }"
@click="$emit('select-path', savedArtifactsPath)"
>
<Archive :size="15" />
<span>Saved Artifacts</span>
</button>
<button
type="button"
class="workspace-nav-item secondary"
:class="{ active: activeKey === 'personal' && isSameOrChildPath(currentPath, agentsPath) }"
@click="$emit('select-path', agentsPath)"
>
<Bot :size="15" />
<span>Agents</span>
</button>
</section>
<section class="sidebar-section">
@ -54,9 +63,19 @@
</template>
<script setup>
import { Archive, FolderKanban, LibraryBig, UsersRound } from 'lucide-vue-next'
import { Archive, Bot, FolderKanban, LibraryBig, UsersRound } from 'lucide-vue-next'
const savedArtifactsPath = '/saved_artifacts'
const agentsPath = '/agents/'
const quickAccessPaths = [savedArtifactsPath, agentsPath]
const normalizePath = (path) => String(path || '/').replace(/\/$/, '') || '/'
const isSameOrChildPath = (path, targetPath) => {
const current = normalizePath(path)
const target = normalizePath(targetPath)
return current === target || current.startsWith(`${target}/`)
}
const isQuickAccessPath = (path) => quickAccessPaths.some((targetPath) => isSameOrChildPath(path, targetPath))
defineProps({
activeKey: { type: String, default: 'personal' },