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

View File

@ -2,10 +2,12 @@ from .backend import ProvisionerSandboxBackend
from .paths import ( from .paths import (
VIRTUAL_PATH_PREFIX, VIRTUAL_PATH_PREFIX,
ensure_thread_dirs, ensure_thread_dirs,
ensure_workspace_default_files,
resolve_virtual_path, resolve_virtual_path,
sandbox_outputs_dir, sandbox_outputs_dir,
sandbox_uploads_dir, sandbox_uploads_dir,
sandbox_user_data_dir, sandbox_user_data_dir,
sandbox_workspace_agents_prompt_file,
sandbox_workspace_dir, sandbox_workspace_dir,
virtual_path_for_thread_file, virtual_path_for_thread_file,
) )
@ -51,6 +53,7 @@ __all__ = [
"ProvisionerSandboxProvider", "ProvisionerSandboxProvider",
"VIRTUAL_PATH_PREFIX", "VIRTUAL_PATH_PREFIX",
"ensure_thread_dirs", "ensure_thread_dirs",
"ensure_workspace_default_files",
"get_sandbox_provider", "get_sandbox_provider",
"init_sandbox_provider", "init_sandbox_provider",
"resolve_virtual_path", "resolve_virtual_path",
@ -58,6 +61,7 @@ __all__ = [
"sandbox_outputs_dir", "sandbox_outputs_dir",
"sandbox_uploads_dir", "sandbox_uploads_dir",
"sandbox_user_data_dir", "sandbox_user_data_dir",
"sandbox_workspace_agents_prompt_file",
"sandbox_workspace_dir", "sandbox_workspace_dir",
"shutdown_sandbox_provider", "shutdown_sandbox_provider",
"virtual_path_for_thread_file", "virtual_path_for_thread_file",

View File

@ -4,7 +4,15 @@ import re
from pathlib import Path from pathlib import Path
from yuxi import config as conf 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_-]+$") _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 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: def sandbox_uploads_dir(thread_id: str) -> Path:
return _thread_root_dir(thread_id) / UPLOADS_DIR_NAME 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: def ensure_thread_dirs(thread_id: str, user_id: str) -> None:
_global_user_data_dir(user_id).mkdir(parents=True, exist_ok=True) _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_uploads_dir(thread_id).mkdir(parents=True, exist_ok=True)
sandbox_outputs_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 langchain.messages import AIMessage, AIMessageChunk, HumanMessage
from langgraph.types import Command from langgraph.types import Command
from yuxi import config as conf 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.buildin import agent_manager
from yuxi.agents.state import AgentStatePayload from yuxi.agents.state import AgentStatePayload
from yuxi.plugins.guard import content_guard from yuxi.plugins.guard import content_guard
@ -30,6 +31,43 @@ from yuxi.utils.question_utils import (
normalize_questions as _normalize_interrupt_questions, 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: def _build_state_files(attachments: list[dict]) -> dict:
"""将附件列表转换为 StateBackend 格式的 files 字典 """将附件列表转换为 StateBackend 格式的 files 字典
@ -560,7 +598,7 @@ async def agent_chat(
thread_id = str(uuid.uuid4()) thread_id = str(uuid.uuid4())
logger.warning(f"No thread_id provided, generated new thread_id: {thread_id}") 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( langfuse_run = _build_langfuse_run_context(
current_user=current_user, current_user=current_user,
thread_id=thread_id, thread_id=thread_id,
@ -776,7 +814,7 @@ async def stream_agent_chat(
thread_id = str(uuid.uuid4()) thread_id = str(uuid.uuid4())
logger.warning(f"No thread_id provided, generated new thread_id: {thread_id}") 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( langfuse_run = _build_langfuse_run_context(
current_user=current_user, current_user=current_user,
thread_id=thread_id, thread_id=thread_id,
@ -1011,8 +1049,7 @@ async def stream_agent_resume(
return return
context = agent.context_schema() context = agent.context_schema()
context.update(agent_config or {}) context.update(await _build_agent_input_context(agent_config or {}, thread_id=thread_id, user_id=user_id))
context.update({"user_id": user_id, "thread_id": thread_id})
graph = await agent.get_graph(context=context) graph = await agent.get_graph(context=context)
langfuse_run = _build_langfuse_run_context( langfuse_run = _build_langfuse_run_context(
current_user=current_user, current_user=current_user,

View File

@ -12,7 +12,7 @@ import aiofiles
from fastapi import HTTPException, UploadFile from fastapi import HTTPException, UploadFile
from fastapi.responses import FileResponse, StreamingResponse 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.services.viewer_filesystem_service import _detect_preview_type
from yuxi.storage.postgres.models_business import User from yuxi.storage.postgres.models_business import User
from yuxi.utils.datetime_utils import utc_isoformat_from_timestamp from yuxi.utils.datetime_utils import utc_isoformat_from_timestamp
@ -25,7 +25,9 @@ def _workspace_root(user: User) -> Path:
except ValueError as exc: except ValueError as exc:
raise HTTPException(status_code=403, detail="Access denied") from exc raise HTTPException(status_code=403, detail="Access denied") from exc
root.mkdir(parents=True, exist_ok=True) 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: 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 VIRTUAL_PATH_PREFIX = config.sandbox_virtual_path_prefix
WORKSPACE_DIR_NAME = "workspace" WORKSPACE_DIR_NAME = "workspace"
WORKSPACE_AGENTS_DIR_NAME = "agents"
WORKSPACE_AGENTS_PROMPT_FILE_NAME = "AGENTS.md"
UPLOADS_DIR_NAME = "uploads" UPLOADS_DIR_NAME = "uploads"
OUTPUTS_DIR_NAME = "outputs" OUTPUTS_DIR_NAME = "outputs"
VIRTUAL_SKILLS_PATH = "/home/gem/skills" VIRTUAL_SKILLS_PATH = "/home/gem/skills"
@ -16,6 +18,8 @@ VIRTUAL_PATH_OUTPUTS = (Path(VIRTUAL_PATH_PREFIX) / OUTPUTS_DIR_NAME).as_posix()
__all__ = [ __all__ = [
"VIRTUAL_PATH_PREFIX", "VIRTUAL_PATH_PREFIX",
"WORKSPACE_DIR_NAME", "WORKSPACE_DIR_NAME",
"WORKSPACE_AGENTS_DIR_NAME",
"WORKSPACE_AGENTS_PROMPT_FILE_NAME",
"UPLOADS_DIR_NAME", "UPLOADS_DIR_NAME",
"OUTPUTS_DIR_NAME", "OUTPUTS_DIR_NAME",
"VIRTUAL_PATH_WORKSPACE", "VIRTUAL_PATH_WORKSPACE",

View File

@ -8,6 +8,21 @@ from langchain.messages import AIMessage, HumanMessage
from yuxi.services import chat_service as svc 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: class _FakeConvRepo:
def __init__(self, _db): def __init__(self, _db):
self.saved_messages: list[dict] = [] 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, "_build_langfuse_run_context", fake_build_langfuse_run_context)
monkeypatch.setattr(svc, "get_trace_info", fake_get_trace_info) monkeypatch.setattr(svc, "get_trace_info", fake_get_trace_info)
monkeypatch.setattr(svc, "flush_langfuse", lambda: calls.setdefault("flushed", True)) 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.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, "get_agent_config_by_id", fake_get_agent_config_by_id)
monkeypatch.setattr(svc, "ConversationRepository", _FakeConvRepo) 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, "save_messages_from_langgraph_state", fake_save_messages_from_langgraph_state)
monkeypatch.setattr(svc.content_guard, "check", fake_guard_check) 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, "get_trace_info", lambda _run_context: {})
monkeypatch.setattr(svc, "flush_langfuse", lambda: None) 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.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, "get_agent_config_by_id", fake_get_agent_config_by_id)
monkeypatch.setattr(svc, "ConversationRepository", _FakeConvRepo) 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, "save_messages_from_langgraph_state", fake_save_messages_from_langgraph_state)
monkeypatch.setattr(svc.content_guard, "check", fake_guard_check) 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["response"] == "Need input later"
assert result["thread_id"] == "thread-2" assert result["thread_id"] == "thread-2"
assert result["request_id"] == "req-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 开发记录
<!-- 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`。 - 扩展管理界面交互逻辑重构:将 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 样式。 - 统一卡片样式:`ExtensionCard` 新增 `tags` prop 支持传入 `[{label, color}]` 数组,内部使用 `<a-tag bordered=false size=small>` 渲染,与知识库卡片标签风格统一;知识库列表页 `DataBaseView` 改用 `ExtensionCard` + `ExtensionCardGrid` 替代原有自定义卡片,移除冗余 card 样式。
- 调整应用主导航:`AppLayout` 从默认窄栏升级为默认展开的侧边栏,保留折叠态图标导航;侧边栏样式收敛为 14px 文本 + 18px 图标的标准紧凑密度并统一导航项、任务中心、GitHub、用户信息的图标与文字对齐。折叠态改为仅通过显式按钮展开避免空白区域误触发。 - 调整应用主导航:`AppLayout` 从默认窄栏升级为默认展开的侧边栏,保留折叠态图标导航;侧边栏样式收敛为 14px 文本 + 18px 图标的标准紧凑密度并统一导航项、任务中心、GitHub、用户信息的图标与文字对齐。折叠态改为仅通过显式按钮展开避免空白区域误触发。

View File

@ -4,7 +4,7 @@
<button <button
type="button" type="button"
class="workspace-nav-item" class="workspace-nav-item"
:class="{ active: activeKey === 'personal' && currentPath !== savedArtifactsPath }" :class="{ active: activeKey === 'personal' && !isQuickAccessPath(currentPath) }"
@click="$emit('select-personal')" @click="$emit('select-personal')"
> >
<FolderKanban :size="16" /> <FolderKanban :size="16" />
@ -17,12 +17,21 @@
<button <button
type="button" type="button"
class="workspace-nav-item secondary" class="workspace-nav-item secondary"
:class="{ active: activeKey === 'personal' && currentPath === savedArtifactsPath }" :class="{ active: activeKey === 'personal' && isSameOrChildPath(currentPath, savedArtifactsPath) }"
@click="$emit('select-path', savedArtifactsPath)" @click="$emit('select-path', savedArtifactsPath)"
> >
<Archive :size="15" /> <Archive :size="15" />
<span>Saved Artifacts</span> <span>Saved Artifacts</span>
</button> </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>
<section class="sidebar-section"> <section class="sidebar-section">
@ -54,9 +63,19 @@
</template> </template>
<script setup> <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 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({ defineProps({
activeKey: { type: String, default: 'personal' }, activeKey: { type: String, default: 'personal' },