feat(workspace): 增强工作区功能,新增默认 agents 文件创建及路径管理
This commit is contained in:
parent
6fcfb090de
commit
981fd3170d
@ -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;优先写成职责清晰、结构完整、可一眼读懂的实现。
|
||||||
|
- 拆函数必须服务于明确的复用、隔离副作用或降低认知负担;如果拆分后调用链更绕、上下文更分散,就应合并回更直接的实现。
|
||||||
|
|
||||||
**其他**:
|
**其他**:
|
||||||
|
|
||||||
|
|||||||
@ -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",
|
||||||
|
|||||||
@ -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)
|
||||||
|
|
||||||
|
|||||||
@ -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,
|
||||||
|
|||||||
@ -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:
|
||||||
|
|||||||
@ -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",
|
||||||
|
|||||||
@ -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"] == "原始系统提示词"
|
||||||
|
|||||||
40
backend/test/unit/services/test_workspace_service.py
Normal file
40
backend/test/unit/services/test_workspace_service.py
Normal 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") == "保留已有内容"
|
||||||
@ -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、用户信息的图标与文字对齐。折叠态改为仅通过显式按钮展开,避免空白区域误触发。
|
||||||
|
|||||||
@ -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' },
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user