diff --git a/DESIGN.md b/DESIGN.md index 3b25f63..fc461eb 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -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_dir,name 可选,并可预设 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 diff --git a/RUN.md b/RUN.md index ddb40af..ee9750e 100644 --- a/RUN.md +++ b/RUN.md @@ -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//` 整目录);只删 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: ` + `data: `);订阅 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 上传到 `//`;路径不存在自动 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 | 必填 | diff --git a/core/agent_builder.py b/core/agent_builder.py index 49efc75..044a058 100644 --- a/core/agent_builder.py +++ b/core/agent_builder.py @@ -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(`/sections/*.md`,纯文本、零转义 / 零语法风险)," f"再调平台渲染器直接出 docx 或 pdf —— **别在 run_python 里手撸文档转换脚本**" diff --git a/core/artifacts.py b/core/artifacts.py new file mode 100644 index 0000000..942205f --- /dev/null +++ b/core/artifacts.py @@ -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 (``/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()) diff --git a/core/executor.py b/core/executor.py index 9d06237..d9895f1 100644 --- a/core/executor.py +++ b/core/executor.py @@ -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): diff --git a/core/executor_docker.py b/core/executor_docker.py index 41f40ed..188693b 100644 --- a/core/executor_docker.py +++ b/core/executor_docker.py @@ -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): diff --git a/core/executor_host.py b/core/executor_host.py index 46d1eed..af6e54b 100644 --- a/core/executor_host.py +++ b/core/executor_host.py @@ -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) diff --git a/core/loop.py b/core/loop.py index 5de23c9..d3444ab 100644 --- a/core/loop.py +++ b/core/loop.py @@ -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): diff --git a/core/session.py b/core/session.py index 86abcf6..2ca198c 100644 --- a/core/session.py +++ b/core/session.py @@ -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 diff --git a/core/storage/models.py b/core/storage/models.py index 08e85ce..928bc9a 100644 --- a/core/storage/models.py +++ b/core/storage/models.py @@ -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)。 diff --git a/core/tool_registry.py b/core/tool_registry.py index 6540335..f9b0021 100644 --- a/core/tool_registry.py +++ b/core/tool_registry.py @@ -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: diff --git a/db/migrations/versions/20260803_1400_0025_message_artifact_refs.py b/db/migrations/versions/20260803_1400_0025_message_artifact_refs.py new file mode 100644 index 0000000..ddead1c --- /dev/null +++ b/db/migrations/versions/20260803_1400_0025_message_artifact_refs.py @@ -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") diff --git a/tests/frontend_preview.test.mjs b/tests/frontend_preview.test.mjs index 47beb23..bdcbe8a 100644 --- a/tests/frontend_preview.test.mjs +++ b/tests/frontend_preview.test.mjs @@ -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"); diff --git a/tests/test_artifacts.py b/tests/test_artifacts.py new file mode 100644 index 0000000..396171b --- /dev/null +++ b/tests/test_artifacts.py @@ -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(), + ) diff --git a/tests/test_loop_persisted_turn.py b/tests/test_loop_persisted_turn.py index 4686464..c100d31 100644 --- a/tests/test_loop_persisted_turn.py +++ b/tests/test_loop_persisted_turn.py @@ -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() diff --git a/tests/test_skill_model_pinning.py b/tests/test_skill_model_pinning.py index 92fff20..df0727e 100644 --- a/tests/test_skill_model_pinning.py +++ b/tests/test_skill_model_pinning.py @@ -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) diff --git a/tests/test_web_routes_db.py b/tests/test_web_routes_db.py index 45854d4..be4e829 100644 --- a/tests/test_web_routes_db.py +++ b/tests/test_web_routes_db.py @@ -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") diff --git a/tools/gpt_image.py b/tools/gpt_image.py index e313df2..04bfc62 100644 --- a/tools/gpt_image.py +++ b/tools/gpt_image.py @@ -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]: diff --git a/tools/office_to_pdf.py b/tools/office_to_pdf.py index e5d9297..1daf253 100644 --- a/tools/office_to_pdf.py +++ b/tools/office_to_pdf.py @@ -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),)) diff --git a/tools/publish_artifacts.py b/tools/publish_artifacts.py new file mode 100644 index 0000000..ce31e36 --- /dev/null +++ b/tools/publish_artifacts.py @@ -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), + ) diff --git a/tools/seedance.py b/tools/seedance.py index ecc710b..5cc30e4 100644 --- a/tools/seedance.py +++ b/tools/seedance.py @@ -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: diff --git a/tools/seedream.py b/tools/seedream.py index 00eeed6..6afa9c6 100644 --- a/tools/seedream.py +++ b/tools/seedream.py @@ -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( diff --git a/web/routers/files.py b/web/routers/files.py index 34ecfe2..305e7c4 100644 --- a/web/routers/files.py +++ b/web/routers/files.py @@ -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, 前端 就渲染不出 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( diff --git a/web/routers/messages.py b/web/routers/messages.py index a896c9a..0b42f81 100644 --- a/web/routers/messages.py +++ b/web/routers/messages.py @@ -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 ] diff --git a/web/static/js/chat.js b/web/static/js/chat.js index 2586b46..cb785ac 100644 --- a/web/static/js/chat.js +++ b/web/static/js/chat.js @@ -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 = `
${escapeHtml(activityLabel)}${(txt || "").length} 字符${banner}
${escapeHtml(txt || "")}
- ${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 的 /... 路径**永远**展示(绕开 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 += `
${escapeHtml(label)}
${escapeHtml(args)}
- ${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 链接:模型常把工作区相对路径写成 [](),renderMd 出 。 @@ -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); diff --git a/web/static/js/media.js b/web/static/js/media.js index 4266249..e01a0d3 100644 --- a/web/static/js/media.js +++ b/web/static/js/media.js @@ -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"=仅 HTML,false=全走 chip。 // 产物工具传 true;assistant 正文传 "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 `
-
${escapeHtml(name)}
+ return `
+
${escapeHtml(name)}
进入可视区域后加载…
`; } if (inlineMode === true && (cat === "image" || cat === "video")) { // 占位元素;插入 DOM 后 upgradeMediaArtifacts 异步 fetch blob → 填 /