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。 **新对话入口(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) ### 7.2 资源模型(/v1)
统一 `/v1` 前缀返 JSON;UI 由 platform 实现(§7.9),本地 dev SPA dogfood。要点(细节见 `web/app.py`): 统一 `/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 Auth POST /v1/auth/login(platform_key)/ login_password / change_password;GET /v1/me
Files GET /v1/files?path= / upload / download / delete / rename Files GET /v1/files?path= / upload / download / delete / rename
(user-rooted;dotfile 隐藏;越界 400;顶层目录 DB-aware,见 §7.4) (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) Admin GET /v1/admin/*(require_admin;overview + usage/models|users + storage/users)
Export GET /v1/tasks/{id}/export(docx) 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 软重置窗口起点 context_base_idx, -- 0019 §8.8 软重置窗口起点
deleted_at, -- 0010 软删 deleted_at, -- 0010 软删
created_at, updated_at) 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)) unique(task_id, idx); gin(payload))
usage_events(pk, user_id, task_id, message_id, kind, -- chat/image/video/vision/... 自由文本 usage_events(pk, user_id, task_id, message_id, kind, -- chat/image/video/vision/... 自由文本
model_profile, units jsonb, cost numeric, created_at) -- 多态用量,加媒体不动 schema 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` | 列当前 user 可用 skill(内置 + 自己的);每项带 `source`(builtin/user)/`overrides_builtin`;另返 `load_errors`(用户 skill 因 frontmatter 坏未加载的) | 必填 |
| `GET /v1/skills/{name}` | 返某 skill 完整 SKILL.md 正文(前端「技能」modal 点开查看);同名按 user wins | 必填 | | `GET /v1/skills/{name}` | 返某 skill 完整 SKILL.md 正文(前端「技能」modal 点开查看);同名按 user wins | 必填 |
| `DELETE /v1/skills/{name}` | 删当前 user 私有 skill(`.skills/<name>/` 整目录);只删 user 源,内置不可删 → 404;`.skills` 文件面板隐藏,这是 UI 上删自己 skill 的唯一入口 | 必填 | | `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` | 必填 | | `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 当前活动 | 必填 | | `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 级,基本秒退 | 必填 | | `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 连不上时前端的兜底通道 | 必填 | | `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?path=` | 列 user_root 下条目 + 面包屑;dotfile 隐藏 | 必填 |
| `GET /v1/files/download?path=` | 下单文件 | 必填 | | `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/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/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 | 必填 | | `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" "完成后执行,所以调用后按“已登记、将在回复后完成”表述。\n"
if allow_working_dir_rename else "" 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_hint = (
"已有 Office 文件需要转 PDF 时,仅当 host-side `office_to_pdf` 的工具说明列出该格式" "已有 Office 文件需要转 PDF 时,仅当 host-side `office_to_pdf` 的工具说明列出该格式"
"才调用LibreOffice 在 backend host不在 Docker shell 内探测 `soffice`。\n" "才调用LibreOffice 在 backend host不在 Docker shell 内探测 `soffice`。\n"
@ -430,6 +437,7 @@ def _build_system_prompt(
f"「宪法」性文件(spec 等)按下面《task 级「宪法」文件命名约定》拼路径。\n" f"「宪法」性文件(spec 等)按下面《task 级「宪法」文件命名约定》拼路径。\n"
f"⛔ 不要把产物写到 cwd / `skills/` / repo 根 —— 只写到 task_dir。\n" f"⛔ 不要把产物写到 cwd / `skills/` / repo 根 —— 只写到 task_dir。\n"
f"{rename_line}" f"{rename_line}"
f"{publish_line}"
f"\n## 生成 Word / PDF 报告(验收 / 技术 / 评审报告等自由长文)\n" f"\n## 生成 Word / PDF 报告(验收 / 技术 / 评审报告等自由长文)\n"
f"**优先**把正文写成 Markdown(`<task_dir>/sections/*.md`,纯文本、零转义 / 零语法风险)," f"**优先**把正文写成 Markdown(`<task_dir>/sections/*.md`,纯文本、零转义 / 零语法风险),"
f"再调平台渲染器直接出 docx 或 pdf —— **别在 run_python 里手撸文档转换脚本**" 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: class ToolResult:
"""工具调用统一返回。 """工具调用统一返回。
现状所有 `Tool.execute` 都返 str,docker backend 后续可能要带 stdout/stderr/ 普通 `Tool.execute` str发布产物的工具可返字符串兼容的 rich result
exit_code 分离这里先留单 content 字段(LLM 拿到的就是这串),exit_code `content` LLM 拿到的文本`artifacts` 只进事件和消息元数据exit_code
backend 内部使用 hint(0=ok / 1=tool 抛异常 / 2=参数非法 / 124=timeout ), backend 内部使用 hint(0=ok / 1=tool 抛异常 / 2=参数非法 / 124=timeout ),
不影响 LLM 接口 不影响 LLM 接口
""" """
content: str content: str
exit_code: int = 0 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): class Executor(ABC):

View File

@ -596,7 +596,11 @@ class DockerExecutor(Executor):
def _compact_shell_like_result(self, result: ToolResult) -> ToolResult: def _compact_shell_like_result(self, result: ToolResult) -> ToolResult:
content = compact_tool_output(result.content) 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): 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 typing import Any, Dict, List
from .executor import ExecCtx, Executor, ToolResult from .executor import ExecCtx, Executor, ToolResult
from .artifacts import ToolExecutionResult, normalize_artifact_refs
from tools.base import Tool from tools.base import Tool
@ -52,6 +53,9 @@ class HostExecutor(Executor):
content=f"[Error executing {name}] {type(e).__name__}: {e}", content=f"[Error executing {name}] {type(e).__name__}: {e}",
exit_code=1, 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): if not isinstance(result, str):
result = str(result) result = str(result)
return ToolResult(content=result, exit_code=0) return ToolResult(content=result, exit_code=0)

View File

@ -31,6 +31,7 @@ from .context import (
) )
from .context_fold import maybe_fold from .context_fold import maybe_fold
from .executor import ExecCtx, Executor from .executor import ExecCtx, Executor
from .artifacts import MAX_ARTIFACTS_PER_MESSAGE
from .llm import LLM from .llm import LLM
from .llm_transport import ( from .llm_transport import (
extract_delta_content, extract_delta_content,
@ -249,6 +250,9 @@ class AgentLoop:
self._repeat_guard = _RepeatGuard() self._repeat_guard = _RepeatGuard()
# 全局「无进展」计数:连续多少步整步无净产出。有净产出清零,见 run loop 熔断。 # 全局「无进展」计数:连续多少步整步无净产出。有净产出清零,见 run loop 熔断。
self._stall = 0 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: def _emit(self, event: dict) -> None:
if self.sink is not None: if self.sink is not None:
@ -279,6 +283,7 @@ class AgentLoop:
return self._run(None) return self._run(None)
def _run(self, user_message: Optional[str]) -> str: def _run(self, user_message: Optional[str]) -> str:
self._pending_artifact_refs = []
self._maybe_fold_context() self._maybe_fold_context()
if user_message is not None: if user_message is not None:
self.session.append({"role": "user", "content": user_message}) self.session.append({"role": "user", "content": user_message})
@ -299,7 +304,11 @@ class AgentLoop:
return "[cancelled]" return "[cancelled]"
msg = response.choices[0].message 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)) usage_details = extract_usage_details(getattr(response, "usage", None))
pt, ct = usage_details["tokens_in"], usage_details["tokens_out"] pt, ct = usage_details["tokens_in"], usage_details["tokens_out"]
@ -339,7 +348,6 @@ class AgentLoop:
"elapsed": elapsed, "elapsed": elapsed,
}) })
tool_calls = getattr(msg, "tool_calls", None) or []
# content 已通过 stream 流式 emit 过 delta,这里不再 emit 整段 text 事件。 # content 已通过 stream 流式 emit 过 delta,这里不再 emit 整段 text 事件。
if not tool_calls: if not tool_calls:
@ -363,7 +371,8 @@ class AgentLoop:
self._fill_cancelled_tool_results(tool_calls[i:]) self._fill_cancelled_tool_results(tool_calls[i:])
self._emit({"type": "cancelled"}) self._emit({"type": "cancelled"})
return "[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 step_productive = step_productive or productive
self.session.append( self.session.append(
{ {
@ -674,7 +683,7 @@ class AgentLoop:
pass pass
return response 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,返回 (结果文本, 本次是否有净产出)。 """执行一次 tool_call,返回 (结果文本, 本次是否有净产出)。
净产出供 run loop 的全局无进展熔断判定 净产出供 run loop 的全局无进展熔断判定
@ -686,7 +695,7 @@ class AgentLoop:
try: try:
args = json.loads(raw_args) args = json.loads(raw_args)
except json.JSONDecodeError as e: 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) args_preview = json.dumps(args, ensure_ascii=False)
if len(args_preview) > 200: if len(args_preview) > 200:
@ -700,7 +709,7 @@ class AgentLoop:
blocked = self._check_repeat_block(name, args) blocked = self._check_repeat_block(name, args)
if blocked is not None: if blocked is not None:
return blocked, False return blocked, False, ()
ctx = ExecCtx( ctx = ExecCtx(
user_id=self.user_id, user_id=self.user_id,
@ -709,7 +718,8 @@ class AgentLoop:
cancel_check=self.cancel_check, cancel_check=self.cancel_check,
) )
tool_started_at = time.time() 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 # 控制返回给模型的 tool 结果体量,避免炸 context
MAX_LEN = 16_000 MAX_LEN = 16_000
@ -729,8 +739,24 @@ class AgentLoop:
"result": result, "result": result,
"preview": preview, "preview": preview,
"truncated": truncated, "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]: def _check_repeat_block(self, name: str, args: Any) -> Optional[str]:
"""执行前的两道拦截(命中返回拦截话术,未命中返 None): """执行前的两道拦截(命中返回拦截话术,未命中返 None):

View File

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

View File

@ -170,6 +170,10 @@ class Message(Base):
) )
idx: Mapped[int] = mapped_column(Integer, nullable=False) idx: Mapped[int] = mapped_column(Integer, nullable=False)
payload: Mapped[dict[str, Any]] = mapped_column(JSONB, 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_in: Mapped[Optional[int]] = mapped_column(Integer, nullable=True)
tokens_out: 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)。 # 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: def _task_actions() -> list:
from tools.publish_artifacts import PublishArtifactsTool
return [ return [
RenameWorkingDirTool( RenameWorkingDirTool(
ctx.deferred_actions, ctx.deferred_actions,
working_dir=ctx.working_dir_path, working_dir=ctx.working_dir_path,
**base, **base,
) ),
PublishArtifactsTool(
working_dir=ctx.working_dir_path,
**wd_base,
),
] ]
def _document_search() -> list: 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, /new IntersectionObserver/);
assert.match(mediaJs, /configureHtmlPreviewFrame\(frame, source/); assert.match(mediaJs, /configureHtmlPreviewFrame\(frame, source/);
assert.match(pageHtml, /\.art-html-frame/); 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 clickHandler = chatJs.indexOf('$("chat-stream").addEventListener("click"');
const expandHandler = chatJs.indexOf('e.target.closest(".art-html-open[data-rel]")'); const expandHandler = chatJs.indexOf('e.target.closest(".art-html-open[data-rel]")');
const sendMessage = chatJs.indexOf("async function sendMessage"); 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 pathlib import Path
from types import SimpleNamespace from types import SimpleNamespace
from unittest.mock import MagicMock from unittest.mock import MagicMock
from unittest.mock import patch
from uuid import uuid4 from uuid import uuid4
from core.loop import AgentLoop from core.loop import AgentLoop
@ -14,10 +15,12 @@ class _Session:
def __init__(self, messages=None): def __init__(self, messages=None):
self.messages = list(messages or []) self.messages = list(messages or [])
self.appended = [] self.appended = []
self.append_artifacts = []
def append(self, message): def append(self, message, *, artifact_refs=None):
self.messages.append(message) self.messages.append(message)
self.appended.append(message) self.appended.append(message)
self.append_artifacts.append(artifact_refs)
return uuid4() return uuid4()
@ -60,6 +63,44 @@ class PersistedTurnTests(unittest.TestCase):
[{"role": "user", "content": "新消息"}], [{"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__": if __name__ == "__main__":
unittest.main() unittest.main()

View File

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

View File

@ -347,6 +347,38 @@ class FilesDbAwareTests(unittest.TestCase):
self.assertTrue(d["working_dir"].endswith("/改名后目录")) self.assertTrue(d["working_dir"].endswith("/改名后目录"))
self.assertTrue((_user_root() / "改名后目录").is_dir()) 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): def test_toplevel_rename_blocked_while_running(self):
tid = self._mk_task("跑动中任务", "跑动中目录") tid = self._mk_task("跑动中任务", "跑动中目录")
_set_run_status(tid, "running") _set_run_status(tid, "running")

View File

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

View File

@ -15,6 +15,7 @@ from pathlib import Path
from typing import Optional from typing import Optional
from uuid import uuid4 from uuid import uuid4
from core.artifacts import ArtifactRef, ToolExecutionResult, resolve_artifact_path
from tools.base import FileOutOfBounds, Tool from tools.base import FileOutOfBounds, Tool
from web.pptx_render import SofficeNotFoundError, find_soffice from web.pptx_render import SofficeNotFoundError, find_soffice
@ -147,7 +148,7 @@ class OfficeToPdfTool(Tool):
return root_candidate return root_candidate
return base_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: try:
src = self._resolve_office_path(source, existing=True) src = self._resolve_office_path(source, existing=True)
except FileOutOfBounds: except FileOutOfBounds:
@ -218,4 +219,13 @@ class OfficeToPdfTool(Tool):
except OSError as e: except OSError as e:
return f"[Error] failed to publish PDF: {type(e).__name__}: {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 typing import Any, Callable, Optional
from uuid import UUID from uuid import UUID
from core.artifacts import ArtifactRef, ToolExecutionResult, resolve_artifact_path
from core.ark_client import ArkClient, ArkConfig, ArkError from core.ark_client import ArkClient, ArkConfig, ArkError
from core.storage.usage import record_video_usage from core.storage.usage import record_video_usage
@ -388,7 +389,7 @@ class SeedanceTool(Tool):
image_banner += f" · image={first_frame_display}" image_banner += f" · image={first_frame_display}"
# banner 协议与 seedream 一致:首行 `[tool] key=value · key=value ...` # banner 协议与 seedream 一致:首行 `[tool] key=value · key=value ...`
# 前端 extractMediaBanner 已 whitelist seedance,正则抓 key=value 挂徽章 # 前端 extractMediaBanner 已 whitelist seedance,正则抓 key=value 挂徽章
return ( result = (
f"[seedance] model={model_id} · mode={mode}{image_banner} · " f"[seedance] model={model_id} · mode={mode}{image_banner} · "
f"resolution={chosen_resolution} · ratio={chosen_ratio} · " f"resolution={chosen_resolution} · ratio={chosen_ratio} · "
f"duration={chosen_duration}s · audio={chosen_generate_audio} · " f"duration={chosen_duration}s · audio={chosen_generate_audio} · "
@ -397,6 +398,12 @@ class SeedanceTool(Tool):
f"prompt={prompt!r}\n" f"prompt={prompt!r}\n"
f"watermark={chosen_watermark} cgt_id={cgt_id}" 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 @staticmethod
def _rough_cost(resolution: str, ratio: str, duration_s: int, fps: int, price_per_mtoken: float) -> float: 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 typing import Any, Optional
from uuid import UUID from uuid import UUID
from core.artifacts import ArtifactRef, ToolExecutionResult, resolve_artifact_path
from core.ark_client import ArkClient, ArkConfig, ArkError from core.ark_client import ArkClient, ArkConfig, ArkError
from core.storage.usage import record_image_usage from core.storage.usage import record_image_usage
@ -222,13 +223,19 @@ class SeedreamTool(Tool):
mode_seg = " · mode=i2i" if is_i2i else "" mode_seg = " · mode=i2i" if is_i2i else ""
ref_line = f"\nreference={ref_disp[0]}" 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 "" note_line = f"\n{size_note}" if size_note else ""
return ( result = (
f"[seedream] model={model_id} · size={chosen_size} · " f"[seedream] model={model_id} · size={chosen_size} · "
f"cost=¥{cost_cny:.2f} · elapsed={elapsed:.1f}s{mode_seg}\n" f"cost=¥{cost_cny:.2f} · elapsed={elapsed:.1f}s{mode_seg}\n"
f"saved: {disp}{ref_line}\n" f"saved: {disp}{ref_line}\n"
f"prompt={prompt!r}\n" f"prompt={prompt!r}\n"
f"watermark={chosen_watermark} search={chosen_search}{note_line}" 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 @staticmethod
def _normalize_size( def _normalize_size(

View File

@ -14,7 +14,7 @@ from fastapi import Depends, File, Form, HTTPException, UploadFile
from fastapi.responses import FileResponse from fastapi.responses import FileResponse
from sqlalchemy import func, select 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 import session_scope
from core.storage.models import Task from core.storage.models import Task
from core.working_dirs import ( from core.working_dirs import (
@ -45,6 +45,88 @@ def _pptx_lock_for(abs_path: str) -> asyncio.Lock:
return 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: def register_file_routes(app, *, require_user) -> None:
@app.get("/v1/user/storage", tags=["user"]) @app.get("/v1/user/storage", tags=["user"])
def user_storage(user_id: UUID = Depends(require_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)。""" """下载 user_root 下单个 regular file(目录 → 400 / 不存在 → 404)。"""
root = load_user_root(user_id) root = load_user_root(user_id)
target = safe_join(root, path) 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 默认能缓数小时) # workspace 文件可变, 禁浏览器启发式缓存 (RFC 7234 默认能缓数小时)
# 否则文件改了 SPA 预览还是旧内容 # 否则文件改了 SPA 预览还是旧内容
# (Starlette FileResponse 不实现 304, 总是 200 全量; workspace 文件小, 可接受) # (Starlette FileResponse 不实现 304, 总是 200 全量; workspace 文件小, 可接受)
# .svg 显式给 image/svg+xml: 部分部署环境 mimetypes 未注册 svg, FileResponse # .svg 显式给 image/svg+xml: 部分部署环境 mimetypes 未注册 svg, FileResponse
# 会猜成 octet-stream, 前端 <img> 就渲染不出 SVG 预览 # 会猜成 octet-stream, 前端 <img> 就渲染不出 SVG 预览
media_type = None return _regular_file_response(target, path)
if target.suffix.lower() == ".svg":
media_type = "image/svg+xml" @app.get("/v1/tasks/{task_id}/files/download", tags=["files"])
return FileResponse( def download_task_file(
path=str(target), task_id: str,
filename=target.name, path: str,
media_type=media_type, legacy: bool = False,
headers={"Cache-Control": "no-cache"}, 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"]) @app.get("/v1/files/preview_pdf", tags=["files"])
async def preview_pdf( async def preview_pdf(
@ -134,35 +217,22 @@ def register_file_routes(app, *, require_user) -> None:
转换跑在 backend host(不进沙盒),按需触发 + 缓存到 `.preview/`(DESIGN §8.3) 转换跑在 backend host(不进沙盒),按需触发 + 缓存到 `.preview/`(DESIGN §8.3)
soffice 缺失 501;转换失败/超时 500;前端据此回退到下载 soffice 缺失 501;转换失败/超时 500;前端据此回退到下载
""" """
from ..pptx_render import (
PptxConvertError,
SofficeNotFoundError,
pptx_to_pdf,
)
root = load_user_root(user_id) root = load_user_root(user_id)
target = safe_join(root, path) target = safe_join(root, path)
if not target.exists(): return await _pptx_preview_response(target, path)
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}")
abs_path = str(target.resolve()) @app.get("/v1/tasks/{task_id}/files/preview_pdf", tags=["files"])
loop = asyncio.get_event_loop() async def preview_task_pdf(
async with _pptx_lock_for(abs_path): task_id: str,
try: path: str,
pdf_path = await loop.run_in_executor(None, pptx_to_pdf, target) legacy: bool = False,
except SofficeNotFoundError as e: user_id: UUID = Depends(require_user),
raise HTTPException(501, str(e)) ):
except PptxConvertError as e: """Preview a PPT addressed relative to the task's current working_dir."""
raise HTTPException(500, str(e)) root = load_user_root(user_id)
return FileResponse( _tid, working_dir = _task_working_dir(task_id, user_id, root)
path=str(pdf_path), target = _task_file_target(root, working_dir, path, legacy)
media_type="application/pdf", return await _pptx_preview_response(target, path)
headers={"Cache-Control": "no-cache"},
)
@app.post("/v1/files/upload", tags=["files"]) @app.post("/v1/files/upload", tags=["files"])
async def upload_files( async def upload_files(

View File

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

View File

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

View File

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

View File

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