feat(web): add task-scoped artifact references
This commit is contained in:
parent
4ec2638d38
commit
57655984dd
|
|
@ -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。
|
**新对话入口(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)
|
### 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
4
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` | 列当前 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 | 必填 |
|
||||||
|
|
|
||||||
|
|
@ -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 里手撸文档转换脚本**"
|
||||||
|
|
|
||||||
|
|
@ -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())
|
||||||
|
|
@ -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):
|
||||||
|
|
|
||||||
|
|
@ -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):
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
42
core/loop.py
42
core/loop.py
|
|
@ -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):
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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)。
|
||||||
|
|
|
||||||
|
|
@ -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:
|
||||||
|
|
|
||||||
|
|
@ -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")
|
||||||
|
|
@ -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");
|
||||||
|
|
|
||||||
|
|
@ -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(),
|
||||||
|
)
|
||||||
|
|
@ -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()
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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")
|
||||||
|
|
|
||||||
|
|
@ -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]:
|
||||||
|
|
|
||||||
|
|
@ -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),))
|
||||||
|
|
|
||||||
|
|
@ -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),
|
||||||
|
)
|
||||||
|
|
@ -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:
|
||||||
|
|
|
||||||
|
|
@ -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(
|
||||||
|
|
|
||||||
|
|
@ -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(
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
]
|
]
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
|
|
||||||
|
|
@ -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"=仅 HTML,false=全走 chip。
|
// inlineMode 控制升级范围:true=图片/视频/HTML,"html"=仅 HTML,false=全走 chip。
|
||||||
// 产物工具传 true;assistant 正文传 "html",避免重复内联图片/视频但让通用工具生成的
|
// 产物工具传 true;assistant 正文传 "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; }
|
||||||
|
|
|
||||||
|
|
@ -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) => {
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue