diff --git a/.gitignore b/.gitignore index f6a26b2d..1229be8b 100644 --- a/.gitignore +++ b/.gitignore @@ -32,6 +32,11 @@ cache *.bak *._* +# test +.pytest_cache +.playwright +.playwright-cli + ### IDE .vscode diff --git a/backend/package/yuxi/agents/buildin/chatbot/graph.py b/backend/package/yuxi/agents/buildin/chatbot/graph.py index a8025517..eed516be 100644 --- a/backend/package/yuxi/agents/buildin/chatbot/graph.py +++ b/backend/package/yuxi/agents/buildin/chatbot/graph.py @@ -4,7 +4,7 @@ from deepagents.middleware.subagents import SubAgentMiddleware from langchain.agents import create_agent from langchain.agents.middleware import ModelRetryMiddleware -from yuxi.agents import BaseAgent, load_chat_model +from yuxi.agents import BaseAgent, BaseState, load_chat_model from yuxi.agents.backends import create_agent_composite_backend from yuxi.agents.middlewares import ( RuntimeConfigMiddleware, @@ -88,6 +88,7 @@ class ChatbotAgent(BaseAgent): model=load_chat_model(fully_specified_name=context.model), system_prompt=system_prompt.strip(), middleware=await _build_middlewares(context), + state_schema=BaseState, checkpointer=await self._get_checkpointer(), ) diff --git a/backend/package/yuxi/agents/buildin/chatbot/prompt.py b/backend/package/yuxi/agents/buildin/chatbot/prompt.py index cc57c243..890795b6 100644 --- a/backend/package/yuxi/agents/buildin/chatbot/prompt.py +++ b/backend/package/yuxi/agents/buildin/chatbot/prompt.py @@ -2,12 +2,14 @@ PROMPT = """ 你是一个人工智能助手 “语析”,专门用来回答用户的问题。请根据用户提供的信息,尽可能详细地回答问题。 如果你不确定答案,可以说你不知道,但请尽量提供相关的信息或建议。请保持礼貌和专业。 -允许的写入路径为 /home/gem/user-data/,请将需要保存的文件写入该目录下。 -推荐使用的路径: -- /home/gem/user-data/workspace/:用于存放工作文件和中间结果 -- /home/gem/user-data/outputs/:用于存放最终输出结果 +系统主要工作路径为 /home/gem/user-data/,但必须遵守规范: +- /home/gem/user-data/workspace/:用于存放工作文件(用户目录,不要轻易写入) +- /home/gem/user-data/outputs/:用于写入的文件夹 + - /home/gem/user-data/outputs/tmp/:用于存放中间结果或备份内容 - /home/gem/user-data/uploads/:用于存放用户上传的文件 +非必要不写入其他路径 + 如果启用了知识库,除了使用知识库工具之外, 当需要精准获取信息的时候,或者 query_kb 中没有找到相关的内容,还可以直接访问知识库文件系统 (路径为 /home/gem/kbs/)来获取信息。 diff --git a/backend/package/yuxi/agents/buildin/deep_agent/graph.py b/backend/package/yuxi/agents/buildin/deep_agent/graph.py index 6bcceeda..12d1ef43 100644 --- a/backend/package/yuxi/agents/buildin/deep_agent/graph.py +++ b/backend/package/yuxi/agents/buildin/deep_agent/graph.py @@ -7,7 +7,7 @@ from langchain.agents.middleware import ( ToolCallLimitMiddleware, ) -from yuxi.agents import BaseAgent, load_chat_model +from yuxi.agents import BaseAgent, BaseState, load_chat_model from yuxi.agents.backends import create_agent_composite_backend from yuxi.agents.middlewares import ( RuntimeConfigMiddleware, @@ -116,6 +116,7 @@ class DeepAgent(BaseAgent): exit_behavior="end", ), ], + state_schema=BaseState, checkpointer=await self._get_checkpointer(), ) diff --git a/backend/package/yuxi/agents/state.py b/backend/package/yuxi/agents/state.py index 981d013f..c2616523 100644 --- a/backend/package/yuxi/agents/state.py +++ b/backend/package/yuxi/agents/state.py @@ -2,19 +2,29 @@ from __future__ import annotations -from collections.abc import Sequence -from dataclasses import dataclass, field -from typing import Annotated +from typing import Annotated, TypedDict -from langchain.messages import AnyMessage -from langgraph.graph import add_messages +from langchain.agents import AgentState -@dataclass -class BaseState: - """Defines the input state for the agent, representing a narrower interface to the outside world. +def merge_artifacts(existing: list[str] | None, new: list[str] | None) -> list[str]: + """Merge artifact file paths while preserving order and removing duplicates.""" + if existing is None: + return new or [] + if new is None: + return existing + return list(dict.fromkeys(existing + new)) - This class is used to define the initial state and structure of incoming data. - """ - messages: Annotated[Sequence[AnyMessage], add_messages] = field(default_factory=list) +class BaseState(AgentState): + """Shared state fields for Yuxi agents.""" + + artifacts: Annotated[list[str], merge_artifacts] + + +class AgentStatePayload(TypedDict): + """Serialized agent state payload consumed by the frontend.""" + + todos: list + files: dict + artifacts: list[str] diff --git a/backend/package/yuxi/agents/toolkits/buildin/__init__.py b/backend/package/yuxi/agents/toolkits/buildin/__init__.py index cccfce3c..29c5f3f5 100644 --- a/backend/package/yuxi/agents/toolkits/buildin/__init__.py +++ b/backend/package/yuxi/agents/toolkits/buildin/__init__.py @@ -1,9 +1,10 @@ # buildin 工具包 -from .tools import ask_user_question, calculator, query_knowledge_graph, text_to_img_qwen_image +from .tools import ask_user_question, calculator, present_artifacts, query_knowledge_graph, text_to_img_qwen_image __all__ = [ "ask_user_question", "calculator", + "present_artifacts", "query_knowledge_graph", "text_to_img_qwen_image", ] diff --git a/backend/package/yuxi/agents/toolkits/buildin/tools.py b/backend/package/yuxi/agents/toolkits/buildin/tools.py index 78c97a36..b28b066b 100644 --- a/backend/package/yuxi/agents/toolkits/buildin/tools.py +++ b/backend/package/yuxi/agents/toolkits/buildin/tools.py @@ -1,10 +1,16 @@ import os import traceback import uuid +from pathlib import Path from typing import Annotated, Any import requests +from langchain.tools import InjectedToolCallId +from langchain_core.messages import ToolMessage +from langgraph.prebuilt.tool_node import ToolRuntime +from langgraph.types import Command from langgraph.types import interrupt +from pydantic import BaseModel, Field from yuxi import config, graph_base from yuxi.agents.toolkits.registry import ToolExtraMetadata, _all_tool_instances, _extra_registry, tool @@ -49,6 +55,52 @@ if config.enable_web_search: logger.warning(f"Failed to register TavilySearch tool: {e}") +class PresentArtifactsInput(BaseModel): + """Expose artifact files to the frontend after the agent finishes.""" + + filepaths: list[str] = Field( + description="需要展示给用户的文件绝对路径列表,只允许位于 /home/gem/user-data/outputs/ 下" + ) + + +def _normalize_presented_artifact_path(filepath: str, runtime: ToolRuntime) -> str: + from yuxi.agents.backends.sandbox.paths import ( + VIRTUAL_PATH_PREFIX, + ensure_thread_dirs, + resolve_virtual_path, + sandbox_outputs_dir, + ) + + outputs_virtual_prefix = f"{VIRTUAL_PATH_PREFIX}/outputs" + runtime_context = runtime.context + thread_id = getattr(runtime_context, "thread_id", None) + if not thread_id: + raise ValueError("当前运行时缺少 thread_id") + + ensure_thread_dirs(thread_id) + outputs_dir = sandbox_outputs_dir(thread_id).resolve() + normalized_input = str(filepath or "").strip() + if not normalized_input: + raise ValueError("文件路径不能为空") + + stripped = normalized_input.lstrip("/") + virtual_prefix = VIRTUAL_PATH_PREFIX.lstrip("/") + if stripped == virtual_prefix or stripped.startswith(f"{virtual_prefix}/"): + actual_path = resolve_virtual_path(thread_id, normalized_input) + else: + actual_path = Path(normalized_input).expanduser().resolve() + + if not actual_path.exists() or not actual_path.is_file(): + raise ValueError(f"文件不存在或不是普通文件: {normalized_input}") + + try: + relative_path = actual_path.relative_to(outputs_dir) + except ValueError as exc: + raise ValueError(f"只允许展示 {outputs_virtual_prefix}/ 下的文件: {normalized_input}") from exc + + return f"{outputs_virtual_prefix}/{relative_path.as_posix()}" + + @tool(category="buildin", tags=["计算"], display_name="计算器") def calculator(a: float, b: float, operation: str) -> float: """计算器:对给定的2个数字进行基本数学运算""" @@ -70,6 +122,47 @@ def calculator(a: float, b: float, operation: str) -> float: raise +PRESENT_ARTIFACTS_DESCRIPTION = """ +将已经生成好的结果文件展示给用户。 + +使用场景: +1. 你已经在 `/home/gem/user-data/outputs/` 下写好了最终结果文件 +2. 你希望前端在对话结束后显示这些结果文件卡片 +3. 这些文件需要支持下载或预览 + +注意事项: +1. 只能传入 `/home/gem/user-data/outputs/` 下的文件 +2. 不要传入中间过程文件,只有真正需要给用户看的结果文件才调用 +3. 可以一次传多个文件 +""" + + +@tool( + category="buildin", + tags=["文件", "交付物"], + display_name="展示交付物", + description=PRESENT_ARTIFACTS_DESCRIPTION, + args_schema=PresentArtifactsInput, +) +def present_artifacts( + filepaths: list[str], + runtime: ToolRuntime, + tool_call_id: Annotated[str, InjectedToolCallId], +) -> Command: + """登记当前线程 outputs 目录下的交付物文件,使前端在对话结束后展示给用户。""" + try: + normalized_paths = [_normalize_presented_artifact_path(filepath, runtime) for filepath in filepaths] + except ValueError as exc: + return Command(update={"messages": [ToolMessage(content=f"Error: {exc}", tool_call_id=tool_call_id)]}) + + return Command( + update={ + "artifacts": normalized_paths, + "messages": [ToolMessage(content="已将交付物展示给用户", tool_call_id=tool_call_id)], + } + ) + + ASK_USER_QUESTION_DESCRIPTION = """ 在执行过程中,当你需要用户做决定或补充需求时,使用这个工具向用户提问。 diff --git a/backend/package/yuxi/services/chat_service.py b/backend/package/yuxi/services/chat_service.py index 72fadb55..98c5d733 100644 --- a/backend/package/yuxi/services/chat_service.py +++ b/backend/package/yuxi/services/chat_service.py @@ -10,6 +10,7 @@ from langchain.messages import AIMessage, AIMessageChunk, HumanMessage from langgraph.types import Command from yuxi import config as conf from yuxi.agents.buildin import agent_manager +from yuxi.agents.state import AgentStatePayload from yuxi.plugins.guard import content_guard from yuxi.repositories.agent_config_repository import AgentConfigRepository from yuxi.repositories.conversation_repository import ConversationRepository @@ -70,16 +71,18 @@ async def _get_langgraph_messages(agent_instance, config_dict): return state.values.get("messages", []) -def extract_agent_state(values: dict) -> dict: +def extract_agent_state(values: dict) -> AgentStatePayload: """从 LangGraph state 中提取 agent 状态""" if not isinstance(values, dict): - return {} + return {"todos": [], "files": {}, "artifacts": []} # 直接获取,信任 state 的数据结构 todos = values.get("todos") - result = { + artifacts = values.get("artifacts") + result: AgentStatePayload = { "todos": list(todos)[:20] if todos else [], "files": values.get("files") or {}, + "artifacts": list(artifacts) if artifacts else [], } return result diff --git a/backend/test/test_agent_artifacts_state.py b/backend/test/test_agent_artifacts_state.py new file mode 100644 index 00000000..49dfcb53 --- /dev/null +++ b/backend/test/test_agent_artifacts_state.py @@ -0,0 +1,77 @@ +from yuxi.agents.backends.sandbox import ( + VIRTUAL_PATH_PREFIX, + ensure_thread_dirs, + sandbox_outputs_dir, + sandbox_uploads_dir, +) +from yuxi.agents.state import merge_artifacts +from yuxi.agents.toolkits.buildin.tools import _normalize_presented_artifact_path +from yuxi.services.chat_service import extract_agent_state + + +def _runtime_with_thread(thread_id: str): + context = type("RuntimeContext", (), {"thread_id": thread_id})() + return type("RuntimeStub", (), {"context": context})() + + +def test_merge_artifacts_deduplicates_and_preserves_order(): + assert merge_artifacts( + ["/home/gem/user-data/outputs/a.md"], + ["/home/gem/user-data/outputs/a.md", "/home/gem/user-data/outputs/b.md"], + ) == [ + "/home/gem/user-data/outputs/a.md", + "/home/gem/user-data/outputs/b.md", + ] + + +def test_normalize_presented_artifact_path_accepts_host_path(): + thread_id = "artifacts-host-path" + ensure_thread_dirs(thread_id) + output_file = sandbox_outputs_dir(thread_id) / "report.md" + output_file.write_text("# demo", encoding="utf-8") + + normalized = _normalize_presented_artifact_path(str(output_file), _runtime_with_thread(thread_id)) + + assert normalized == f"{VIRTUAL_PATH_PREFIX}/outputs/report.md" + + +def test_normalize_presented_artifact_path_accepts_virtual_path(): + thread_id = "artifacts-virtual-path" + ensure_thread_dirs(thread_id) + output_file = sandbox_outputs_dir(thread_id) / "summary.txt" + output_file.write_text("demo", encoding="utf-8") + + normalized = _normalize_presented_artifact_path( + f"{VIRTUAL_PATH_PREFIX}/outputs/summary.txt", + _runtime_with_thread(thread_id), + ) + + assert normalized == f"{VIRTUAL_PATH_PREFIX}/outputs/summary.txt" + + +def test_normalize_presented_artifact_path_rejects_non_outputs_path(): + thread_id = "artifacts-reject-path" + ensure_thread_dirs(thread_id) + upload_file = sandbox_uploads_dir(thread_id) / "note.txt" + upload_file.write_text("demo", encoding="utf-8") + + try: + _normalize_presented_artifact_path(str(upload_file), _runtime_with_thread(thread_id)) + except ValueError as exc: + assert f"{VIRTUAL_PATH_PREFIX}/outputs/" in str(exc) + else: + raise AssertionError("expected ValueError for non-outputs file") + + +def test_extract_agent_state_includes_artifacts(): + state = extract_agent_state( + { + "todos": [{"content": "done", "status": "completed"}], + "files": {"/tmp/demo.txt": {"content": ["x"]}}, + "artifacts": ["/home/gem/user-data/outputs/demo.txt"], + } + ) + + assert state["todos"] == [{"content": "done", "status": "completed"}] + assert state["files"] == {"/tmp/demo.txt": {"content": ["x"]}} + assert state["artifacts"] == ["/home/gem/user-data/outputs/demo.txt"] diff --git a/docker-compose.yml b/docker-compose.yml index 34d468ce..6b105cb5 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -124,7 +124,7 @@ services: build: context: ./docker/sandbox_provisioner dockerfile: Dockerfile - image: yuxi-sandbox-provisioner:0.5.2.dev + image: yuxi-sandbox-provisioner:0.6.dev container_name: sandbox-provisioner volumes: - ./saves:/app/saves diff --git a/docs/develop-guides/roadmap.md b/docs/develop-guides/roadmap.md index f9be5d12..f79ac37a 100644 --- a/docs/develop-guides/roadmap.md +++ b/docs/develop-guides/roadmap.md @@ -35,6 +35,7 @@ - 新增 LITE 模式启动,启动时不加载知识库、知识图谱相关模块,可以使用 make up-lite 快捷启动 - 新增沙盒环境,详见后续文档更新,统一沙盒虚拟路径前缀默认值为 `/home/gem/user-data` - 新增基于沙盒的文件系统,前端工作台可以查看文件系统,支持预览(文本、图片、PDF、HTML)、下载文件 +- 新增 `present_artifacts` 内置工具:Agent 可将 `/home/gem/user-data/outputs/` 下的结果文件显式写入 LangGraph state 的 `artifacts` 字段,前端支持在输入框顶部以默认折叠的堆叠卡片展示本轮交付物文件,并保持可下载、可预览能力 - 新增基于沙盒的知识库只读映射,按“用户可访问知识库 ∩ 当前 Agent 已启用知识库”暴露原始文件与解析后的 Markdown - 重构附件系统,直接集成在了沙盒文件系统中,附件上传后直接落盘到沙盒挂载目录 - 优化前端流式消息体验:新增通用 `useStreamSmoother` 调度层,统一平滑 Agent runs SSE、普通聊天流与审批恢复流中的 `loading` chunk diff --git a/web/src/components/AgentArtifactsCard.vue b/web/src/components/AgentArtifactsCard.vue new file mode 100644 index 00000000..fafe6806 --- /dev/null +++ b/web/src/components/AgentArtifactsCard.vue @@ -0,0 +1,662 @@ +