feat(web): add task-scoped artifact references

This commit is contained in:
caoqianming 2026-08-03 13:49:20 +08:00
parent 4ec2638d38
commit 57655984dd
27 changed files with 804 additions and 104 deletions

View File

@ -152,6 +152,8 @@ Session = 消息列表,ORM 直写 PG `messages`(append-only,jsonb 存 LiteLLM
**新对话入口(0.60)**:登录未选 task 与左栏「+ 新对话」共用同一前端草稿页,先选择已有 working_dir 或输入新目录名,再直接写消息;草稿不落 DB首发时才 `POST /v1/tasks`,避免空 task 堆积。创建请求省略/留空 name 时必须显式给 working_dir后端据此判定自动命名以「新对话」占位并置一次性 `auto_title_pending`;显式 name 的旧调用继续视为人工标题working_dir 仍可省略并 fallback 到 name`auto_title` 字段只作兼容保留。首条消息并行触发短标题调用,结果只改 `tasks.name`、绝不改 working_dir人工 PATCH name 同时清 pending条件 UPDATE 保证在途标题也不能覆盖用户命名。原完整创建表单保留为「自定义」入口UI 同样要求明确选择 working_dirname 可选,并可预设 description/skill/model。标题是 UI 元数据辅助调用,记 `usage_events.kind="task_title"`,失败只保留占位名、不阻塞主 run。
**对话产物引用(0025)**:真实文件仍是事实源,不建 artifacts 表;`messages.artifact_refs` 只保存可重建的轻量 UI 元数据,规范路径以该 task 的**当前 working_dir 为根**,形如 `{version:1, scope:"working_dir", path:"reports/a.pdf", label?:"最终报告"}`。预览/下载走 task-scoped 文件 API服务端用 task 当前 `working_dir` 解析因此顶层工作目录改名后历史卡片仍有效。普通源码树、中间文件和配套资源只留文件面板agent 仅用 `publish_artifacts` 显式提升少量最终文件,单条消息最多 10 个,图像/视频/Office 转 PDF 等成品工具可自动提升。`NULL` 表示迁移前旧消息,前端继续使用正文路径抽取,并在 task-scoped API 上启用只读兼容链(旧 user-root 含义→原样 task-relative→去掉旧目录前缀新消息写 `[]` 或结构化列表,停止启发式抽取,避免重复卡片与误识别。文件在 working_dir 内再次移动或删除后引用可失效,这是 FS 事实源语义,不复制文件、不引不可变对象存储。
### 7.2 资源模型(/v1)
统一 `/v1` 前缀返 JSON;UI 由 platform 实现(§7.9),本地 dev SPA dogfood。要点(细节见 `web/app.py`):
@ -168,6 +170,8 @@ Tasks POST/GET/PATCH/DELETE /v1/tasks*(POST 可选 auto_title;分页+筛选+
Auth POST /v1/auth/login(platform_key)/ login_password / change_password;GET /v1/me
Files GET /v1/files?path= / upload / download / delete / rename
(user-rooted;dotfile 隐藏;越界 400;顶层目录 DB-aware,见 §7.4)
GET /v1/tasks/{id}/files/download|preview_pdf?path=
(working_dir-rooted;结构化产物入口,保留旧 user-rooted API)
Admin GET /v1/admin/*(require_admin;overview + usage/models|users + storage/users)
Export GET /v1/tasks/{id}/export(docx)
```
@ -196,7 +200,8 @@ tasks(task_id pk, user_id fk, name NOT NULL, auto_title_pending default false,
context_base_idx, -- 0019 §8.8 软重置窗口起点
deleted_at, -- 0010 软删
created_at, updated_at)
messages(pk, task_id fk, idx, payload jsonb, tokens_in/out, model_profile, kind, -- kind=push 等
messages(pk, task_id fk, idx, payload jsonb, artifact_refs jsonb null, -- 0025,task-relative UI 元数据
tokens_in/out, model_profile, kind, -- kind=push 等
unique(task_id, idx); gin(payload))
usage_events(pk, user_id, task_id, message_id, kind, -- chat/image/video/vision/... 自由文本
model_profile, units jsonb, cost numeric, created_at) -- 多态用量,加媒体不动 schema

4
RUN.md
View File

@ -274,7 +274,7 @@ curl --noproxy '*' -H "Authorization: Bearer $TOKEN" http://127.0.0.1:8765/v1/ta
| `GET /v1/skills` | 列当前 user 可用 skill(内置 + 自己的);每项带 `source`(builtin/user)/`overrides_builtin`;另返 `load_errors`(用户 skill 因 frontmatter 坏未加载的) | 必填 |
| `GET /v1/skills/{name}` | 返某 skill 完整 SKILL.md 正文(前端「技能」modal 点开查看);同名按 user wins | 必填 |
| `DELETE /v1/skills/{name}` | 删当前 user 私有 skill(`.skills/<name>/` 整目录);只删 user 源,内置不可删 → 404;`.skills` 文件面板隐藏,这是 UI 上删自己 skill 的唯一入口 | 必填 |
| `GET /v1/tasks/{id}/messages` | LiteLLM payload 透传 | 必填 |
| `GET /v1/tasks/{id}/messages` | LiteLLM payload 透传0025 起每条另带 `artifact_refs``null`=旧消息、`[]`=新消息无产物、非空数组=相对该 task 当前 working_dir 的结构化产物引用 | 必填 |
| `POST /v1/tasks/{id}/messages` | `{content, image_model?=""}` 发消息;返 `{events_url}`;**`run_status` 是 running/cancelling → 409**(单活 run;error 起新 run 时清);`image_model` 是 `config/media/doubao.yaml` image 段的 variant key(空 → 沿用 yaml 第一个),仅本 run 装配 SeedreamTool 时使用,不入 DB;UI 应 disable send 直到 SSE `done` | 必填 |
| `GET /v1/tasks/{id}/events` | SSE 流(`event: <type>` + `data: <json>`);订阅 task 当前活动 | 必填 |
| `POST /v1/tasks/{id}/cancel` | 协作式 cancel;`run_status != running` → 409;LLM 走 streaming,chunk 间 poll cancel — 延迟 100ms 级,基本秒退 | 必填 |
@ -286,6 +286,8 @@ curl --noproxy '*' -H "Authorization: Bearer $TOKEN" http://127.0.0.1:8765/v1/ta
| `POST /v1/asr/transcribe` | body 为裸 PCM(16kHz/16bit/单声道/小端,`application/octet-stream`)→ 讯飞 IAT 整段转写,返 `{text}`;>60s → 413;`XFYUN_*` env 未配 → 501;讯飞侧错误 → 502(带错误码提示)。流式 WS 连不上时前端的兜底通道 | 必填 |
| `GET /v1/files?path=` | 列 user_root 下条目 + 面包屑;dotfile 隐藏 | 必填 |
| `GET /v1/files/download?path=` | 下单文件 | 必填 |
| `GET /v1/tasks/{id}/files/download?path=` | 下载结构化产物;`path` 以该 task 当前 working_dir 为根,顶层目录改名后无需改历史消息;跨用户/越界/不存在均拒绝。`legacy=true` 仅供前端读取迁移前卡片,按旧 user-root→两种 task-relative 含义顺序兼容 | 必填 |
| `GET /v1/tasks/{id}/files/preview_pdf?path=` | PPT/PPTX 结构化产物按 task-relative 路径转 PDF 预览;状态码与旧 `/v1/files/preview_pdf` 一致;旧卡片可同样传 `legacy=true` | 必填 |
| `POST /v1/files/upload` | multipart 上传到 `<user_root>/<path>/`;路径不存在自动 mkdir,重名覆盖 | 必填 |
| `POST /v1/files/delete` | `{path, recursive?=false}`;`recursive=false` 文件或空目录(非空 → 400);`recursive=true` `shutil.rmtree` —— 顶层目录被 task 引用 → 409(先 DELETE task);空目录两种模式都可删,task.working_dir 字段不动,下次 build_agent 按需 mkdir 重建 | 必填 |
| `POST /v1/files/rename` | `{path, new_name}`;sibling 已存在 → 409;**path 顶层目录** → 同事务 UPDATE tasks.working_dir + FOR UPDATE 锁;有 running/cancelling → 409;check_no_subtask 防嵌套 → 409 | 必填 |

View File

@ -412,6 +412,13 @@ def _build_system_prompt(
"完成后执行,所以调用后按“已登记、将在回复后完成”表述。\n"
if allow_working_dir_rename else ""
)
publish_line = (
"完成任务后,仅把用户真正需要打开、下载或继续使用的少量最终文件调用 "
"`publish_artifacts` 发布到聊天path 相对 task_dir不带工作目录名前缀。"
"源码树、中间文件、临时脚本和配套资源留在右侧文件区,不逐个发布;"
"office_to_pdf、图像和视频工具会自动发布其成品无需重复调用。\n"
if allow_working_dir_rename else ""
)
office_pdf_hint = (
"已有 Office 文件需要转 PDF 时,仅当 host-side `office_to_pdf` 的工具说明列出该格式"
"才调用LibreOffice 在 backend host不在 Docker shell 内探测 `soffice`。\n"
@ -430,6 +437,7 @@ def _build_system_prompt(
f"「宪法」性文件(spec 等)按下面《task 级「宪法」文件命名约定》拼路径。\n"
f"⛔ 不要把产物写到 cwd / `skills/` / repo 根 —— 只写到 task_dir。\n"
f"{rename_line}"
f"{publish_line}"
f"\n## 生成 Word / PDF 报告(验收 / 技术 / 评审报告等自由长文)\n"
f"**优先**把正文写成 Markdown(`<task_dir>/sections/*.md`,纯文本、零转义 / 零语法风险),"
f"再调平台渲染器直接出 docx 或 pdf —— **别在 run_python 里手撸文档转换脚本**"

150
core/artifacts.py Normal file
View File

@ -0,0 +1,150 @@
"""Task artifact references and working-dir scoped path resolution.
Files remain the source of truth. Artifact refs are small, rebuildable UI metadata:
they identify a user-facing deliverable relative to a task's mutable working_dir.
"""
from __future__ import annotations
from dataclasses import dataclass
from pathlib import Path
from typing import Iterable, Optional
ARTIFACT_REF_VERSION = 1
MAX_ARTIFACTS_PER_MESSAGE = 10
_CONTAINER_ROOT = Path("/workspace")
class ArtifactPathError(ValueError):
"""A proposed artifact path is invalid or outside the current working_dir."""
@dataclass(frozen=True)
class ArtifactRef:
path: str
label: str = ""
scope: str = "working_dir"
version: int = ARTIFACT_REF_VERSION
def as_dict(self) -> dict:
out = {
"version": self.version,
"scope": self.scope,
"path": self.path,
}
if self.label:
out["label"] = self.label
return out
class ToolExecutionResult(str):
"""String-compatible rich result for tools that publish deliverables.
A few internal scripts and tests call tools directly and historically received a
plain string. Subclassing ``str`` preserves that contract while executors can
still consume the structured artifact metadata.
"""
content: str
artifacts: tuple[ArtifactRef, ...]
def __new__(
cls,
content: str,
artifacts: Iterable[ArtifactRef] = (),
) -> "ToolExecutionResult":
obj = super().__new__(cls, content)
obj.content = content
obj.artifacts = tuple(artifacts)
return obj
def _relative_parts(path: Path) -> tuple[str, ...]:
return tuple(part for part in path.parts if part not in ("", "."))
def resolve_artifact_path(
raw_path: str,
*,
working_dir: Path,
user_root: Path,
require_file: bool = True,
allow_legacy_user_relative: bool = True,
) -> tuple[Path, str]:
"""Resolve legacy/canonical input and return (absolute, task-relative POSIX path).
Canonical input is relative to working_dir (``reports/a.pdf``). For compatibility,
user-root-relative paths (``<wd>/reports/a.pdf``), container absolute paths under
``/workspace`` and host absolute paths inside working_dir are accepted too.
"""
raw = str(raw_path or "").strip().replace("\\", "/")
if not raw or "\x00" in raw:
raise ArtifactPathError("artifact path is empty or contains NUL")
wd = Path(working_dir).resolve()
root = Path(user_root).resolve()
try:
wd_rel = wd.relative_to(root)
except ValueError as exc:
raise ArtifactPathError("working_dir is outside user_root") from exc
explicit_task_relative = raw.startswith("./")
p = Path(raw[2:] if explicit_task_relative else raw)
if raw == "/workspace" or raw.startswith("/workspace/"):
rest = raw[len("/workspace"):].lstrip("/")
candidate = root / Path(rest)
elif p.is_absolute():
candidate = p
else:
parts = _relative_parts(p)
wd_parts = _relative_parts(wd_rel)
if (
allow_legacy_user_relative
and not explicit_task_relative
and wd_parts
and parts[:len(wd_parts)] == wd_parts
):
candidate = root.joinpath(*parts)
else:
candidate = wd.joinpath(*parts)
resolved = candidate.resolve()
try:
rel = resolved.relative_to(wd)
except ValueError as exc:
raise ArtifactPathError("artifact path escapes working_dir") from exc
if rel == Path("."):
raise ArtifactPathError("artifact path must reference a file")
if require_file and not resolved.is_file():
raise ArtifactPathError(f"artifact file not found: {rel.as_posix()}")
return resolved, rel.as_posix()
def normalize_artifact_refs(refs: Iterable[ArtifactRef]) -> list[dict]:
"""Deduplicate validated refs while preserving order and enforcing the UI limit."""
out: list[dict] = []
seen: set[tuple[str, str]] = set()
for ref in refs:
if ref.scope != "working_dir" or ref.version != ARTIFACT_REF_VERSION:
continue
key = (ref.scope, ref.path)
if key in seen:
continue
seen.add(key)
out.append(ref.as_dict())
if len(out) >= MAX_ARTIFACTS_PER_MESSAGE:
break
return out
def artifact_ref_for_file(
path: Path,
*,
working_dir: Path,
user_root: Path,
label: Optional[str] = None,
) -> ArtifactRef:
_, rel = resolve_artifact_path(
str(path), working_dir=working_dir, user_root=user_root, require_file=True,
)
return ArtifactRef(path=rel, label=(label or "").strip())

View File

@ -41,13 +41,16 @@ class ExecCtx:
class ToolResult:
"""工具调用统一返回。
现状所有 `Tool.execute` 都返 str,docker backend 后续可能要带 stdout/stderr/
exit_code 分离这里先留单 content 字段(LLM 拿到的就是这串),exit_code
普通 `Tool.execute` str发布产物的工具可返字符串兼容的 rich result
`content` LLM 拿到的文本`artifacts` 只进事件和消息元数据exit_code
backend 内部使用 hint(0=ok / 1=tool 抛异常 / 2=参数非法 / 124=timeout ),
不影响 LLM 接口
"""
content: str
exit_code: int = 0
# Optional structured, task-relative deliverables. Existing executors/tools that only
# return text remain fully compatible.
artifacts: tuple[dict, ...] = ()
class Executor(ABC):

View File

@ -596,7 +596,11 @@ class DockerExecutor(Executor):
def _compact_shell_like_result(self, result: ToolResult) -> ToolResult:
content = compact_tool_output(result.content)
return ToolResult(content=content, exit_code=result.exit_code)
return ToolResult(
content=content,
exit_code=result.exit_code,
artifacts=result.artifacts,
)
def _check_user_disk_quota(user_id: UUID):

View File

@ -15,6 +15,7 @@ from __future__ import annotations
from typing import Any, Dict, List
from .executor import ExecCtx, Executor, ToolResult
from .artifacts import ToolExecutionResult, normalize_artifact_refs
from tools.base import Tool
@ -52,6 +53,9 @@ class HostExecutor(Executor):
content=f"[Error executing {name}] {type(e).__name__}: {e}",
exit_code=1,
)
if isinstance(result, ToolExecutionResult):
refs = tuple(normalize_artifact_refs(result.artifacts))
return ToolResult(content=result.content, exit_code=0, artifacts=refs)
if not isinstance(result, str):
result = str(result)
return ToolResult(content=result, exit_code=0)

View File

@ -31,6 +31,7 @@ from .context import (
)
from .context_fold import maybe_fold
from .executor import ExecCtx, Executor
from .artifacts import MAX_ARTIFACTS_PER_MESSAGE
from .llm import LLM
from .llm_transport import (
extract_delta_content,
@ -249,6 +250,9 @@ class AgentLoop:
self._repeat_guard = _RepeatGuard()
# 全局「无进展」计数:连续多少步整步无净产出。有净产出清零,见 run loop 熔断。
self._stall = 0
# Structured deliverables accumulated across tool steps in the current user turn.
# They are persisted on the final assistant message, not mixed into provider payloads.
self._pending_artifact_refs: list[dict] = []
def _emit(self, event: dict) -> None:
if self.sink is not None:
@ -279,6 +283,7 @@ class AgentLoop:
return self._run(None)
def _run(self, user_message: Optional[str]) -> str:
self._pending_artifact_refs = []
self._maybe_fold_context()
if user_message is not None:
self.session.append({"role": "user", "content": user_message})
@ -299,7 +304,11 @@ class AgentLoop:
return "[cancelled]"
msg = response.choices[0].message
asst_msg_id = self.session.append(msg)
tool_calls = getattr(msg, "tool_calls", None) or []
asst_msg_id = self.session.append(
msg,
artifact_refs=(list(self._pending_artifact_refs) if not tool_calls else None),
)
usage_details = extract_usage_details(getattr(response, "usage", None))
pt, ct = usage_details["tokens_in"], usage_details["tokens_out"]
@ -339,7 +348,6 @@ class AgentLoop:
"elapsed": elapsed,
})
tool_calls = getattr(msg, "tool_calls", None) or []
# content 已通过 stream 流式 emit 过 delta,这里不再 emit 整段 text 事件。
if not tool_calls:
@ -363,7 +371,8 @@ class AgentLoop:
self._fill_cancelled_tool_results(tool_calls[i:])
self._emit({"type": "cancelled"})
return "[cancelled]"
result, productive = self._execute_tool_call(tc)
result, productive, artifacts = self._execute_tool_call(tc)
self._remember_artifacts(artifacts)
step_productive = step_productive or productive
self.session.append(
{
@ -674,7 +683,7 @@ class AgentLoop:
pass
return response
def _execute_tool_call(self, tc: Any) -> Tuple[str, bool]:
def _execute_tool_call(self, tc: Any) -> Tuple[str, bool, tuple[dict, ...]]:
"""执行一次 tool_call,返回 (结果文本, 本次是否有净产出)。
净产出供 run loop 的全局无进展熔断判定
@ -686,7 +695,7 @@ class AgentLoop:
try:
args = json.loads(raw_args)
except json.JSONDecodeError as e:
return f"[Error] invalid JSON arguments for {name}: {e}", False
return f"[Error] invalid JSON arguments for {name}: {e}", False, ()
args_preview = json.dumps(args, ensure_ascii=False)
if len(args_preview) > 200:
@ -700,7 +709,7 @@ class AgentLoop:
blocked = self._check_repeat_block(name, args)
if blocked is not None:
return blocked, False
return blocked, False, ()
ctx = ExecCtx(
user_id=self.user_id,
@ -709,7 +718,8 @@ class AgentLoop:
cancel_check=self.cancel_check,
)
tool_started_at = time.time()
result = self.executor.call_tool(name, args, ctx).content
tool_result = self.executor.call_tool(name, args, ctx)
result = tool_result.content
# 控制返回给模型的 tool 结果体量,避免炸 context
MAX_LEN = 16_000
@ -729,8 +739,24 @@ class AgentLoop:
"result": result,
"preview": preview,
"truncated": truncated,
"artifacts": list(getattr(tool_result, "artifacts", ()) or ()),
})
return result, productive
return result, productive, tuple(getattr(tool_result, "artifacts", ()) or ())
def _remember_artifacts(self, refs: tuple[dict, ...]) -> None:
"""Accumulate a bounded, ordered set for the final assistant message."""
seen = {
(str(ref.get("scope") or ""), str(ref.get("path") or ""))
for ref in self._pending_artifact_refs
}
for ref in refs:
key = (str(ref.get("scope") or ""), str(ref.get("path") or ""))
if not key[1] or key in seen:
continue
self._pending_artifact_refs.append(dict(ref))
seen.add(key)
if len(self._pending_artifact_refs) >= MAX_ARTIFACTS_PER_MESSAGE:
break
def _check_repeat_block(self, name: str, args: Any) -> Optional[str]:
"""执行前的两道拦截(命中返回拦截话术,未命中返 None):

View File

@ -61,7 +61,12 @@ class Session:
self.messages.append({"role": "system", "content": system_prompt})
self._n_head = 1
def append(self, msg: Any) -> Optional[UUID]:
def append(
self,
msg: Any,
*,
artifact_refs: Optional[list[dict]] = None,
) -> Optional[UUID]:
"""追加消息;非 system 落 DB,system 仅内存。返回新落库行的 message_id。
前置条件:tasks 行已由 web 入口(`POST /v1/tasks` `ensure_local_task_row`)写入;
@ -96,6 +101,7 @@ class Session:
task_id=self.task_id,
idx=self._db_idx,
payload=msg_dict,
artifact_refs=artifact_refs,
)
s.add(row)
s.flush() # 触发 INSERT 拿到 server-default 生成的 message_id

View File

@ -170,6 +170,10 @@ class Message(Base):
)
idx: Mapped[int] = mapped_column(Integer, nullable=False)
payload: Mapped[dict[str, Any]] = mapped_column(JSONB, nullable=False)
# Optional structured user-facing deliverables. NULL means legacy message (frontend may
# fall back to path extraction); [] means a new message explicitly published no artifacts.
# Kept outside payload so provider-bound conversation messages remain protocol-clean.
artifact_refs: Mapped[Optional[list[dict[str, Any]]]] = mapped_column(JSONB, nullable=True)
tokens_in: Mapped[Optional[int]] = mapped_column(Integer, nullable=True)
tokens_out: Mapped[Optional[int]] = mapped_column(Integer, nullable=True)
# 0006:产生该 message 的模型(只在 assistant 行有值;user/tool/system 为 NULL)。

View File

@ -121,12 +121,17 @@ def build_tools(ctx: ToolContext) -> dict[str, Any]:
]
def _task_actions() -> list:
from tools.publish_artifacts import PublishArtifactsTool
return [
RenameWorkingDirTool(
ctx.deferred_actions,
working_dir=ctx.working_dir_path,
**base,
)
),
PublishArtifactsTool(
working_dir=ctx.working_dir_path,
**wd_base,
),
]
def _document_search() -> list:

View File

@ -0,0 +1,28 @@
"""Add structured task-relative artifact references to messages.
Revision ID: 0025
Revises: 0024
Create Date: 2026-08-03
"""
from typing import Sequence, Union
import sqlalchemy as sa
from alembic import op
from sqlalchemy.dialects import postgresql
revision: str = "0025"
down_revision: Union[str, None] = "0024"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.add_column(
"messages",
sa.Column("artifact_refs", postgresql.JSONB(astext_type=sa.Text()), nullable=True),
)
def downgrade() -> None:
op.drop_column("messages", "artifact_refs")

View File

@ -56,7 +56,12 @@ test("assistant HTML artifacts render inline with lazy loading and an expand act
assert.match(mediaJs, /new IntersectionObserver/);
assert.match(mediaJs, /configureHtmlPreviewFrame\(frame, source/);
assert.match(pageHtml, /\.art-html-frame/);
assert.match(chatJs, /renderArtifactBarHtml\(extractArtifactRels\(p\.content, wd\), "html"\)/);
assert.match(chatJs, /renderArtifactBarHtml\(extractArtifactRels\(p\.content, wd\), "html", state\.taskId/);
assert.match(chatJs, /Array\.isArray\(m\.artifact_refs\)/);
assert.match(chatJs, /renderArtifactBarHtml\(m\.artifact_refs, true, state\.taskId/);
assert.match(previewJs, /\/v1\/tasks\/\$\{encodeURIComponent\(taskId\)\}\/files\/download/);
assert.match(previewJs, /downloadFile\(_fpCurrentRel, _fpCurrentTaskId, _fpCurrentLegacy\)/);
assert.match(chatJs, /dataset\.legacyPath === "1"/);
const clickHandler = chatJs.indexOf('$("chat-stream").addEventListener("click"');
const expandHandler = chatJs.indexOf('e.target.closest(".art-html-open[data-rel]")');
const sendMessage = chatJs.indexOf("async function sendMessage");

114
tests/test_artifacts.py Normal file
View File

@ -0,0 +1,114 @@
import tempfile
import unittest
from pathlib import Path
from core.artifacts import ArtifactPathError, ToolExecutionResult, resolve_artifact_path
from core.executor import ExecCtx
from core.executor_host import HostExecutor
from tools.publish_artifacts import PublishArtifactsTool
from web.routers.files import _task_file_target
class ArtifactPathTests(unittest.TestCase):
def setUp(self) -> None:
self.tmp = tempfile.TemporaryDirectory()
self.root = Path(self.tmp.name)
self.wd = self.root / "技术讨论"
self.wd.mkdir()
(self.wd / "report.pdf").write_bytes(b"pdf")
def tearDown(self) -> None:
self.tmp.cleanup()
def test_canonical_and_legacy_paths_resolve_to_same_file(self) -> None:
expected = self.wd / "report.pdf"
for raw in (
"report.pdf",
"技术讨论/report.pdf",
str(expected),
"/workspace/技术讨论/report.pdf",
):
with self.subTest(raw=raw):
actual, rel = resolve_artifact_path(
raw, working_dir=self.wd, user_root=self.root,
)
self.assertEqual(actual, expected.resolve())
self.assertEqual(rel, "report.pdf")
def test_explicit_dot_slash_disambiguates_same_named_subdirectory(self) -> None:
nested = self.wd / "技术讨论" / "nested.html"
nested.parent.mkdir()
nested.write_text("ok", encoding="utf-8")
actual, rel = resolve_artifact_path(
"./技术讨论/nested.html", working_dir=self.wd, user_root=self.root,
)
self.assertEqual(actual, nested.resolve())
self.assertEqual(rel, "技术讨论/nested.html")
def test_escape_and_directory_are_rejected(self) -> None:
for raw in ("../outside.txt", "."):
with self.subTest(raw=raw), self.assertRaises(ArtifactPathError):
resolve_artifact_path(
raw, working_dir=self.wd, user_root=self.root,
)
def test_publish_artifacts_is_explicit_bounded_and_deduplicated(self) -> None:
tool = PublishArtifactsTool(
working_dir=self.wd,
base_dir=self.wd,
user_root=self.root,
)
result = tool.execute({"not": "a list"})
self.assertIsInstance(result, str)
published = tool.execute([
{"path": "report.pdf", "label": "最终报告"},
{"path": "./report.pdf"},
])
self.assertIsInstance(published, ToolExecutionResult)
self.assertEqual(len(published.artifacts), 1)
self.assertEqual(published.artifacts[0].path, "report.pdf")
self.assertEqual(published.artifacts[0].label, "最终报告")
executed = HostExecutor({tool.name: tool}).call_tool(
tool.name,
{"artifacts": [{"path": "report.pdf"}]},
ExecCtx(user_id="u", task_id="t", working_dir=self.wd),
)
self.assertEqual(executed.content, "[OK] published 1 artifact(s): report.pdf")
self.assertEqual(executed.artifacts[0]["path"], "report.pdf")
def test_publish_path_is_strictly_task_relative_when_names_repeat(self) -> None:
nested = self.wd / "技术讨论" / "nested.html"
nested.parent.mkdir()
nested.write_text("ok", encoding="utf-8")
tool = PublishArtifactsTool(
working_dir=self.wd,
base_dir=self.wd,
user_root=self.root,
)
published = tool.execute([{"path": "技术讨论/nested.html"}])
self.assertIsInstance(published, ToolExecutionResult)
self.assertEqual(published.artifacts[0].path, "技术讨论/nested.html")
def test_legacy_card_resolution_covers_both_known_shapes_and_rename(self) -> None:
direct = self.wd / "manual.pdf"
direct.write_bytes(b"direct")
nested = self.wd / "技术讨论" / "nested.html"
nested.parent.mkdir()
nested.write_text("nested", encoding="utf-8")
# 92ac20cf shape: old user-root path already points at the correct file.
self.assertEqual(
_task_file_target(self.root, self.wd, "技术讨论/manual.pdf", True),
direct.resolve(),
)
# 9b4502aa shape: the same text was actually task-relative into a repeated dir.
self.assertEqual(
_task_file_target(self.root, self.wd, "技术讨论/nested.html", True),
nested.resolve(),
)
# After a top-level rename, dropping the obsolete first component finds the file.
self.assertEqual(
_task_file_target(self.root, self.wd, "旧目录/manual.pdf", True),
direct.resolve(),
)

View File

@ -5,6 +5,7 @@ import unittest
from pathlib import Path
from types import SimpleNamespace
from unittest.mock import MagicMock
from unittest.mock import patch
from uuid import uuid4
from core.loop import AgentLoop
@ -14,10 +15,12 @@ class _Session:
def __init__(self, messages=None):
self.messages = list(messages or [])
self.appended = []
self.append_artifacts = []
def append(self, message):
def append(self, message, *, artifact_refs=None):
self.messages.append(message)
self.appended.append(message)
self.append_artifacts.append(artifact_refs)
return uuid4()
@ -60,6 +63,44 @@ class PersistedTurnTests(unittest.TestCase):
[{"role": "user", "content": "新消息"}],
)
def test_final_assistant_persists_explicit_artifact_list(self) -> None:
session = _Session([{"role": "user", "content": "生成报告"}])
loop = AgentLoop(
llm=MagicMock(),
executor=MagicMock(),
session=session,
capabilities=SimpleNamespace(
max_iterations=1, family="test", variant="model",
input_cny_per_mtoken=0, output_cny_per_mtoken=0,
cache_hit_cny_per_mtoken=0,
),
user_id=uuid4(),
working_dir=Path("."),
)
loop._maybe_fold_context = MagicMock()
loop._pending_artifact_refs = [{
"version": 1, "scope": "working_dir", "path": "report.pdf",
}]
# _run resets turn state; emulate a published tool by restoring the pending ref when
# the final model response is received.
response = SimpleNamespace(
choices=[SimpleNamespace(message=SimpleNamespace(
content="已完成", tool_calls=None,
))],
usage=None,
)
loop._stream_llm = MagicMock(return_value=(response, False))
original_fold = loop._maybe_fold_context
original_fold.side_effect = lambda: loop._pending_artifact_refs.append({
"version": 1, "scope": "working_dir", "path": "report.pdf",
})
with patch("core.loop.record_chat_usage"):
result = loop.run_persisted_turn()
self.assertEqual(result, "已完成")
self.assertEqual(session.append_artifacts[-1], [{
"version": 1, "scope": "working_dir", "path": "report.pdf",
}])
if __name__ == "__main__":
unittest.main()

View File

@ -111,7 +111,7 @@ class TestLoopHotSwap(unittest.TestCase):
return "glm.pro52", new_caps, new_llm
loop = _make_loop("[skill=ppt, dir=x]\n# PPT", switcher)
result, _ = loop._execute_tool_call(_load_skill_tc())
result, _, _artifacts = loop._execute_tool_call(_load_skill_tc())
self.assertEqual(calls, [("ppt", "deepseek_v4.flash")])
self.assertIs(loop.caps, new_caps)
@ -127,7 +127,7 @@ class TestLoopHotSwap(unittest.TestCase):
def test_no_switch_when_switcher_returns_none(self):
loop = _make_loop("[skill=ppt, dir=x]\n# PPT", lambda n, c: None)
old_caps, old_llm = loop.caps, loop.llm
result, _ = loop._execute_tool_call(_load_skill_tc())
result, _, _artifacts = loop._execute_tool_call(_load_skill_tc())
self.assertIs(loop.caps, old_caps)
self.assertIs(loop.llm, old_llm)
self.assertNotIn("[模型切换]", result)
@ -148,7 +148,7 @@ class TestLoopHotSwap(unittest.TestCase):
loop = _make_loop("[skill=ppt, dir=x]\n# PPT", switcher)
old_caps, old_llm = loop.caps, loop.llm
result, _ = loop._execute_tool_call(_load_skill_tc())
result, _, _artifacts = loop._execute_tool_call(_load_skill_tc())
self.assertIs(loop.caps, old_caps)
self.assertIs(loop.llm, old_llm)
self.assertNotIn("[模型切换]", result)

View File

@ -347,6 +347,38 @@ class FilesDbAwareTests(unittest.TestCase):
self.assertTrue(d["working_dir"].endswith("/改名后目录"))
self.assertTrue((_user_root() / "改名后目录").is_dir())
def test_task_relative_download_survives_working_dir_rename(self):
tid = self._mk_task("稳定产物任务", "产物旧目录")
artifact = _user_root() / "产物旧目录" / "reports" / "result.txt"
artifact.parent.mkdir(parents=True, exist_ok=True)
artifact.write_text("stable", encoding="utf-8")
url = f"/v1/tasks/{tid}/files/download"
r = _client.get(url, params={"path": "reports/result.txt"}, headers=_AUTH)
self.assertEqual(r.status_code, 200, r.text)
self.assertEqual(r.content, b"stable")
r = _client.get(
url,
params={"path": "产物旧目录/reports/result.txt", "legacy": "true"},
headers=_AUTH,
)
self.assertEqual(r.status_code, 200, r.text)
self.assertEqual(r.content, b"stable")
r = _client.post(
"/v1/files/rename",
json={"path": "产物旧目录", "new_name": "产物新目录"},
headers=_AUTH,
)
self.assertEqual(r.status_code, 200, r.text)
r = _client.get(url, params={"path": "reports/result.txt"}, headers=_AUTH)
self.assertEqual(r.status_code, 200, r.text)
self.assertEqual(r.content, b"stable")
self.assertEqual(
_client.get(url, params={"path": "../escape.txt"}, headers=_AUTH).status_code,
400,
)
def test_toplevel_rename_blocked_while_running(self):
tid = self._mk_task("跑动中任务", "跑动中目录")
_set_run_status(tid, "running")

View File

@ -23,6 +23,7 @@ from pathlib import Path
from typing import Optional
from uuid import UUID
from core.artifacts import ArtifactRef, ToolExecutionResult, resolve_artifact_path
from core.ark_client import ArkClient, ArkConfig, ArkError
from core.storage.usage import record_image_usage
@ -184,12 +185,18 @@ class GptImageTool(Tool):
# 首行 banner 协议同 seedream(`key=value · ` 分隔,前端 extractMediaBanner 解析);
# 价格未知(price=0)时不放 cost 段,避免"¥0.00 = 免费"的误导。
cost_seg = f" · cost=¥{price:.2f}" if price > 0 else ""
return (
result = (
f"[gpt_image] model={model_id} · size={actual_size} · quality={actual_quality}"
f"{cost_seg} · elapsed={elapsed:.1f}s\n"
f"saved: {disp}\n"
f"prompt={prompt!r}"
)
if self.user_root is None:
return result
_, rel = resolve_artifact_path(
str(dest_png), working_dir=self.working_dir, user_root=self.user_root,
)
return ToolExecutionResult(content=result, artifacts=(ArtifactRef(path=rel),))
@staticmethod
def _normalize_size(raw: object) -> tuple[str, str]:

View File

@ -15,6 +15,7 @@ from pathlib import Path
from typing import Optional
from uuid import uuid4
from core.artifacts import ArtifactRef, ToolExecutionResult, resolve_artifact_path
from tools.base import FileOutOfBounds, Tool
from web.pptx_render import SofficeNotFoundError, find_soffice
@ -147,7 +148,7 @@ class OfficeToPdfTool(Tool):
return root_candidate
return base_candidate
def execute(self, source: str, output: Optional[str] = None) -> str:
def execute(self, source: str, output: Optional[str] = None) -> str | ToolExecutionResult:
try:
src = self._resolve_office_path(source, existing=True)
except FileOutOfBounds:
@ -218,4 +219,13 @@ class OfficeToPdfTool(Tool):
except OSError as e:
return f"[Error] failed to publish PDF: {type(e).__name__}: {e}"
return f"[OK] PDF created: {self._display(out)} ({out.stat().st_size} bytes)"
content = f"[OK] PDF created: {self._display(out)} ({out.stat().st_size} bytes)"
if self.user_root is None:
return content
try:
_, rel = resolve_artifact_path(
str(out), working_dir=self.base_dir, user_root=self.user_root,
)
except ValueError:
return content
return ToolExecutionResult(content=content, artifacts=(ArtifactRef(path=rel),))

View File

@ -0,0 +1,95 @@
"""Explicitly promote a small set of workspace files to user-facing artifacts."""
from __future__ import annotations
from pathlib import Path
from core.artifacts import (
MAX_ARTIFACTS_PER_MESSAGE,
ArtifactPathError,
ArtifactRef,
ToolExecutionResult,
resolve_artifact_path,
)
from .base import Tool
class PublishArtifactsTool(Tool):
name = "publish_artifacts"
description = (
"Publish a small set of final deliverable files to the chat. Ordinary source, "
"temporary, intermediate, and project support files should stay in the file panel "
"and must not be published. Paths are relative to the current task working directory."
)
parameters = {
"type": "object",
"properties": {
"artifacts": {
"type": "array",
"minItems": 1,
"maxItems": MAX_ARTIFACTS_PER_MESSAGE,
"items": {
"type": "object",
"properties": {
"path": {
"type": "string",
"minLength": 1,
"maxLength": 1000,
"description": "File path relative to the current task working directory.",
},
"label": {
"type": "string",
"maxLength": 120,
"description": "Optional short user-facing label.",
},
},
"required": ["path"],
},
}
},
"required": ["artifacts"],
}
def __init__(self, working_dir: Path, **kwargs) -> None:
super().__init__(**kwargs)
self.working_dir = Path(working_dir)
def execute(self, artifacts: list[dict]) -> ToolExecutionResult | str:
if not isinstance(artifacts, list) or not artifacts:
return "[Error] artifacts must be a non-empty list"
if len(artifacts) > MAX_ARTIFACTS_PER_MESSAGE:
return f"[Error] at most {MAX_ARTIFACTS_PER_MESSAGE} artifacts may be published at once"
if self.user_root is None:
return "[Error] publish_artifacts requires a user workspace"
refs: list[ArtifactRef] = []
seen: set[str] = set()
for item in artifacts:
if not isinstance(item, dict):
return "[Error] every artifact must be an object with path and optional label"
try:
raw_path = str(item.get("path") or "")
if len(raw_path) > 1000:
return "[Error] artifact path is too long"
label = str(item.get("label") or "").strip()
if len(label) > 120:
return "[Error] artifact label is too long"
_, rel = resolve_artifact_path(
raw_path,
working_dir=self.working_dir,
user_root=self.user_root,
require_file=True,
allow_legacy_user_relative=False,
)
except ArtifactPathError as exc:
return f"[Error] cannot publish artifact: {exc}"
if rel in seen:
continue
seen.add(rel)
refs.append(ArtifactRef(path=rel, label=label))
names = ", ".join(ref.label or ref.path for ref in refs)
return ToolExecutionResult(
content=f"[OK] published {len(refs)} artifact(s): {names}",
artifacts=tuple(refs),
)

View File

@ -24,6 +24,7 @@ from pathlib import Path
from typing import Any, Callable, Optional
from uuid import UUID
from core.artifacts import ArtifactRef, ToolExecutionResult, resolve_artifact_path
from core.ark_client import ArkClient, ArkConfig, ArkError
from core.storage.usage import record_video_usage
@ -388,7 +389,7 @@ class SeedanceTool(Tool):
image_banner += f" · image={first_frame_display}"
# banner 协议与 seedream 一致:首行 `[tool] key=value · key=value ...`
# 前端 extractMediaBanner 已 whitelist seedance,正则抓 key=value 挂徽章
return (
result = (
f"[seedance] model={model_id} · mode={mode}{image_banner} · "
f"resolution={chosen_resolution} · ratio={chosen_ratio} · "
f"duration={chosen_duration}s · audio={chosen_generate_audio} · "
@ -397,6 +398,12 @@ class SeedanceTool(Tool):
f"prompt={prompt!r}\n"
f"watermark={chosen_watermark} cgt_id={cgt_id}"
)
if self.user_root is None:
return result
_, rel = resolve_artifact_path(
str(dest_mp4), working_dir=self.working_dir, user_root=self.user_root,
)
return ToolExecutionResult(content=result, artifacts=(ArtifactRef(path=rel),))
@staticmethod
def _rough_cost(resolution: str, ratio: str, duration_s: int, fps: int, price_per_mtoken: float) -> float:

View File

@ -15,6 +15,7 @@ from pathlib import Path
from typing import Any, Optional
from uuid import UUID
from core.artifacts import ArtifactRef, ToolExecutionResult, resolve_artifact_path
from core.ark_client import ArkClient, ArkConfig, ArkError
from core.storage.usage import record_image_usage
@ -222,13 +223,19 @@ class SeedreamTool(Tool):
mode_seg = " · mode=i2i" if is_i2i else ""
ref_line = f"\nreference={ref_disp[0]}" if is_i2i else ""
note_line = f"\n{size_note}" if size_note else ""
return (
result = (
f"[seedream] model={model_id} · size={chosen_size} · "
f"cost=¥{cost_cny:.2f} · elapsed={elapsed:.1f}s{mode_seg}\n"
f"saved: {disp}{ref_line}\n"
f"prompt={prompt!r}\n"
f"watermark={chosen_watermark} search={chosen_search}{note_line}"
)
if self.user_root is None:
return result
_, rel = resolve_artifact_path(
str(dest_png), working_dir=self.working_dir, user_root=self.user_root,
)
return ToolExecutionResult(content=result, artifacts=(ArtifactRef(path=rel),))
@staticmethod
def _normalize_size(

View File

@ -14,7 +14,7 @@ from fastapi import Depends, File, Form, HTTPException, UploadFile
from fastapi.responses import FileResponse
from sqlalchemy import func, select
from core.paths import to_db_path
from core.paths import from_db_path, to_db_path
from core.storage import session_scope
from core.storage.models import Task
from core.working_dirs import (
@ -45,6 +45,88 @@ def _pptx_lock_for(abs_path: str) -> asyncio.Lock:
return lock
def _regular_file_response(target: Path, display_path: str) -> FileResponse:
if not target.exists():
raise HTTPException(404, f"file not found: {display_path}")
if not target.is_file():
raise HTTPException(400, f"not a file: {display_path}")
media_type = "image/svg+xml" if target.suffix.lower() == ".svg" else None
return FileResponse(
path=str(target),
filename=target.name,
media_type=media_type,
headers={"Cache-Control": "no-cache"},
)
def _task_working_dir(task_id: str, user_id: UUID, root: Path) -> tuple[UUID, Path]:
try:
tid = UUID(task_id)
except ValueError:
raise HTTPException(404, f"invalid task id: {task_id!r}")
with session_scope() as s:
db_path = s.execute(
select(Task.working_dir).where(Task.task_id == tid, Task.user_id == user_id)
).scalar_one_or_none()
if not db_path:
raise HTTPException(404, "task not found")
working_dir = from_db_path(db_path).resolve()
try:
working_dir.relative_to(root.resolve())
except ValueError:
raise HTTPException(400, "task working_dir is outside user workspace")
return tid, working_dir
def _task_file_target(root: Path, working_dir: Path, path: str, legacy: bool) -> Path:
"""Resolve canonical task refs, with a read-only fallback chain for old cards.
Historical messages used user-root paths, while one known failure accidentally emitted
a task-relative path with the same leading directory name. Old cards therefore try the
original user-root meaning first, then both task-relative interpretations. New refs never
use this branch and remain unambiguous.
"""
if not legacy:
return safe_join(working_dir, path)
candidates = [safe_join(root, path), safe_join(working_dir, path)]
normalized = str(path or "").replace("\\", "/")
if "/" in normalized:
candidates.append(safe_join(working_dir, normalized.split("/", 1)[1]))
for candidate in candidates:
if candidate.is_file():
return candidate
return candidates[0]
async def _pptx_preview_response(target: Path, display_path: str) -> FileResponse:
from ..pptx_render import (
PptxConvertError,
SofficeNotFoundError,
pptx_to_pdf,
)
if not target.exists():
raise HTTPException(404, f"file not found: {display_path}")
if not target.is_file():
raise HTTPException(400, f"not a file: {display_path}")
if target.suffix.lower() not in (".pptx", ".ppt"):
raise HTTPException(400, f"not a pptx: {display_path}")
abs_path = str(target.resolve())
loop = asyncio.get_event_loop()
async with _pptx_lock_for(abs_path):
try:
pdf_path = await loop.run_in_executor(None, pptx_to_pdf, target)
except SofficeNotFoundError as e:
raise HTTPException(501, str(e))
except PptxConvertError as e:
raise HTTPException(500, str(e))
return FileResponse(
path=str(pdf_path),
media_type="application/pdf",
headers={"Cache-Control": "no-cache"},
)
def register_file_routes(app, *, require_user) -> None:
@app.get("/v1/user/storage", tags=["user"])
def user_storage(user_id: UUID = Depends(require_user)):
@ -105,24 +187,25 @@ def register_file_routes(app, *, require_user) -> None:
"""下载 user_root 下单个 regular file(目录 → 400 / 不存在 → 404)。"""
root = load_user_root(user_id)
target = safe_join(root, path)
if not target.exists():
raise HTTPException(404, f"file not found: {path}")
if not target.is_file():
raise HTTPException(400, f"not a file: {path}")
# workspace 文件可变, 禁浏览器启发式缓存 (RFC 7234 默认能缓数小时)
# 否则文件改了 SPA 预览还是旧内容
# (Starlette FileResponse 不实现 304, 总是 200 全量; workspace 文件小, 可接受)
# .svg 显式给 image/svg+xml: 部分部署环境 mimetypes 未注册 svg, FileResponse
# 会猜成 octet-stream, 前端 <img> 就渲染不出 SVG 预览
media_type = None
if target.suffix.lower() == ".svg":
media_type = "image/svg+xml"
return FileResponse(
path=str(target),
filename=target.name,
media_type=media_type,
headers={"Cache-Control": "no-cache"},
)
return _regular_file_response(target, path)
@app.get("/v1/tasks/{task_id}/files/download", tags=["files"])
def download_task_file(
task_id: str,
path: str,
legacy: bool = False,
user_id: UUID = Depends(require_user),
):
"""Download a file addressed relative to the task's current working_dir."""
root = load_user_root(user_id)
_tid, working_dir = _task_working_dir(task_id, user_id, root)
target = _task_file_target(root, working_dir, path, legacy)
return _regular_file_response(target, path)
@app.get("/v1/files/preview_pdf", tags=["files"])
async def preview_pdf(
@ -134,35 +217,22 @@ def register_file_routes(app, *, require_user) -> None:
转换跑在 backend host(不进沙盒),按需触发 + 缓存到 `.preview/`(DESIGN §8.3)
soffice 缺失 501;转换失败/超时 500;前端据此回退到下载
"""
from ..pptx_render import (
PptxConvertError,
SofficeNotFoundError,
pptx_to_pdf,
)
root = load_user_root(user_id)
target = safe_join(root, path)
if not target.exists():
raise HTTPException(404, f"file not found: {path}")
if not target.is_file():
raise HTTPException(400, f"not a file: {path}")
if target.suffix.lower() not in (".pptx", ".ppt"):
raise HTTPException(400, f"not a pptx: {path}")
return await _pptx_preview_response(target, path)
abs_path = str(target.resolve())
loop = asyncio.get_event_loop()
async with _pptx_lock_for(abs_path):
try:
pdf_path = await loop.run_in_executor(None, pptx_to_pdf, target)
except SofficeNotFoundError as e:
raise HTTPException(501, str(e))
except PptxConvertError as e:
raise HTTPException(500, str(e))
return FileResponse(
path=str(pdf_path),
media_type="application/pdf",
headers={"Cache-Control": "no-cache"},
)
@app.get("/v1/tasks/{task_id}/files/preview_pdf", tags=["files"])
async def preview_task_pdf(
task_id: str,
path: str,
legacy: bool = False,
user_id: UUID = Depends(require_user),
):
"""Preview a PPT addressed relative to the task's current working_dir."""
root = load_user_root(user_id)
_tid, working_dir = _task_working_dir(task_id, user_id, root)
target = _task_file_target(root, working_dir, path, legacy)
return await _pptx_preview_response(target, path)
@app.post("/v1/files/upload", tags=["files"])
async def upload_files(

View File

@ -99,6 +99,7 @@ def register_message_routes(app, *, require_user) -> None:
cols = (
Message.idx, Message.payload, Message.tokens_in,
Message.tokens_out, Message.model_profile, Message.created_at,
Message.artifact_refs,
)
if limit is None:
# 旧行为:升序全量
@ -146,6 +147,7 @@ def register_message_routes(app, *, require_user) -> None:
"tokens_out": r.tokens_out,
"model_profile": r.model_profile, # 0006:assistant 行非空,标产生该 msg 的模型
"created_at": iso(r.created_at),
"artifact_refs": r.artifact_refs,
}
for r in rows
]

View File

@ -1482,6 +1482,17 @@ function renderMessages(msgs, { stickBottom = true } = {}) {
// chip 去重:同一路径在 tool 结果里挂过 inline 图后,assistant 正文 echo 同路径不再重挂。
// chronological 遍历,首次出现保留(tool 结果常在前),后续重复过滤掉。
const seenRels = new Set();
// New messages explicitly carry artifact_refs (including []). Suppress legacy tool-result
// path extraction for that user turn so auto-published media is not shown twice.
const msgTurn = new Map();
const structuredTurns = new Set();
let turnNo = -1;
for (const item of msgs) {
const payload = item.payload || {};
if (payload.role === "user") turnNo += 1;
msgTurn.set(item.idx, turnNo);
if (Array.isArray(item.artifact_refs)) structuredTurns.add(turnNo);
}
// 历史态把 assistant tool_call 与紧随其后的 tool result 合成一条活动项。
// tool result 才是最终可追溯记录;已得到结果的 call 不再额外渲一条“准备调用”,避免双行噪声。
const toolCallsById = new Map();
@ -1548,14 +1559,15 @@ function renderMessages(msgs, { stickBottom = true } = {}) {
const failed = isToolResultFailure(txt);
// 工具结果只有产物工具(seedream/seedance)挂 chip + inline 大图;通用工具
// (grep/read/glob/shell)echo 的路径是"引用"不是"产物",不挂以免噪声。
const isProducer = ARTIFACT_PRODUCING_TOOLS.has(p.name || "");
const isProducer = ARTIFACT_PRODUCING_TOOLS.has(p.name || "")
&& !structuredTurns.has(msgTurn.get(m.idx));
const rels = isProducer ? pickFresh(extractArtifactRels(txt || "", wd)) : [];
card.innerHTML = `
<details class="tool-call activity${failed ? " failed" : ""}">
<summary><span class="tool-state" aria-hidden="true">${failed ? "!" : "✓"}</span><span class="tool-label">${escapeHtml(activityLabel)}</span><span class="tool-result-meta">${(txt || "").length} </span>${banner}</summary>
<pre>${escapeHtml(txt || "")}</pre>
</details>
${renderArtifactBarHtml(rels, isProducer)}
${renderArtifactBarHtml(rels, isProducer, state.taskId || "", true)}
`;
// bg proc 启动结果 → 卡片活化(spinner/跳秒/停止,与直播态同构;真实状态
// 由 selectTask 尾部的 refreshProcs 校正,终态卡显示 exit/耗时定格)
@ -1584,11 +1596,15 @@ function renderMessages(msgs, { stickBottom = true } = {}) {
// assistant 正文里 echo 的 <wd>/... 路径**永远**展示(绕开 seenRels)。图片/视频
// 已可能在产物工具结果中内联,仍用 chip 防重复HTML 通常由 write/shell 产出,
// 没有 producer 工具卡可承载,故在最终答复处直接升级为懒加载内嵌卡片。
if (role === "assistant") {
if (role === "assistant" && !Array.isArray(m.artifact_refs)) {
const wd = _workingDirName(state.taskMeta && state.taskMeta.working_dir);
html += renderArtifactBarHtml(extractArtifactRels(p.content, wd), "html");
html += renderArtifactBarHtml(extractArtifactRels(p.content, wd), "html", state.taskId || "", true);
}
}
if (role === "assistant" && Array.isArray(m.artifact_refs)) {
html += renderArtifactBarHtml(m.artifact_refs, true, state.taskId || "");
if (m.artifact_refs.length) hasVisibleAssistantContent = true;
}
if (Array.isArray(p.tool_calls) && p.tool_calls.length) {
const wd = _workingDirName(state.taskMeta && state.taskMeta.working_dir);
const progressResult = progressActionsFromToolCalls(p.tool_calls, currentProgressSteps);
@ -1624,7 +1640,7 @@ function renderMessages(msgs, { stickBottom = true } = {}) {
const rels = isProducer ? pickFresh(extractArtifactRels(args, wd)) : [];
html += `
<details class="tool-call activity"><summary><span class="tool-state" aria-hidden="true"></span><span class="tool-label">${escapeHtml(label)}</span></summary><pre>${escapeHtml(args)}</pre></details>
${renderArtifactBarHtml(rels, isProducer)}
${renderArtifactBarHtml(rels, isProducer, state.taskId || "", true)}
`;
hasVisibleAssistantContent = true;
}
@ -2391,19 +2407,19 @@ $("chat-stream").addEventListener("click", (e) => {
const chip = e.target.closest && e.target.closest(".art-chip");
if (chip) {
const rel = chip.dataset.rel;
if (rel) openFilePreview(rel);
if (rel) openFilePreview(rel, chip.dataset.taskId || "", chip.dataset.legacyPath === "1");
return;
}
const htmlOpen = e.target.closest && e.target.closest(".art-html-open[data-rel]");
if (htmlOpen) {
const rel = htmlOpen.dataset.rel;
if (rel) openFilePreview(rel);
if (rel) openFilePreview(rel, htmlOpen.dataset.taskId || "", htmlOpen.dataset.legacyPath === "1");
return;
}
const inlineImg = e.target.closest && e.target.closest(".art-media-image[data-rel]");
if (inlineImg) {
const rel = inlineImg.dataset.rel;
if (rel) openFilePreview(rel);
if (rel) openFilePreview(rel, inlineImg.dataset.taskId || "", inlineImg.dataset.legacyPath === "1");
return;
}
// 正文里的 markdown 链接:模型常把工作区相对路径写成 [<rel>](<rel>),renderMd 出 <a>。
@ -2886,7 +2902,7 @@ function handleSseEvent(ev, asstCard, ctx) {
? extractArtifactRels(argsStr, wd).filter(r => !ctx.seenRels.has(r))
: [];
fresh.forEach(r => ctx.seenRels.add(r));
const barHtml = renderArtifactBarHtml(fresh, isProducer);
const barHtml = renderArtifactBarHtml(fresh, isProducer, ctx.taskId || "", true);
if (barHtml) {
asstCard.insertAdjacentHTML("beforeend", barHtml);
if (isProducer) upgradeMediaArtifacts(asstCard);
@ -2917,11 +2933,17 @@ function handleSseEvent(ev, asstCard, ctx) {
if (summary && banner) summary.insertAdjacentHTML("beforeend", banner);
const wd = _workingDirName(ctx.workingDir);
const isProducer = ARTIFACT_PRODUCING_TOOLS.has(toolName);
const fresh = isProducer
const structured = Array.isArray(ev.data && ev.data.artifacts) ? ev.data.artifacts : [];
const fresh = structured.length ? structured : (isProducer
? extractArtifactRels(txtStr, wd).filter(r => !ctx.seenRels.has(r))
: [];
fresh.forEach(r => ctx.seenRels.add(r));
const barHtml = renderArtifactBarHtml(fresh, isProducer);
: []);
fresh.forEach(r => ctx.seenRels.add(typeof r === "string" ? r : r.path));
const barHtml = renderArtifactBarHtml(
fresh,
isProducer || structured.length > 0,
ctx.taskId || "",
structured.length === 0,
);
if (barHtml) {
asstCard.insertAdjacentHTML("beforeend", barHtml);
if (isProducer) upgradeMediaArtifacts(asstCard);

View File

@ -35,6 +35,8 @@ export function toolActivityLabel(name, args) {
case "web_search": return `联网搜索: ${clip(a.query, 60)}`;
case "load_skill": return `加载技能: ${clip(a.name, 40)}`;
case "rename_working_dir": return `重命名工作目录: ${clip(a.new_name, 60)}`;
case "publish_artifacts": return `发布最终产物: ${clip(JSON.stringify(a.artifacts || []), 80)}`;
case "office_to_pdf": return `转换 PDF: ${clip(a.source, 80)}`;
case "seedream": return `生成图像: ${clip(a.prompt, 60)}`;
case "gpt_image": return `生成图像: ${clip(a.prompt, 60)}`;
case "seedance": return `生成视频: ${clip(a.prompt, 60)}`;
@ -168,23 +170,28 @@ export function extractArtifactRels(text, workingDir) {
// inlineMode 控制升级范围:true=图片/视频/HTML"html"=仅 HTMLfalse=全走 chip。
// 产物工具传 trueassistant 正文传 "html",避免重复内联图片/视频但让通用工具生成的
// HTML 有展示位;普通工具结果传 false引用到的文件仍走 chip避免把旧产物铺满消息。
export function renderArtifactBarHtml(rels, inlineMode = true) {
export function renderArtifactBarHtml(rels, inlineMode = true, taskId = "", legacy = false) {
if (!rels || !rels.length) return "";
const items = rels.map((rel) => {
const name = rel.split("/").pop() || rel;
const taskAttr = taskId ? ` data-task-id="${escapeHtml(taskId)}"` : "";
const legacyAttr = legacy ? ` data-legacy-path="1"` : "";
const items = rels.map((item) => {
const ref = (item && typeof item === "object") ? item : { path: item };
const rel = String(ref.path || "");
if (!rel) return "";
const name = String(ref.label || rel.split("/").pop() || rel);
const cat = _categorize(rel);
if ((inlineMode === true || inlineMode === "html") && cat === "html") {
return `<section class="art-html" data-rel="${escapeHtml(rel)}" title="${escapeHtml(rel)}">
<div class="art-html-head"><span>${escapeHtml(name)}</span><button type="button" class="art-html-open" data-rel="${escapeHtml(rel)}"></button></div>
return `<section class="art-html" data-rel="${escapeHtml(rel)}"${taskAttr}${legacyAttr} title="${escapeHtml(rel)}">
<div class="art-html-head"><span>${escapeHtml(name)}</span><button type="button" class="art-html-open" data-rel="${escapeHtml(rel)}"${taskAttr}${legacyAttr}></button></div>
<div class="art-html-viewport"><span class="art-media-loading">进入可视区域后加载</span></div>
</section>`;
}
if (inlineMode === true && (cat === "image" || cat === "video")) {
// 占位元素;插入 DOM 后 upgradeMediaArtifacts 异步 fetch blob → 填 <img>/<video>。
// 不在这里发请求避免 string-build 阶段失控的并发;upgrade 走 DOM walk 一次。
return `<span class="art-media art-media-${cat}" data-rel="${escapeHtml(rel)}" data-cat="${cat}" title="${escapeHtml(rel)}"><span class="art-media-loading">${escapeHtml(name)} 加载中…</span></span>`;
return `<span class="art-media art-media-${cat}" data-rel="${escapeHtml(rel)}" data-cat="${cat}"${taskAttr}${legacyAttr} title="${escapeHtml(rel)}"><span class="art-media-loading">${escapeHtml(name)} 加载中…</span></span>`;
}
return `<button type="button" class="art-chip" data-rel="${escapeHtml(rel)}" title="${escapeHtml(rel)} · 点击预览(可下载)">${escapeHtml(name)}</button>`;
return `<button type="button" class="art-chip" data-rel="${escapeHtml(rel)}"${taskAttr}${legacyAttr} title="${escapeHtml(rel)} · 点击预览(可下载)">${escapeHtml(name)}</button>`;
}).join("");
return `<div class="artifact-bar">${items}</div>`;
}
@ -196,22 +203,31 @@ const _mediaArtifactCache = new Map();
const _htmlArtifactCache = new Map();
const INLINE_HTML_MAX = 2 * 1024 * 1024;
function _fetchMediaBlobUrl(rel) {
if (_mediaArtifactCache.has(rel)) return _mediaArtifactCache.get(rel);
const p = fetch("/v1/files/download?path=" + encodeURIComponent(rel), {
function _artifactDownloadUrl(rel, taskId = "", legacy = false) {
const base = taskId
? `/v1/tasks/${encodeURIComponent(taskId)}/files/download`
: "/v1/files/download";
return base + "?path=" + encodeURIComponent(rel) + (legacy ? "&legacy=true" : "");
}
function _fetchMediaBlobUrl(rel, taskId = "", legacy = false) {
const key = `${taskId}:${legacy ? "legacy:" : ""}${rel}`;
if (_mediaArtifactCache.has(key)) return _mediaArtifactCache.get(key);
const p = fetch(_artifactDownloadUrl(rel, taskId, legacy), {
headers: { "Authorization": "Bearer " + state.token },
}).then(async (r) => {
if (!r.ok) throw new Error("HTTP " + r.status);
const blob = await r.blob();
return URL.createObjectURL(blob);
});
_mediaArtifactCache.set(rel, p);
_mediaArtifactCache.set(key, p);
return p;
}
function _fetchHtmlArtifact(rel) {
if (_htmlArtifactCache.has(rel)) return _htmlArtifactCache.get(rel);
const p = fetch("/v1/files/download?path=" + encodeURIComponent(rel), {
function _fetchHtmlArtifact(rel, taskId = "", legacy = false) {
const key = `${taskId}:${legacy ? "legacy:" : ""}${rel}`;
if (_htmlArtifactCache.has(key)) return _htmlArtifactCache.get(key);
const p = fetch(_artifactDownloadUrl(rel, taskId, legacy), {
headers: { "Authorization": "Bearer " + state.token },
}).then(async (r) => {
if (!r.ok) throw new Error("HTTP " + r.status);
@ -219,7 +235,7 @@ function _fetchHtmlArtifact(rel) {
if (blob.size > INLINE_HTML_MAX) throw new Error("文件过大,请点击放大预览");
return blob.text();
});
_htmlArtifactCache.set(rel, p);
_htmlArtifactCache.set(key, p);
return p;
}
@ -228,8 +244,10 @@ function _loadInlineHtml(node) {
if (!node || node.dataset.loaded) return;
node.dataset.loaded = "1";
const rel = node.dataset.rel;
const taskId = node.dataset.taskId || "";
const legacy = node.dataset.legacyPath === "1";
const viewport = node.querySelector(".art-html-viewport");
_fetchHtmlArtifact(rel).then((source) => {
_fetchHtmlArtifact(rel, taskId, legacy).then((source) => {
if (!node.isConnected || !viewport) return;
viewport.innerHTML = "";
const frame = document.createElement("iframe");
@ -276,8 +294,10 @@ export function upgradeMediaArtifacts(root) {
nodes.forEach((node) => {
node.dataset.upgraded = "1";
const rel = node.dataset.rel;
const taskId = node.dataset.taskId || "";
const legacy = node.dataset.legacyPath === "1";
const cat = node.dataset.cat;
_fetchMediaBlobUrl(rel).then((url) => {
_fetchMediaBlobUrl(rel, taskId, legacy).then((url) => {
node.innerHTML = "";
if (cat === "image") {
const img = document.createElement("img");
@ -303,8 +323,11 @@ export function upgradeMediaArtifacts(root) {
});
}
export function downloadFile(rel) {
fetch("/v1/files/download?path=" + encodeURIComponent(rel), {
export function downloadFile(rel, taskId = "", legacy = false) {
const base = taskId
? `/v1/tasks/${encodeURIComponent(taskId)}/files/download`
: "/v1/files/download";
fetch(base + "?path=" + encodeURIComponent(rel) + (legacy ? "&legacy=true" : ""), {
headers: { "Authorization": "Bearer " + state.token },
}).then(async (r) => {
if (!r.ok) { message("下载失败:" + r.status, "error"); return; }

View File

@ -63,6 +63,8 @@ export function _categorize(rel) {
}
let _fpCurrentRel = null;
let _fpCurrentTaskId = "";
let _fpCurrentLegacy = false;
// Markdown / HTML 共用“预览 / 源文件”切换。HTML 在 opaque-origin sandbox iframe
// 中运行:可执行脚本、加载 HTTPS 资源,但不能读取 zcbot 页面或发起表单/顶层跳转。
@ -234,8 +236,24 @@ function _bindBodyWheel(bodyEl) {
}, { passive: false });
}
export async function openFilePreview(rel) {
function _fileDownloadUrl(rel, taskId = "", legacy = false) {
const base = taskId
? `/v1/tasks/${encodeURIComponent(taskId)}/files/download`
: "/v1/files/download";
return base + "?path=" + encodeURIComponent(rel) + (legacy ? "&legacy=true" : "");
}
function _pptPreviewUrl(rel, taskId = "", legacy = false) {
const base = taskId
? `/v1/tasks/${encodeURIComponent(taskId)}/files/preview_pdf`
: "/v1/files/preview_pdf";
return base + "?path=" + encodeURIComponent(rel) + (legacy ? "&legacy=true" : "");
}
export async function openFilePreview(rel, taskId = "", legacy = false) {
_fpCurrentRel = rel;
_fpCurrentTaskId = taskId;
_fpCurrentLegacy = legacy;
const name = rel.split("/").pop() || rel;
$("fp-name").textContent = name;
$("fp-meta").textContent = "";
@ -254,9 +272,9 @@ export async function openFilePreview(rel) {
const cat = _categorize(rel);
// pptx/ppt:后端转 PDF 再复用现成 PDF iframe(非下载原文件),首次稍候 + 失败回退下载。
if (cat === "ppt") { await _showPptAsPdf(rel, $("fp-body"), $("fp-meta"), _showFallback); return; }
if (cat === "ppt") { await _showPptAsPdf(rel, $("fp-body"), $("fp-meta"), _showFallback, _trackBlobUrl, taskId, legacy); return; }
try {
const r = await fetch("/v1/files/download?path=" + encodeURIComponent(rel), {
const r = await fetch(_fileDownloadUrl(rel, taskId, legacy), {
headers: { "Authorization": "Bearer " + state.token },
});
if (!r.ok) throw new Error("HTTP " + r.status);
@ -326,13 +344,13 @@ function _showPdf(blob) {
}
// pptx/ppt → 后端转 PDF → iframe。main / mini 共用:传各自 body / meta / fallback / 追踪 blob 的 fn。
async function _showPptAsPdf(rel, body, metaEl, fallbackFn, trackFn = _trackBlobUrl) {
async function _showPptAsPdf(rel, body, metaEl, fallbackFn, trackFn = _trackBlobUrl, taskId = "", legacy = false) {
body.className = "body center";
body.innerHTML = `<div class="ph"><div class="preview-spinner"></div>由 PPT 转换为 PDF · 首次稍候…</div>`;
if (metaEl) metaEl.textContent = "";
let r;
try {
r = await fetch("/v1/files/preview_pdf?path=" + encodeURIComponent(rel), {
r = await fetch(_pptPreviewUrl(rel, taskId, legacy), {
headers: { "Authorization": "Bearer " + state.token },
});
} catch (e) {
@ -437,7 +455,7 @@ function _showFallback(msg) {
dl.className = "primary";
dl.textContent = "下载原文件";
dl.style.marginTop = "12px";
dl.onclick = () => { if (_fpCurrentRel) downloadFile(_fpCurrentRel); };
dl.onclick = () => { if (_fpCurrentRel) downloadFile(_fpCurrentRel, _fpCurrentTaskId, _fpCurrentLegacy); };
ph.appendChild(document.createElement("br"));
ph.appendChild(br);
ph.appendChild(dl);
@ -453,6 +471,8 @@ export function closeFilePreview() {
$("fp-body").innerHTML = "";
_flushBlobUrls();
_fpCurrentRel = null;
_fpCurrentTaskId = "";
_fpCurrentLegacy = false;
}
let _mpCurrentRel = null;
@ -572,7 +592,7 @@ _bindBodyWheel($("fp-body"));
_bindBodyWheel($("mp-body"));
$("fp-close").onclick = closeFilePreview;
$("fp-download").onclick = () => { if (_fpCurrentRel) downloadFile(_fpCurrentRel); };
$("fp-download").onclick = () => { if (_fpCurrentRel) downloadFile(_fpCurrentRel, _fpCurrentTaskId, _fpCurrentLegacy); };
$("fp-mode-preview").onclick = () => _renderTextMode("fp", "preview");
$("fp-mode-source").onclick = () => _renderTextMode("fp", "source");
$("file-preview-modal").addEventListener("click", (e) => {