feat: 添加工件管理和展示功能

- 更新 .gitignore 以排除测试相关目录。
- 增强 ChatbotAgent 和 DeepAgent 以利用 BaseState 进行状态管理。
- 引入 PresentArtifacts 工具,允许代理向用户展示输出文件。
- 在 state.py 中添加新的状态管理功能,包括 merge_artifacts 和 BaseState。
- 创建 AgentArtifactsCard 组件以在前端显示工件。
- 在 AgentChatComponent 和 AgentInputArea 中集成工件处理。
- 更新 MessageInputComponent 和 ToolCallRenderer 以更好地处理工件的 UI 展示。
- 添加工件状态管理和标准化函数的测试。
- 更新 docker-compose 以使用最新的沙箱配置器镜像。
- 在路线图中记录新功能。
This commit is contained in:
Wenjie Zhang 2026-03-29 10:55:00 +08:00
parent d72273f469
commit ab268d8eba
17 changed files with 921 additions and 31 deletions

5
.gitignore vendored
View File

@ -32,6 +32,11 @@ cache
*.bak *.bak
*._* *._*
# test
.pytest_cache
.playwright
.playwright-cli
### IDE ### IDE
.vscode .vscode

View File

@ -4,7 +4,7 @@ from deepagents.middleware.subagents import SubAgentMiddleware
from langchain.agents import create_agent from langchain.agents import create_agent
from langchain.agents.middleware import ModelRetryMiddleware 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.backends import create_agent_composite_backend
from yuxi.agents.middlewares import ( from yuxi.agents.middlewares import (
RuntimeConfigMiddleware, RuntimeConfigMiddleware,
@ -88,6 +88,7 @@ class ChatbotAgent(BaseAgent):
model=load_chat_model(fully_specified_name=context.model), model=load_chat_model(fully_specified_name=context.model),
system_prompt=system_prompt.strip(), system_prompt=system_prompt.strip(),
middleware=await _build_middlewares(context), middleware=await _build_middlewares(context),
state_schema=BaseState,
checkpointer=await self._get_checkpointer(), checkpointer=await self._get_checkpointer(),
) )

View File

@ -2,12 +2,14 @@ PROMPT = """
你是一个人工智能助手 语析专门用来回答用户的问题请根据用户提供的信息尽可能详细地回答问题 你是一个人工智能助手 语析专门用来回答用户的问题请根据用户提供的信息尽可能详细地回答问题
如果你不确定答案可以说你不知道但请尽量提供相关的信息或建议请保持礼貌和专业 如果你不确定答案可以说你不知道但请尽量提供相关的信息或建议请保持礼貌和专业
允许的写入路径为 /home/gem/user-data/请将需要保存的文件写入该目录下 系统主要工作路径为 /home/gem/user-data/但必须遵守规范
推荐使用的路径 - /home/gem/user-data/workspace/用于存放工作文件用户目录不要轻易写入
- /home/gem/user-data/workspace/用于存放工作文件和中间结果 - /home/gem/user-data/outputs/用于写入的文件夹
- /home/gem/user-data/outputs/用于存放最终输出结果 - /home/gem/user-data/outputs/tmp/用于存放中间结果或备份内容
- /home/gem/user-data/uploads/用于存放用户上传的文件 - /home/gem/user-data/uploads/用于存放用户上传的文件
非必要不写入其他路径
如果启用了知识库除了使用知识库工具之外 如果启用了知识库除了使用知识库工具之外
当需要精准获取信息的时候或者 query_kb 中没有找到相关的内容还可以直接访问知识库文件系统 当需要精准获取信息的时候或者 query_kb 中没有找到相关的内容还可以直接访问知识库文件系统
路径为 /home/gem/kbs/来获取信息 路径为 /home/gem/kbs/来获取信息

View File

@ -7,7 +7,7 @@ from langchain.agents.middleware import (
ToolCallLimitMiddleware, 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.backends import create_agent_composite_backend
from yuxi.agents.middlewares import ( from yuxi.agents.middlewares import (
RuntimeConfigMiddleware, RuntimeConfigMiddleware,
@ -116,6 +116,7 @@ class DeepAgent(BaseAgent):
exit_behavior="end", exit_behavior="end",
), ),
], ],
state_schema=BaseState,
checkpointer=await self._get_checkpointer(), checkpointer=await self._get_checkpointer(),
) )

View File

@ -2,19 +2,29 @@
from __future__ import annotations from __future__ import annotations
from collections.abc import Sequence from typing import Annotated, TypedDict
from dataclasses import dataclass, field
from typing import Annotated
from langchain.messages import AnyMessage from langchain.agents import AgentState
from langgraph.graph import add_messages
@dataclass def merge_artifacts(existing: list[str] | None, new: list[str] | None) -> list[str]:
class BaseState: """Merge artifact file paths while preserving order and removing duplicates."""
"""Defines the input state for the agent, representing a narrower interface to the outside world. 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]

View File

@ -1,9 +1,10 @@
# buildin 工具包 # 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__ = [ __all__ = [
"ask_user_question", "ask_user_question",
"calculator", "calculator",
"present_artifacts",
"query_knowledge_graph", "query_knowledge_graph",
"text_to_img_qwen_image", "text_to_img_qwen_image",
] ]

View File

@ -1,10 +1,16 @@
import os import os
import traceback import traceback
import uuid import uuid
from pathlib import Path
from typing import Annotated, Any from typing import Annotated, Any
import requests 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 langgraph.types import interrupt
from pydantic import BaseModel, Field
from yuxi import config, graph_base from yuxi import config, graph_base
from yuxi.agents.toolkits.registry import ToolExtraMetadata, _all_tool_instances, _extra_registry, tool 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}") 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="计算器") @tool(category="buildin", tags=["计算"], display_name="计算器")
def calculator(a: float, b: float, operation: str) -> float: def calculator(a: float, b: float, operation: str) -> float:
"""计算器对给定的2个数字进行基本数学运算""" """计算器对给定的2个数字进行基本数学运算"""
@ -70,6 +122,47 @@ def calculator(a: float, b: float, operation: str) -> float:
raise 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 = """ ASK_USER_QUESTION_DESCRIPTION = """
在执行过程中当你需要用户做决定或补充需求时使用这个工具向用户提问 在执行过程中当你需要用户做决定或补充需求时使用这个工具向用户提问

View File

@ -10,6 +10,7 @@ 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.buildin import agent_manager from yuxi.agents.buildin import agent_manager
from yuxi.agents.state import AgentStatePayload
from yuxi.plugins.guard import content_guard from yuxi.plugins.guard import content_guard
from yuxi.repositories.agent_config_repository import AgentConfigRepository from yuxi.repositories.agent_config_repository import AgentConfigRepository
from yuxi.repositories.conversation_repository import ConversationRepository 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", []) return state.values.get("messages", [])
def extract_agent_state(values: dict) -> dict: def extract_agent_state(values: dict) -> AgentStatePayload:
"""从 LangGraph state 中提取 agent 状态""" """从 LangGraph state 中提取 agent 状态"""
if not isinstance(values, dict): if not isinstance(values, dict):
return {} return {"todos": [], "files": {}, "artifacts": []}
# 直接获取,信任 state 的数据结构 # 直接获取,信任 state 的数据结构
todos = values.get("todos") todos = values.get("todos")
result = { artifacts = values.get("artifacts")
result: AgentStatePayload = {
"todos": list(todos)[:20] if todos else [], "todos": list(todos)[:20] if todos else [],
"files": values.get("files") or {}, "files": values.get("files") or {},
"artifacts": list(artifacts) if artifacts else [],
} }
return result return result

View File

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

View File

@ -124,7 +124,7 @@ services:
build: build:
context: ./docker/sandbox_provisioner context: ./docker/sandbox_provisioner
dockerfile: Dockerfile dockerfile: Dockerfile
image: yuxi-sandbox-provisioner:0.5.2.dev image: yuxi-sandbox-provisioner:0.6.dev
container_name: sandbox-provisioner container_name: sandbox-provisioner
volumes: volumes:
- ./saves:/app/saves - ./saves:/app/saves

View File

@ -35,6 +35,7 @@
- 新增 LITE 模式启动,启动时不加载知识库、知识图谱相关模块,可以使用 make up-lite 快捷启动 - 新增 LITE 模式启动,启动时不加载知识库、知识图谱相关模块,可以使用 make up-lite 快捷启动
- 新增沙盒环境,详见后续文档更新,统一沙盒虚拟路径前缀默认值为 `/home/gem/user-data` - 新增沙盒环境,详见后续文档更新,统一沙盒虚拟路径前缀默认值为 `/home/gem/user-data`
- 新增基于沙盒的文件系统前端工作台可以查看文件系统支持预览文本、图片、PDF、HTML、下载文件 - 新增基于沙盒的文件系统前端工作台可以查看文件系统支持预览文本、图片、PDF、HTML、下载文件
- 新增 `present_artifacts` 内置工具Agent 可将 `/home/gem/user-data/outputs/` 下的结果文件显式写入 LangGraph state 的 `artifacts` 字段,前端支持在输入框顶部以默认折叠的堆叠卡片展示本轮交付物文件,并保持可下载、可预览能力
- 新增基于沙盒的知识库只读映射,按“用户可访问知识库 ∩ 当前 Agent 已启用知识库”暴露原始文件与解析后的 Markdown - 新增基于沙盒的知识库只读映射,按“用户可访问知识库 ∩ 当前 Agent 已启用知识库”暴露原始文件与解析后的 Markdown
- 重构附件系统,直接集成在了沙盒文件系统中,附件上传后直接落盘到沙盒挂载目录 - 重构附件系统,直接集成在了沙盒文件系统中,附件上传后直接落盘到沙盒挂载目录
- 优化前端流式消息体验:新增通用 `useStreamSmoother` 调度层,统一平滑 Agent runs SSE、普通聊天流与审批恢复流中的 `loading` chunk - 优化前端流式消息体验:新增通用 `useStreamSmoother` 调度层,统一平滑 Agent runs SSE、普通聊天流与审批恢复流中的 `loading` chunk

View File

@ -0,0 +1,662 @@
<template>
<section v-if="normalizedArtifacts.length" class="artifacts-card" :class="{ expanded }">
<button type="button" class="artifacts-toggle" @click="expanded = !expanded">
<div class="artifacts-summary">
<FolderOutput class="artifacts-icon" :size="16" />
<span class="artifacts-title">交付物</span>
<span class="artifacts-count">{{ artifactsCountLabel }}</span>
</div>
<div class="artifacts-action">
<span class="artifacts-action-text">{{ expanded ? '收起' : '展开' }}</span>
<ChevronDown class="artifacts-arrow" :size="16" />
</div>
</button>
<div class="artifacts-panel" :class="{ expanded }">
<div class="artifacts-panel-inner">
<div class="output-list">
<div v-for="file in normalizedArtifacts" :key="file.path" class="output-item">
<div class="item-main" @click="openPreview(file)">
<component
:is="getFileIcon(file.path)"
class="item-icon"
:style="{ color: getFileIconColor(file.path) }"
/>
<div class="item-meta">
<div class="item-name">{{ file.name }}</div>
</div>
</div>
<div class="item-actions">
<button
v-if="file.canPreview"
class="item-action-btn"
title="预览"
@click.stop="openPreview(file)"
>
<Eye :size="15" />
</button>
<button class="item-action-btn" title="下载" @click.stop="downloadFile(file)">
<Download :size="15" />
</button>
</div>
</div>
</div>
</div>
</div>
<a-modal
v-model:open="modalVisible"
width="800px"
:style="{ maxWidth: '90vw', top: '5vh' }"
:bodyStyle="{ maxHeight: '90vh', overflow: 'auto' }"
:footer="null"
:closable="false"
@cancel="closePreview"
>
<template #title>
<div class="modal-header-title">
<div class="file-title">
<component
:is="getFileIcon(currentFilePath)"
:style="{ color: getFileIconColor(currentFilePath), fontSize: '18px' }"
/>
<span class="file-path-title">{{ currentFilePath }}</span>
</div>
<div class="modal-actions">
<div v-if="isHtmlFile" class="preview-mode-switch">
<button
class="preview-mode-btn"
:class="{ active: htmlPreviewMode === 'render' }"
@click="htmlPreviewMode = 'render'"
title="预览"
>
<Globe :size="16" />
</button>
<button
class="preview-mode-btn"
:class="{ active: htmlPreviewMode === 'source' }"
@click="htmlPreviewMode = 'source'"
title="源码"
>
<Code2 :size="16" />
</button>
</div>
<button class="modal-action-btn" @click="downloadFile(currentFile)" title="下载">
<Download :size="18" />
</button>
<button class="modal-action-btn" @click="closePreview" title="关闭">
<X :size="18" />
</button>
</div>
</div>
</template>
<div class="file-content">
<template v-if="currentFile?.previewType === 'image' && currentFile?.previewUrl">
<div class="image-preview-wrapper">
<img :src="currentFile.previewUrl" :alt="currentFilePath" class="image-preview" />
</div>
</template>
<template v-else-if="currentFile?.previewType === 'pdf' && currentFile?.previewUrl">
<iframe :src="currentFile.previewUrl" class="pdf-preview" :title="currentFilePath" />
</template>
<template v-else-if="isHtmlFile && htmlPreviewMode === 'render'">
<iframe
class="html-preview"
:srcdoc="formatContent(currentFile?.content)"
:title="currentFilePath"
sandbox=""
/>
</template>
<template v-else-if="isMarkdown">
<MdPreview
class="flat-md-preview"
:modelValue="formatContent(currentFile?.content)"
:theme="theme"
previewTheme="github"
/>
</template>
<template v-else-if="currentFile?.supported === false">
<div class="unsupported-preview">
{{ currentFile?.message || '当前文件暂不支持预览,请下载后查看' }}
</div>
</template>
<template v-else>
<pre
v-if="isCodePreview && typeof currentFile?.content === 'string'"
:class="['file-content-pre', 'code-highlight', codeThemeClass]"
><code class="hljs" v-html="highlightedCodeContent"></code></pre>
<pre v-else-if="typeof currentFile?.content === 'string'" class="file-content-pre">{{
currentFile.content
}}</pre>
<pre v-else>{{ JSON.stringify(currentFile, null, 2) }}</pre>
</template>
</div>
</a-modal>
</section>
</template>
<script setup>
import { computed, onUnmounted, ref, watch } from 'vue'
import { ChevronDown, Code2, Download, Eye, FolderOutput, Globe, X } from 'lucide-vue-next'
import { MdPreview } from 'md-editor-v3'
import hljs from 'highlight.js/lib/common'
import 'md-editor-v3/lib/preview.css'
import { useThemeStore } from '@/stores/theme'
import { getFileIcon, getFileIconColor } from '@/utils/file_utils'
import { getCodeLanguageByPath, getPreviewTypeByPath, isHtmlPreview, isMarkdownPreview } from '@/utils/file_preview'
import { downloadViewerFile, getViewerFileContent } from '@/apis/viewer_filesystem'
const props = defineProps({
artifacts: {
type: Array,
default: () => []
},
threadId: {
type: String,
default: null
},
agentId: {
type: String,
default: null
},
agentConfigId: {
type: [String, Number],
default: null
}
})
const themeStore = useThemeStore()
const theme = computed(() => (themeStore.isDark ? 'dark' : 'light'))
const normalizedArtifacts = computed(() =>
(props.artifacts || [])
.filter((path) => typeof path === 'string' && path.trim())
.map((path) => {
const normalizedPath = path.trim()
return {
path: normalizedPath,
name: normalizedPath.split('/').pop() || normalizedPath,
canPreview: ['text', 'markdown', 'pdf', 'image'].includes(getPreviewTypeByPath(normalizedPath))
}
})
)
const artifactsCountLabel = computed(() => `${normalizedArtifacts.value.length} 个文件`)
const expanded = ref(false)
const modalVisible = ref(false)
const currentFile = ref(null)
const currentFilePath = ref('')
const htmlPreviewMode = ref('render')
const isMarkdown = computed(() =>
isMarkdownPreview(currentFilePath.value, currentFile.value?.previewType)
)
const isHtmlFile = computed(
() =>
currentFile.value?.previewType === 'text' &&
typeof currentFile.value?.content === 'string' &&
isHtmlPreview(currentFilePath.value)
)
const codeThemeClass = computed(() => (themeStore.isDark ? 'hljs-theme-dark' : 'hljs-theme-light'))
const codeLanguage = computed(() => getCodeLanguageByPath(currentFilePath.value))
const isCodePreview = computed(
() =>
currentFile.value?.previewType === 'text' &&
typeof currentFile.value?.content === 'string' &&
!isHtmlFile.value &&
Boolean(codeLanguage.value)
)
const escapeHtml = (content) =>
String(content)
.replaceAll('&', '&amp;')
.replaceAll('<', '&lt;')
.replaceAll('>', '&gt;')
.replaceAll('"', '&quot;')
.replaceAll("'", '&#39;')
const highlightedCodeContent = computed(() => {
const content = currentFile.value?.content
if (!isCodePreview.value || typeof content !== 'string') {
return ''
}
try {
return codeLanguage.value
? hljs.highlight(content, { language: codeLanguage.value }).value
: hljs.highlightAuto(content).value
} catch (error) {
console.warn('代码高亮失败:', error)
return escapeHtml(content)
}
})
const formatContent = (content) => {
if (content === undefined || content === null) return ''
return String(content)
}
const parseDownloadFilename = (contentDisposition) => {
if (!contentDisposition) return ''
const utf8Match = contentDisposition.match(/filename\*=UTF-8''([^;]+)/i)
if (utf8Match && utf8Match[1]) {
try {
return decodeURIComponent(utf8Match[1])
} catch (error) {
console.warn('解析 UTF-8 文件名失败:', error)
}
}
const asciiMatch = contentDisposition.match(/filename="?([^";]+)"?/i)
return asciiMatch?.[1] || ''
}
const revokeCurrentPreviewUrl = () => {
const previewUrl = currentFile.value?.previewUrl
if (previewUrl) {
window.URL.revokeObjectURL(previewUrl)
}
}
const closePreview = () => {
revokeCurrentPreviewUrl()
modalVisible.value = false
currentFile.value = null
currentFilePath.value = ''
htmlPreviewMode.value = 'render'
}
const openPreview = async (file) => {
if (!props.threadId || !file?.path) return
revokeCurrentPreviewUrl()
currentFilePath.value = file.path
htmlPreviewMode.value = 'render'
currentFile.value = {
...file,
content: 'Loading...',
supported: true,
previewType: 'text',
message: '',
previewUrl: ''
}
modalVisible.value = true
try {
const res = await getViewerFileContent(
props.threadId,
file.path,
props.agentId,
props.agentConfigId
)
const previewType = res?.preview_type || 'text'
let previewUrl = ''
if ((previewType === 'image' || previewType === 'pdf') && res?.supported) {
const response = await downloadViewerFile(
props.threadId,
file.path,
props.agentId,
props.agentConfigId
)
const blob = await response.blob()
previewUrl = window.URL.createObjectURL(blob)
}
currentFile.value = {
...file,
content: res?.content ?? '',
supported: res?.supported !== false,
previewType,
message: res?.message || '',
previewUrl
}
} catch (error) {
currentFile.value = {
...file,
content: `Error loading file: ${error?.message || 'unknown error'}`,
supported: false,
previewType: 'unsupported',
message: error?.message || '文件预览失败',
previewUrl: ''
}
}
}
const downloadFile = async (file) => {
if (!props.threadId || !file?.path) return
const response = await downloadViewerFile(
props.threadId,
file.path,
props.agentId,
props.agentConfigId
)
const blob = await response.blob()
const contentDisposition =
response.headers.get('Content-Disposition') || response.headers.get('content-disposition')
const filename = parseDownloadFilename(contentDisposition) || file.name
const url = window.URL.createObjectURL(blob)
const link = document.createElement('a')
link.href = url
link.download = filename
document.body.appendChild(link)
link.click()
document.body.removeChild(link)
window.URL.revokeObjectURL(url)
}
onUnmounted(() => {
revokeCurrentPreviewUrl()
})
watch(
() => [props.threadId, normalizedArtifacts.value.map((file) => file.path).join('|')],
() => {
expanded.value = false
}
)
</script>
<style scoped lang="less">
.artifacts-card {
width: 90%;
max-width: 800px;
margin: 0 auto;
border-radius: 12px 12px 0 0;
overflow: hidden;
border: 1px solid var(--gray-100);
background: linear-gradient(180deg, var(--gray-25) 0%, var(--gray-0) 100%);
border-bottom: none;
position: relative;
z-index: 1;
&.expanded {
.artifacts-toggle {
border-bottom-color: var(--gray-150);
}
}
}
.artifacts-toggle {
width: 100%;
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
padding: 6px 12px;
border: none;
border-bottom: 1px solid transparent;
background: linear-gradient(180deg, var(--gray-25) 0%, var(--gray-50) 100%);
color: var(--gray-900);
cursor: pointer;
text-align: left;
transition: background 0.2s ease;
}
.artifacts-summary,
.artifacts-action {
display: flex;
align-items: center;
}
.artifacts-summary {
min-width: 0;
gap: 8px;
}
.artifacts-icon {
flex-shrink: 0;
color: var(--gray-600);
opacity: 0.92;
}
.artifacts-title {
font-size: 14px;
font-weight: 700;
color: var(--gray-900);
line-height: 1;
}
.artifacts-count {
font-size: 12px;
color: var(--gray-500);
white-space: nowrap;
}
.artifacts-action {
gap: 6px;
flex-shrink: 0;
}
.artifacts-action-text {
font-size: 12px;
font-weight: 600;
color: var(--gray-600);
}
.artifacts-arrow {
flex-shrink: 0;
color: var(--gray-600);
transition: transform 0.24s ease;
}
.artifacts-card.expanded .artifacts-arrow {
transform: rotate(180deg);
}
.artifacts-panel {
max-height: 0;
overflow: hidden;
transition: max-height 0.28s ease;
background: transparent;
&.expanded {
max-height: 240px;
}
}
.artifacts-panel-inner {
padding: 4px 6px 6px;
background: var(--gray-0);
}
.output-list {
display: flex;
flex-direction: column;
gap: 0;
}
.output-item {
display: flex;
align-items: center;
justify-content: space-between;
gap: 4px;
border-radius: 0;
background: transparent;
border-bottom: 1px solid var(--gray-150);
transition: background 0.18s ease;
&:last-child {
border-bottom: none;
}
&:hover {
background: var(--gray-25);
}
}
@media (max-width: 768px) {
.artifacts-card {
width: 100%;
}
.artifacts-toggle {
padding: 11px 12px;
}
.artifacts-panel-inner {
padding: 8px;
}
.artifacts-title {
font-size: 14px;
}
.artifacts-count,
.artifacts-action-text {
font-size: 12px;
}
}
.item-main {
min-width: 0;
flex: 1;
display: flex;
align-items: center;
gap: 8px;
cursor: pointer;
}
.item-icon {
flex-shrink: 0;
font-size: 16px;
opacity: 0.82;
}
.item-meta {
min-width: 0;
}
.item-name {
font-size: 13px;
font-weight: 600;
color: var(--gray-900);
word-break: break-word;
line-height: 1.3;
}
.item-actions {
display: flex;
align-items: center;
gap: 2px;
margin-left: 8px;
}
.item-action-btn,
.modal-action-btn,
.preview-mode-btn {
width: 30px;
height: 30px;
border: none;
background: transparent;
color: var(--gray-600);
border-radius: 6px;
display: inline-flex;
align-items: center;
justify-content: center;
cursor: pointer;
transition: all 0.2s ease;
}
.item-action-btn:hover,
.modal-action-btn:hover,
.preview-mode-btn:hover,
.preview-mode-btn.active {
color: var(--main-700);
background: var(--gray-100);
}
.modal-header-title,
.file-title,
.modal-actions,
.preview-mode-switch {
display: flex;
align-items: center;
}
.modal-header-title {
justify-content: space-between;
gap: 12px;
}
.file-title {
gap: 10px;
min-width: 0;
}
.file-path-title {
word-break: break-all;
color: var(--gray-900);
}
.modal-actions,
.preview-mode-switch {
gap: 8px;
}
.file-content {
min-height: 120px;
}
.file-content-pre {
white-space: pre-wrap;
word-break: break-word;
margin: 0;
font-size: 13px;
line-height: 1.7;
color: var(--gray-900);
}
.image-preview-wrapper {
display: flex;
justify-content: center;
}
.image-preview,
.pdf-preview,
.html-preview {
width: 100%;
border: none;
border-radius: 12px;
}
.image-preview {
max-height: 70vh;
object-fit: contain;
}
.pdf-preview,
.html-preview {
min-height: 70vh;
background: var(--gray-25);
}
.unsupported-preview {
padding: 16px;
border-radius: 12px;
background: var(--gray-50);
color: var(--gray-600);
}
:deep(.flat-md-preview .md-editor) {
background: transparent;
}
:deep(.flat-md-preview .md-editor-preview-wrapper) {
padding: 0;
}
@media (max-width: 768px) {
.output-item {
align-items: flex-start;
flex-direction: column;
}
.item-actions {
width: 100%;
justify-content: flex-end;
}
}
</style>

View File

@ -150,6 +150,13 @@
/> />
</div> </div>
<AgentArtifactsCard
:artifacts="currentArtifacts"
:thread-id="currentChatId"
:agent-id="currentThread?.agent_id || currentAgentId"
:agent-config-id="selectedAgentConfigId"
/>
<AgentInputArea <AgentInputArea
v-model="userInput" v-model="userInput"
:is-loading="isProcessing" :is-loading="isProcessing"
@ -254,6 +261,7 @@ import { useAgentRunStream } from '@/composables/useAgentRunStream'
import { useAgentStreamHandler } from '@/composables/useAgentStreamHandler' import { useAgentStreamHandler } from '@/composables/useAgentStreamHandler'
import { useStreamSmoother } from '@/composables/useStreamSmoother' import { useStreamSmoother } from '@/composables/useStreamSmoother'
import { useAgentMentionConfig } from '@/composables/useAgentMentionConfig' import { useAgentMentionConfig } from '@/composables/useAgentMentionConfig'
import AgentArtifactsCard from '@/components/AgentArtifactsCard.vue'
import AgentPanel from '@/components/AgentPanel.vue' import AgentPanel from '@/components/AgentPanel.vue'
import UserInfoComponent from '@/components/UserInfoComponent.vue' import UserInfoComponent from '@/components/UserInfoComponent.vue'
@ -426,6 +434,10 @@ const currentThreadAttachments = computed(() => {
if (!currentChatId.value) return [] if (!currentChatId.value) return []
return threadAttachmentsMap.value[currentChatId.value] || [] return threadAttachmentsMap.value[currentChatId.value] || []
}) })
const currentArtifacts = computed(() => {
const artifacts = currentAgentState.value?.artifacts
return Array.isArray(artifacts) ? artifacts : []
})
const hasAgentStateContent = computed(() => { const hasAgentStateContent = computed(() => {
const s = currentAgentState.value const s = currentAgentState.value
@ -1917,7 +1929,6 @@ watch(currentChatId, (threadId, oldThreadId) => {
.conv-box { .conv-box {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 4px;
} }
.bottom { .bottom {
@ -1926,7 +1937,6 @@ watch(currentChatId, (threadId, oldThreadId) => {
width: 100%; width: 100%;
margin: 0 auto; margin: 0 auto;
padding: 4px 1rem 0 1rem; padding: 4px 1rem 0 1rem;
background: var(--gray-0);
z-index: 1000; z-index: 1000;
.message-input-wrapper { .message-input-wrapper {
@ -1938,6 +1948,8 @@ watch(currentChatId, (threadId, oldThreadId) => {
display: flex; display: flex;
justify-content: center; justify-content: center;
align-items: center; align-items: center;
width: 100%;
background: var(--gray-0);
} }
.note { .note {

View File

@ -12,12 +12,13 @@
@keydown="handleKeyDown" @keydown="handleKeyDown"
> >
<template #top> <template #top>
<ImagePreviewComponent <div v-if="currentImage" class="input-top-stack">
v-if="currentImage" <ImagePreviewComponent
:image-data="currentImage" :image-data="currentImage"
@remove="handleImageRemoved" @remove="handleImageRemoved"
class="image-preview-wrapper" class="image-preview-wrapper"
/> />
</div>
</template> </template>
<template #options-left> <template #options-left>
<AttachmentOptionsComponent <AttachmentOptionsComponent
@ -143,6 +144,14 @@ defineExpose({
margin-right: 8px; margin-right: 8px;
} }
.input-top-stack {
width: 100%;
display: flex;
flex-direction: column;
gap: 8px;
margin-bottom: 10px;
}
// 穿 slot // 穿 slot
:deep(.input-action-btn) { :deep(.input-action-btn) {
display: flex; display: flex;
@ -187,4 +196,11 @@ defineExpose({
display: none; display: none;
} }
} }
@media (max-width: 768px) {
.input-top-stack {
gap: 8px;
margin-bottom: 10px;
}
}
</style> </style>

View File

@ -625,6 +625,7 @@ defineExpose({
border-radius: 0.8rem; border-radius: 0.8rem;
box-shadow: 0 2px 8px var(--shadow-1); box-shadow: 0 2px 8px var(--shadow-1);
transition: all 0.3s ease; transition: all 0.3s ease;
background: var(--gray-0);
gap: 0px; gap: 0px;
position: relative; position: relative;

View File

@ -5,7 +5,7 @@
:tool-call="toolCall" :tool-call="toolCall"
ref="toolRendererRef" ref="toolRendererRef"
/> />
<BaseToolCall v-else :tool-call="toolCall" /> <BaseToolCall v-else-if="!isHidden" :tool-call="toolCall" />
</template> </template>
<script setup> <script setup>
@ -71,7 +71,10 @@ const TOOL_RENDERERS = {
write_todos: TodoListTool write_todos: TodoListTool
} }
const TOOL_RENDERER_HIDE = ["present_artifacts"]
const currentRenderer = computed(() => TOOL_RENDERERS[toolId.value] || null) const currentRenderer = computed(() => TOOL_RENDERERS[toolId.value] || null)
const isHidden = computed(() => TOOL_RENDERER_HIDE.includes(toolId.value))
const toolRendererRef = ref(null) const toolRendererRef = ref(null)
const refreshGraph = () => { const refreshGraph = () => {

View File

@ -7,6 +7,7 @@ import {
FilePen, FilePen,
FileText, FileText,
Folder, Folder,
FolderOutput,
FolderSearch, FolderSearch,
Globe, Globe,
HelpCircle, HelpCircle,
@ -31,6 +32,7 @@ export const TOOL_ICON_MAP = {
mysql_describe_table: Database, mysql_describe_table: Database,
mysql_list_tables: Database, mysql_list_tables: Database,
mysql_query: Database, mysql_query: Database,
present_artifacts: FolderOutput,
query_kb: BookOpen, query_kb: BookOpen,
query_knowledge_graph: Network, query_knowledge_graph: Network,
read_file: FileText, read_file: FileText,