From 4932f00e900122b2ea18c3e1bd7779f5115f98ea Mon Sep 17 00:00:00 2001 From: caoqianming Date: Wed, 12 Aug 2026 12:44:28 +0800 Subject: [PATCH] feat(artifacts): add lifecycle tracking and trash --- CHANGELOG.md | 5 + DESIGN.md | 4 +- PROGRESS.md | 4 +- RUN.md | 1 + core/__init__.py | 2 +- core/agent_builder.py | 2 +- core/artifact_lifecycle.py | 208 ++++++++++++++++++ core/artifacts.py | 21 +- core/loop.py | 26 ++- core/storage/models.py | 48 ++++ core/working_dirs.py | 18 +- .../versions/20260812_1400_0028_artifacts.py | 143 ++++++++++++ tests/frontend_preview.test.mjs | 3 +- tests/test_artifacts.py | 53 ++++- tests/test_web_routes_db.py | 78 ++++++- tests/test_web_routes_nodb.py | 18 +- web/routers/files.py | 118 +++++++++- web/static/js/chat.js | 6 +- web/static/js/media.js | 37 ++-- web/static/js/preview.js | 27 ++- 20 files changed, 758 insertions(+), 64 deletions(-) create mode 100644 core/artifact_lifecycle.py create mode 100644 db/migrations/versions/20260812_1400_0028_artifacts.py diff --git a/CHANGELOG.md b/CHANGELOG.md index bd519fd..56e106b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,11 @@ > 所以不是每个版本号都有条目。条目格式 `## <版本> — <日期>`,新条目加在最上面。 > 工程口径的完整记录见 `PROGRESS.md` / git log。 +## 0.64.0 — 2026-08-12 + +- 已发布产物现在拥有稳定身份:重命名和移动后,历史对话中的产物仍可正常预览和下载;复制产物会创建可独立管理的副本。 +- 删除已发布产物时,文件会进入平台隐藏回收区而不是立即永久丢失;普通工作文件仍按原方式删除。 + ## 0.63.11 — 2026-08-12 - 修复 PDF 和 PPT 连续预览无法正常滚动的问题,现在可直接上下滚动浏览全部页面。 diff --git a/DESIGN.md b/DESIGN.md index dfc04fb..afabb28 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -173,7 +173,7 @@ Eval 与生产 core 解耦,通过现有 `/v1` API 创建专用任务、监听 **新对话入口(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,也不把 pending 留给后续消息误命名。 -**对话产物引用(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 事实源语义,不复制文件、不引不可变对象存储。 +**对话产物与生命周期(0025/0028)**:真实文件仍是内容事实源;`artifacts` 表记录已发布产物的稳定身份和生命周期,包含 user-root 相对当前路径、来源 task、复制来源、哈希/大小及 active/deleted、回收路径。新 `messages.artifact_refs` 使用 `{version:2, artifact_id, scope:"working_dir", path:"reports/a.pdf", label?:"最终报告"}`;`path` 是兼容快照,预览/下载优先按 `artifact_id` 找当前路径,因此移动或重命名后历史卡片仍有效。version 1 和 `NULL` 旧消息继续走原 task-scoped 兼容链。普通源码树、中间文件和配套资源不登记;agent 仅用 `publish_artifacts` 显式提升少量最终文件。移动保持身份,复制为每个副本创建新身份并记录直接来源;删除将文件移入 `.zcbot_artifact_trash/` 并软删记录,普通文件仍物理删除。 ### 7.2 资源模型(/v1) @@ -285,7 +285,7 @@ scheduled_jobs(§8.5) channel_bindings(§8.7,判别列+JSONB) - **path-as-identity 而非 folder_id**:folder 真实存在于 FS,folder_id 是第二份 source of truth;rename 走 DB-aware 同事务 cascade。 - **files API 单一 mutation 入口**(2026-05-18):"顶层目录分支"从数据状态派生而非客户端意图,放服务端才有强制力;双命名空间(/folders vs /files)把分支搬给 client,失强制力且端点翻倍。 - **task 软删除(2026-06-17 推翻 hard cascade)**:公测后对话轨迹是训练/研究语料,`deleted_at` 置位 + restore,避免用户误删立即永久丢失。**当前实现仍无限期保留软删数据**,物理清理仅有管理员手段;后续生命周期已定为“软删除后保留 30 天再物理清理”(待容量信号实施,见 §8.5),届时恢复能力明确限于宽限期内。 -- **文件留存(设计已定,实现待办)**:用户文件在 FS,删除/覆盖即字节丢。方案=① restic/borg 定时增量备份做地基(与应用解耦,新端点自动覆盖,捕获删除+覆盖+成品)+ ② 应用层 `data_events` 事件日志(补用户意图语义)。**不选**每个删除端点内联 copytree:横切关注点手写 N 处必漏。起步同盘(不防整盘损坏,已知边界)。 +- **文件留存**:普通用户文件仍是 FS 直接删除;已写入结构化 `messages.artifact_refs` 的已发布产物,在统一 files delete 入口删除时原子移动到用户根目录的平台隐藏区 `.zcbot_artifact_trash/`,原路径与历史卡片立即表现为已删除。递归目录只回收其中 artifact,其他文件照常删除;回收内容仍计入用户配额。该机制只防误删产物,不防覆盖、agent/shell 绕过 files API 或整盘损坏。完整地基仍采用 restic/borg 定时增量备份(与应用解耦,捕获删除+覆盖+所有写入口),后续容量需要时再补 `data_events` 用户意图事件和回收区清理/恢复管理。 - **0004 删 runs/usage_events 旧表**:只写不读的死代码;代价是失历史 run 元数据,真要细粒度审计再补(届时是新需求非技术债)。 - **本地也用 PG 不用 SQLite**:dogfood ≡ 真实路径;Docker 已是必然依赖;双 adapter 维护税 > 一次性配置。 - **API-only,UI 由 platform 实现**(2026-05-15):本仓库再维护一套 UI 是双套浪费;SSE payload 从 HTML 切 JSON;沉淀的 sink/broker/路径安全全保留。**dev SPA 留一份**作 dogfood 主路径(SSE 调试 curl/Swagger 都覆盖不了)。 diff --git a/PROGRESS.md b/PROGRESS.md index a0b00b0..bb5cf03 100644 --- a/PROGRESS.md +++ b/PROGRESS.md @@ -2,7 +2,7 @@ > 配合 `DESIGN.md`。本文件只记 phase 状态、决策偏差、文件量、下一步。每条 1-2 句:做了啥 + 关键判断;细节查 `git log` / `git diff` / `DESIGN §7.9`。 -最后更新:2026-08-12(PDF/PPT 连续预览滚动布局修复,bump 0.63.11) +最后更新:2026-08-12(artifact 稳定身份与隐藏回收,bump 0.64.0) --- @@ -23,6 +23,8 @@ ### 2026-08-12 +- **08-12 / 0.64.0 / artifact 稳定身份 + 隐藏回收**:新增 0028 `artifacts` 生命周期表并回填存量结构化引用;新发布消息写带 `artifact_id` 的 v2 引用,移动/重命名保持身份,复制创建独立身份并记录来源,历史卡片按身份解析最新路径。删除已发布产物时移动到用户隐藏目录 `.zcbot_artifact_trash/` 并软删记录,普通文件仍物理删除;Python 35 项(测试库门控 1 skip)、Node 前端 11 项、mypy、Alembic 单 head、编译、Ruff 致命规则及 diff 检查通过,未连接或写入生产 DB。 + - **08-12 / 0.63.11 / PDF/PPT 连续预览滚动布局修复**:补齐 PDF 预览根容器的纵向 Flex 布局与可收缩高度约束,使内部连续页 viewport 获得实际可用高度和独立滚动区域,不再被外层 `overflow:hidden` 裁断;回归测试同步锁定根容器、viewport 与页列表三层布局契约。Node 前端预览 11 项、JavaScript 语法及 diff 检查通过;无 schema、migration、HTTP API、依赖或运行方式变化,未连接生产 DB。 ### 2026-08-11 diff --git a/RUN.md b/RUN.md index 366bea2..018fecc 100644 --- a/RUN.md +++ b/RUN.md @@ -151,6 +151,7 @@ - **channel 长会话上下文(微信/企业微信通用,0019)**:常驻会话不再无限膨胀。① **自动分段**——入站时距上次消息超过 `config.json` 的 `channel.session_gap_hours`(默 **6** 小时,设 `<=0` 关闭)→ 软重置:只把「最后一条 user 消息起」喂模型(保留上一轮做续聊锚点),之前的历史仍全留 DB,网页端照旧翻完整记录;② **手动新话题**——用户在微信/企业微信里直接发「新话题 / 新会话 / `/new` / 清空上下文」→ 硬重置,彻底从零(回执提示已归档)。两者都**不删任何消息**,只移动「喂给模型的窗口起点」`tasks.context_base_idx`。网页端「清空对话」(`POST /v1/tasks/{id}/clear`)仍整清并把 base 归 0。需 `main.py db upgrade head` 带上 `0019`。 - **PG**:`ZCBOT_DB_URL` 必填。本地 docker compose / 远端 dev / 生产任选;未设置时启动清晰报错,不引导 docker(§7.4)。 - **OpenAPI / MCP 外部系统**:① `.env` 配置独立的 `ZCBOT_CREDENTIAL_MASTER_KEY`,可选 `ZCBOT_CREDENTIAL_KEY_ID` 标识当前密钥;轮换时把旧 key 以 JSON 对象放入 `ZCBOT_CREDENTIAL_PREVIOUS_KEYS`,待用户凭据完成重写后再移除。② 执行 `main.py db upgrade head`。③ admin 进入管理后台「外部系统」,选择通用 OpenAPI 或通用 MCP;具体 MES/ERP/LIMS 都作为数据库 definition 配置,不新增专用 provider。MCP 填写与登录 Base URL 同源的 Streamable HTTP URL,可选填写期望 Server 名称;连接后以 `tools/list` 为事实源。④ 普通用户点击左栏 **「外部」**,页面按 definition 动态显示用户名密码、API Key 或 Bearer Token;目标、Server 身份、登录、认证绑定或 TLS 变化后保留密文并暂停调用,重新测试成功后恢复。OpenAPI spec 和 MCP tool catalog 只在进程内按连接身份有界缓存,登录与业务响应均限长,普通用户和模型不能传任意 URL。 +- **Artifact 生命周期(0028)**:部署本版本必须先执行 `.venv/Scripts/python.exe main.py db upgrade head`。migration 会从存量 `messages.artifact_refs` 回填 active artifact 身份;删除后的已发布产物保存在用户根目录隐藏区 `.zcbot_artifact_trash/`,默认不自动清理且继续计入磁盘配额。普通文件删除语义不变。 - **旧 `factory_mes` definition 一次性转换**:新版代码不再识别 `factory_mes`;部署时保持旧服务进程运行,先从新代码目录执行数据脚本,转换成功后再重启到新版。脚本不加载 `.env`、不读取 `ZCBOT_DB_URL`,只认显式的 `ZCBOT_MIGRATION_DB_URL`;默认 dry-run,检查同名冲突与配置合法性。确认输出后加 `--apply`,脚本把 definition 转为 `generic_openapi + query`、物化 JWT/提示/只读 POST 配置并同步 active connection revision,不解密或改写用户凭据。 ```powershell $env:ZCBOT_MIGRATION_DB_URL="postgresql+psycopg://user:pass@host:5432/zcbot" diff --git a/core/__init__.py b/core/__init__.py index 15b442a..e3e3972 100644 --- a/core/__init__.py +++ b/core/__init__.py @@ -1,3 +1,3 @@ # zcbot 版本号单一事实源:web/app.py 的 FastAPI version、/healthz 返回、前端展示都引这里。 # 改版本只动这一行。 -__version__ = "0.63.11" +__version__ = "0.64.0" diff --git a/core/agent_builder.py b/core/agent_builder.py index 3a59c7d..c4dda02 100644 --- a/core/agent_builder.py +++ b/core/agent_builder.py @@ -674,7 +674,7 @@ def build_agent( agent = AgentLoop( llm, executor, session, caps, - user_id=uid, working_dir=working_dir_path, sink=sink, + user_id=uid, working_dir=working_dir_path, user_root=ur_path, sink=sink, skill_model_switch=_skill_model_switch, deferred_actions=deferred_actions, ) diff --git a/core/artifact_lifecycle.py b/core/artifact_lifecycle.py new file mode 100644 index 0000000..94d4103 --- /dev/null +++ b/core/artifact_lifecycle.py @@ -0,0 +1,208 @@ +"""Database-backed lifecycle operations for published workspace artifacts.""" +from __future__ import annotations + +import hashlib +import mimetypes +import os +import shutil +from datetime import datetime, timezone +from pathlib import Path +from uuid import UUID, uuid4 + +from sqlalchemy import select +from sqlalchemy.dialects.postgresql import insert as pg_insert + +from .artifacts import ARTIFACT_TRASH_DIR, ArtifactRef +from .storage import session_scope +from .storage.models import Artifact + + +def _rel(root: Path, path: Path) -> str: + return Path(path).resolve().relative_to(Path(root).resolve()).as_posix() + + +def _hash_file(path: Path) -> str: + digest = hashlib.sha256() + with Path(path).open("rb") as stream: + for chunk in iter(lambda: stream.read(1024 * 1024), b""): + digest.update(chunk) + return digest.hexdigest() + + +def register_published_artifacts( + *, + user_id: UUID, + task_id: UUID, + user_root: Path, + working_dir: Path, + refs: tuple[dict, ...], +) -> tuple[dict, ...]: + """Upsert active artifact identities and return version-2 message refs.""" + root = Path(user_root).resolve() + wd = Path(working_dir).resolve() + output: list[dict] = [] + with session_scope() as session: + for ref in refs: + task_path = str(ref.get("path") or "") + path = (wd / Path(task_path)).resolve() + path.relative_to(wd) + if not path.is_file(): + continue + current_path = _rel(root, path) + label = str(ref.get("label") or "") + media_type = mimetypes.guess_type(path.name)[0] + size_bytes = path.stat().st_size + content_sha256 = _hash_file(path) + statement = pg_insert(Artifact).values( + user_id=user_id, + origin_task_id=task_id, + current_path=current_path, + label=label, + media_type=media_type, + size_bytes=size_bytes, + content_sha256=content_sha256, + ).on_conflict_do_update( + index_elements=[Artifact.user_id, Artifact.current_path], + index_where=Artifact.status == "active", + set_={ + "label": label, + "media_type": media_type, + "size_bytes": size_bytes, + "content_sha256": content_sha256, + "updated_at": datetime.now(timezone.utc), + }, + ).returning(Artifact.artifact_id) + artifact_id = session.execute(statement).scalar_one() + output.append(ArtifactRef( + path=task_path, + label=label, + artifact_id=artifact_id, + version=2, + ).as_dict()) + return tuple(output) + + +def rename_active_artifacts( + *, + user_id: UUID, + user_root: Path, + old_path: Path, + new_path: Path, +) -> int: + """Rewrite active artifact paths for one file or directory subtree.""" + root = Path(user_root).resolve() + old_rel = _rel(root, old_path) + new_rel = _rel(root, new_path) + with session_scope() as session: + rows = session.execute( + select(Artifact).where( + Artifact.user_id == user_id, + Artifact.status == "active", + ) + ).scalars().all() + changed = 0 + for row in rows: + if row.current_path == old_rel: + suffix = "" + elif row.current_path.startswith(old_rel + "/"): + suffix = row.current_path[len(old_rel):] + else: + continue + row.current_path = new_rel + suffix + changed += 1 + return changed + + +def copy_active_artifacts( + *, + user_id: UUID, + user_root: Path, + source: Path, + target: Path, +) -> int: + """Create independent artifact identities for copied files.""" + root = Path(user_root).resolve() + source_rel = _rel(root, source) + target_rel = _rel(root, target) + with session_scope() as session: + sources = session.execute( + select(Artifact).where( + Artifact.user_id == user_id, + Artifact.status == "active", + ) + ).scalars().all() + created = 0 + for original in sources: + if original.current_path == source_rel: + suffix = "" + elif original.current_path.startswith(source_rel + "/"): + suffix = original.current_path[len(source_rel):] + else: + continue + copied_path = target_rel + suffix + session.add(Artifact( + user_id=user_id, + origin_task_id=original.origin_task_id, + copied_from_artifact_id=original.artifact_id, + current_path=copied_path, + label=(original.label + " 副本").strip(), + media_type=original.media_type, + size_bytes=original.size_bytes, + content_sha256=original.content_sha256, + )) + created += 1 + return created + + +def trash_active_artifacts( + *, + user_id: UUID, + user_root: Path, + target: Path, +) -> int: + """Move matching active artifacts to hidden trash and mark them deleted.""" + root = Path(user_root).resolve() + source = Path(target).resolve() + source_rel = _rel(root, source) + entry = ( + root / ARTIFACT_TRASH_DIR + / datetime.now(timezone.utc).strftime("%Y/%m/%d") + / uuid4().hex + ) + moved: list[tuple[Path, Path]] = [] + try: + with session_scope() as session: + rows = session.execute( + select(Artifact).where( + Artifact.user_id == user_id, + Artifact.status == "active", + ).with_for_update() + ).scalars().all() + matches = [ + row for row in rows + if row.current_path == source_rel + or row.current_path.startswith(source_rel + "/") + ] + if not matches: + return 0 + for row in matches: + original = root / Path(row.current_path) + if not original.is_file(): + continue + destination = entry / "files" / Path(row.current_path) + destination.parent.mkdir(parents=True, exist_ok=True) + os.replace(original, destination) + moved.append((original, destination)) + row.status = "deleted" + row.deleted_at = datetime.now(timezone.utc) + row.trash_path = _rel(root, destination) + return len(moved) + except Exception: + for original, destination in reversed(moved): + try: + original.parent.mkdir(parents=True, exist_ok=True) + os.replace(destination, original) + except OSError: + pass + shutil.rmtree(entry, ignore_errors=True) + raise diff --git a/core/artifacts.py b/core/artifacts.py index 942205f..d96fb0a 100644 --- a/core/artifacts.py +++ b/core/artifacts.py @@ -1,18 +1,21 @@ -"""Task artifact references and working-dir scoped path resolution. +"""Artifact message 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. +Files remain the content source of truth. Version-2 refs carry a stable database identity +plus a task-relative path snapshot; version-1 path-only refs remain readable. """ from __future__ import annotations +from collections.abc import Iterable from dataclasses import dataclass from pathlib import Path -from typing import Iterable, Optional - +from typing import Optional +from uuid import UUID ARTIFACT_REF_VERSION = 1 +ARTIFACT_REF_CURRENT_VERSION = 2 MAX_ARTIFACTS_PER_MESSAGE = 10 _CONTAINER_ROOT = Path("/workspace") +ARTIFACT_TRASH_DIR = ".zcbot_artifact_trash" class ArtifactPathError(ValueError): @@ -23,6 +26,7 @@ class ArtifactPathError(ValueError): class ArtifactRef: path: str label: str = "" + artifact_id: Optional[UUID] = None scope: str = "working_dir" version: int = ARTIFACT_REF_VERSION @@ -32,6 +36,8 @@ class ArtifactRef: "scope": self.scope, "path": self.path, } + if self.artifact_id: + out["artifact_id"] = str(self.artifact_id) if self.label: out["label"] = self.label return out @@ -125,7 +131,10 @@ def normalize_artifact_refs(refs: Iterable[ArtifactRef]) -> list[dict]: out: list[dict] = [] seen: set[tuple[str, str]] = set() for ref in refs: - if ref.scope != "working_dir" or ref.version != ARTIFACT_REF_VERSION: + if ref.scope != "working_dir" or ref.version not in ( + ARTIFACT_REF_VERSION, + ARTIFACT_REF_CURRENT_VERSION, + ): continue key = (ref.scope, ref.path) if key in seen: diff --git a/core/loop.py b/core/loop.py index 2e38f85..71673c1 100644 --- a/core/loop.py +++ b/core/loop.py @@ -221,6 +221,7 @@ class AgentLoop: capabilities: ModelCapabilities, user_id: UUID, working_dir: Path, + user_root: Optional[Path] = None, sink: Optional[Any] = None, max_iterations: Optional[int] = None, cancel_check: Optional[Callable[[], bool]] = None, @@ -235,6 +236,7 @@ class AgentLoop: # ExecCtx 字段:user_id / task_id 已在,working_dir 单独传 —— 供 docker backend # (Step 3)拼 `--workdir /workspace/` 与临时文件命名空间使用。 self.working_dir = working_dir + self.user_root = Path(user_root).resolve() if user_root else None self.max_iterations = max_iterations or capabilities.max_iterations self.sink = sink # 协作式 cancel:web 层注入 `lambda: broker.is_cancelled(task_id)`; @@ -756,13 +758,31 @@ class AgentLoop: def _remember_artifacts(self, refs: tuple[dict, ...]) -> None: """Accumulate a bounded, ordered set for the final assistant message.""" + if refs and self.user_root is not None: + from .artifact_lifecycle import register_published_artifacts + + refs = register_published_artifacts( + user_id=self.user_id, + task_id=self.session.task_id, + user_root=self.user_root, + working_dir=self.working_dir, + refs=refs, + ) seen = { - (str(ref.get("scope") or ""), str(ref.get("path") or "")) + ( + str(ref.get("artifact_id") or ""), + 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: + key = ( + str(ref.get("artifact_id") or ""), + str(ref.get("scope") or ""), + str(ref.get("path") or ""), + ) + if not key[2] or key in seen: continue self._pending_artifact_refs.append(dict(ref)) seen.add(key) diff --git a/core/storage/models.py b/core/storage/models.py index c95f39a..6ed9974 100644 --- a/core/storage/models.py +++ b/core/storage/models.py @@ -196,6 +196,54 @@ class Message(Base): return sanitize_jsonb_nul(value) +class Artifact(Base): + """Stable identity and lifecycle metadata for a published workspace file.""" + + __tablename__ = "artifacts" + __table_args__ = ( + Index("ix_artifacts_user_status_path", "user_id", "status", "current_path"), + Index("ix_artifacts_origin_task", "origin_task_id"), + ) + + artifact_id: Mapped[UUID] = mapped_column( + PG_UUID(as_uuid=True), primary_key=True, default=uuid4 + ) + user_id: Mapped[UUID] = mapped_column( + PG_UUID(as_uuid=True), ForeignKey("users.user_id"), nullable=False + ) + origin_task_id: Mapped[Optional[UUID]] = mapped_column( + PG_UUID(as_uuid=True), + ForeignKey("tasks.task_id", ondelete="SET NULL"), + nullable=True, + ) + copied_from_artifact_id: Mapped[Optional[UUID]] = mapped_column( + PG_UUID(as_uuid=True), + ForeignKey("artifacts.artifact_id", ondelete="SET NULL"), + nullable=True, + ) + current_path: Mapped[str] = mapped_column(Text, nullable=False) + label: Mapped[str] = mapped_column(Text, nullable=False, default="") + media_type: Mapped[Optional[str]] = mapped_column(Text, nullable=True) + size_bytes: Mapped[Optional[int]] = mapped_column(BigInteger, nullable=True) + content_sha256: Mapped[Optional[str]] = mapped_column(Text, nullable=True) + status: Mapped[str] = mapped_column( + Text, nullable=False, default="active", server_default="active" + ) + trash_path: Mapped[Optional[str]] = mapped_column(Text, nullable=True) + created_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), server_default=func.now(), nullable=False + ) + updated_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), + server_default=func.now(), + onupdate=func.now(), + nullable=False, + ) + deleted_at: Mapped[Optional[datetime]] = mapped_column( + DateTime(timezone=True), nullable=True + ) + + class UsageEvent(Base): """per-event 用量记账(0006 v2 形态)。 diff --git a/core/working_dirs.py b/core/working_dirs.py index 6b31707..75e4770 100644 --- a/core/working_dirs.py +++ b/core/working_dirs.py @@ -13,7 +13,7 @@ from sqlalchemy import select, update from .paths import to_db_path from .storage import NoSubtaskError, check_no_subtask, session_scope -from .storage.models import Task +from .storage.models import Artifact, Task class WorkingDirRenameError(RuntimeError): @@ -94,6 +94,22 @@ def rename_working_dir( .where(Task.task_id.in_(tids)) .values(working_dir=new_db) ) + old_rel = old.relative_to(old.parent).as_posix() + new_rel = new.relative_to(new.parent).as_posix() + artifacts = s.execute( + select(Artifact).where( + Artifact.user_id == user_id, + Artifact.status == "active", + ) + ).scalars().all() + for artifact in artifacts: + if artifact.current_path == old_rel: + suffix = "" + elif artifact.current_path.startswith(old_rel + "/"): + suffix = artifact.current_path[len(old_rel):] + else: + continue + artifact.current_path = new_rel + suffix try: old.rename(new) except OSError as e: diff --git a/db/migrations/versions/20260812_1400_0028_artifacts.py b/db/migrations/versions/20260812_1400_0028_artifacts.py new file mode 100644 index 0000000..a767a7d --- /dev/null +++ b/db/migrations/versions/20260812_1400_0028_artifacts.py @@ -0,0 +1,143 @@ +"""Add stable artifact identity and lifecycle metadata. + +Revision ID: 0028 +Revises: 0027 +Create Date: 2026-08-12 +""" +from typing import Sequence, Union + +import sqlalchemy as sa +from alembic import op +from sqlalchemy.dialects import postgresql + + +revision: str = "0028" +down_revision: Union[str, None] = "0027" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + op.create_table( + "artifacts", + sa.Column( + "artifact_id", + postgresql.UUID(as_uuid=True), + nullable=False, + ), + sa.Column( + "user_id", + postgresql.UUID(as_uuid=True), + nullable=False, + ), + sa.Column( + "origin_task_id", + postgresql.UUID(as_uuid=True), + nullable=True, + ), + sa.Column( + "copied_from_artifact_id", + postgresql.UUID(as_uuid=True), + nullable=True, + ), + sa.Column("current_path", sa.Text(), nullable=False), + sa.Column("label", sa.Text(), server_default="", nullable=False), + sa.Column("media_type", sa.Text(), nullable=True), + sa.Column("size_bytes", sa.BigInteger(), nullable=True), + sa.Column("content_sha256", sa.Text(), nullable=True), + sa.Column("status", sa.Text(), server_default="active", nullable=False), + sa.Column("trash_path", sa.Text(), nullable=True), + sa.Column( + "created_at", + sa.DateTime(timezone=True), + server_default=sa.func.now(), + nullable=False, + ), + sa.Column( + "updated_at", + sa.DateTime(timezone=True), + server_default=sa.func.now(), + nullable=False, + ), + sa.Column("deleted_at", sa.DateTime(timezone=True), nullable=True), + sa.CheckConstraint( + "status IN ('active', 'deleted')", + name="ck_artifacts_status", + ), + sa.ForeignKeyConstraint(["user_id"], ["users.user_id"]), + sa.ForeignKeyConstraint( + ["origin_task_id"], + ["tasks.task_id"], + ondelete="SET NULL", + ), + sa.ForeignKeyConstraint( + ["copied_from_artifact_id"], + ["artifacts.artifact_id"], + ondelete="SET NULL", + ), + sa.PrimaryKeyConstraint("artifact_id"), + ) + op.create_index( + "ix_artifacts_user_status_path", + "artifacts", + ["user_id", "status", "current_path"], + ) + op.create_index( + "ix_artifacts_origin_task", + "artifacts", + ["origin_task_id"], + ) + op.create_index( + "uq_artifacts_active_user_path", + "artifacts", + ["user_id", "current_path"], + unique=True, + postgresql_where=sa.text("status = 'active'"), + ) + # Backfill legacy structured refs without rewriting append-only message metadata. + # working_dir is stored as workspace/users//. + op.execute( + r""" + INSERT INTO artifacts ( + artifact_id, user_id, origin_task_id, current_path, label, status + ) + SELECT DISTINCT ON (t.user_id, current_path) + gen_random_uuid(), + t.user_id, + t.task_id, + current_path, + COALESCE(ref->>'label', ''), + 'active' + FROM tasks AS t + JOIN messages AS m ON m.task_id = t.task_id + CROSS JOIN LATERAL jsonb_array_elements( + CASE + WHEN jsonb_typeof(m.artifact_refs) = 'array' + THEN m.artifact_refs + ELSE '[]'::jsonb + END + ) AS ref + CROSS JOIN LATERAL ( + SELECT + regexp_replace( + t.working_dir, + '^workspace/users/' || t.user_id::text || '/', + '' + ) || '/' || (ref->>'path') AS current_path + ) AS resolved + WHERE m.artifact_refs IS NOT NULL + AND ref->>'scope' = 'working_dir' + AND COALESCE(ref->>'path', '') <> '' + AND ref->>'path' !~ '(^/|(^|/)\.\.(/|$))' + AND strpos(ref->>'path', chr(92)) = 0 + ORDER BY t.user_id, current_path, m.created_at + ON CONFLICT DO NOTHING + """ + ) + + +def downgrade() -> None: + op.drop_index("uq_artifacts_active_user_path", table_name="artifacts") + op.drop_index("ix_artifacts_origin_task", table_name="artifacts") + op.drop_index("ix_artifacts_user_status_path", table_name="artifacts") + op.drop_table("artifacts") diff --git a/tests/frontend_preview.test.mjs b/tests/frontend_preview.test.mjs index ba9897f..70e3b32 100644 --- a/tests/frontend_preview.test.mjs +++ b/tests/frontend_preview.test.mjs @@ -106,7 +106,8 @@ test("assistant HTML artifacts render inline with lazy loading and an expand act 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(previewJs, /downloadFile\(_fpCurrentRel, _fpCurrentTaskId, _fpCurrentLegacy, _fpCurrentArtifactId\)/); + assert.match(mediaJs, /data-artifact-id/); 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]")'); diff --git a/tests/test_artifacts.py b/tests/test_artifacts.py index 396171b..d8fa02f 100644 --- a/tests/test_artifacts.py +++ b/tests/test_artifacts.py @@ -1,10 +1,20 @@ import tempfile import unittest +from contextlib import contextmanager from pathlib import Path +from types import SimpleNamespace +from unittest.mock import MagicMock, patch +from uuid import uuid4 -from core.artifacts import ArtifactPathError, ToolExecutionResult, resolve_artifact_path +from core.artifacts import ( + ArtifactRef, + ArtifactPathError, + ToolExecutionResult, + resolve_artifact_path, +) from core.executor import ExecCtx from core.executor_host import HostExecutor +from core.artifact_lifecycle import trash_active_artifacts from tools.publish_artifacts import PublishArtifactsTool from web.routers.files import _task_file_target @@ -35,6 +45,47 @@ class ArtifactPathTests(unittest.TestCase): self.assertEqual(actual, expected.resolve()) self.assertEqual(rel, "report.pdf") + def test_version_two_ref_carries_stable_artifact_identity(self) -> None: + artifact_id = uuid4() + ref = ArtifactRef( + path="report.pdf", + label="最终报告", + artifact_id=artifact_id, + version=2, + ).as_dict() + self.assertEqual(ref["version"], 2) + self.assertEqual(ref["artifact_id"], str(artifact_id)) + + def test_trash_moves_file_and_marks_lifecycle_row_deleted(self) -> None: + artifact = self.wd / "report.pdf" + row = SimpleNamespace( + current_path="技术讨论/report.pdf", + status="active", + deleted_at=None, + trash_path=None, + ) + session = MagicMock() + session.execute.return_value.scalars.return_value.all.return_value = [row] + + @contextmanager + def fake_scope(): + yield session + + with patch("core.artifact_lifecycle.session_scope", fake_scope): + count = trash_active_artifacts( + user_id=uuid4(), + user_root=self.root, + target=artifact, + ) + + self.assertEqual(count, 1) + self.assertFalse(artifact.exists()) + self.assertEqual(row.status, "deleted") + self.assertIsNotNone(row.deleted_at) + trashed = self.root / row.trash_path + self.assertTrue(trashed.is_file()) + self.assertEqual(trashed.read_bytes(), b"pdf") + def test_explicit_dot_slash_disambiguates_same_named_subdirectory(self) -> None: nested = self.wd / "技术讨论" / "nested.html" nested.parent.mkdir() diff --git a/tests/test_web_routes_db.py b/tests/test_web_routes_db.py index d9784ca..b7aac17 100644 --- a/tests/test_web_routes_db.py +++ b/tests/test_web_routes_db.py @@ -48,7 +48,7 @@ try: if not _test_db_ready(): raise RuntimeError("ZCBOT_TEST_DB_URL 未设") from core.storage import session_scope - from core.storage.models import Message, Task, UsageEvent, User + from core.storage.models import Artifact, Message, Task, UsageEvent, User with session_scope() as _s: _s.execute(__import__("sqlalchemy").select(1)) @@ -57,6 +57,7 @@ except Exception: _DB_OK = False if _DB_OK: + from sqlalchemy import select from starlette.testclient import TestClient from web.app import create_app from web.auth import AuthConfig, mint_token @@ -90,6 +91,7 @@ def tearDownModule() -> None: tids = s.execute(select(Task.task_id).where(Task.user_id == _UID)).scalars().all() if tids: s.execute(delete(Message).where(Message.task_id.in_(tids))) + s.execute(delete(Artifact).where(Artifact.user_id == _UID)) s.execute(delete(UsageEvent).where(UsageEvent.user_id == _UID)) s.execute(delete(Task).where(Task.user_id == _UID)) s.execute(delete(User).where(User.user_id == _UID)) @@ -338,6 +340,80 @@ class FilesDbAwareTests(unittest.TestCase): self.assertEqual(r.status_code, 201, r.text) return r.json()["task_id"] + def test_published_artifact_delete_moves_to_hidden_trash(self): + tid = self._mk_task("产物回收任务", "产物回收目录") + artifact = _user_root() / "产物回收目录" / "reports" / "result.pdf" + artifact.parent.mkdir(parents=True, exist_ok=True) + artifact.write_bytes(b"artifact") + with session_scope() as s: + s.add(Artifact( + user_id=_UID, + origin_task_id=uuid.UUID(tid), + current_path="产物回收目录/reports/result.pdf", + label="结果", + )) + + r = _client.post( + "/v1/files/delete", + json={"path": "产物回收目录/reports/result.pdf"}, + headers=_AUTH, + ) + + self.assertEqual(r.status_code, 200, r.text) + self.assertEqual(r.json()["artifacts_trashed"], 1) + self.assertFalse(artifact.exists()) + trash = _user_root() / ".zcbot_artifact_trash" + trashed = list(trash.rglob("result.pdf")) + self.assertEqual(len(trashed), 1) + self.assertEqual(trashed[0].read_bytes(), b"artifact") + + def test_artifact_copy_gets_new_identity_and_move_keeps_it(self): + tid = self._mk_task("产物复制任务", "产物复制目录") + source = _user_root() / "产物复制目录" / "result.pdf" + source.write_bytes(b"artifact") + destination = _user_root() / "复制目标" + destination.mkdir() + with session_scope() as s: + original = Artifact( + user_id=_UID, + origin_task_id=uuid.UUID(tid), + current_path="产物复制目录/result.pdf", + label="结果", + ) + s.add(original) + s.flush() + original_id = original.artifact_id + + copied = _client.post( + "/v1/files/copy", + json={"paths": ["产物复制目录/result.pdf"], "dest_dir": "复制目标"}, + headers=_AUTH, + ) + self.assertEqual(copied.status_code, 200, copied.text) + self.assertEqual(copied.json()["transferred"][0]["artifacts_copied"], 1) + with session_scope() as s: + copy_row = s.execute( + select(Artifact).where( + Artifact.user_id == _UID, + Artifact.current_path == "复制目标/result.pdf", + ) + ).scalar_one() + copied_id = copy_row.artifact_id + self.assertNotEqual(copied_id, original_id) + self.assertEqual(copy_row.copied_from_artifact_id, original_id) + + archive = _user_root() / "归档" + archive.mkdir() + moved = _client.post( + "/v1/files/move", + json={"paths": ["复制目标/result.pdf"], "dest_dir": "归档"}, + headers=_AUTH, + ) + self.assertEqual(moved.status_code, 200, moved.text) + with session_scope() as s: + moved_row = s.get(Artifact, copied_id) + self.assertEqual(moved_row.current_path, "归档/result.pdf") + def test_toplevel_rename_cascades_db(self): tid = self._mk_task("改名任务", "改名前目录") r = _client.post("/v1/files/rename", diff --git a/tests/test_web_routes_nodb.py b/tests/test_web_routes_nodb.py index fbb1af8..17be34f 100644 --- a/tests/test_web_routes_nodb.py +++ b/tests/test_web_routes_nodb.py @@ -384,16 +384,18 @@ class FilesRoutesTests(unittest.TestCase): def test_rename_delete_copy_nontop(self): (self.wd / "sub" / "c.txt").write_text("c", encoding="utf-8") # 非顶层改名:纯 FS,tasks_updated=0 - r = _client.post("/v1/files/rename", - json={"path": "route-test-wd/sub/c.txt", "new_name": "c2.txt"}, headers=_AUTH) + with patch("core.artifact_lifecycle.rename_active_artifacts", return_value=0): + r = _client.post("/v1/files/rename", + json={"path": "route-test-wd/sub/c.txt", "new_name": "c2.txt"}, headers=_AUTH) self.assertEqual(r.status_code, 200) self.assertEqual(r.json()["tasks_updated"], 0) self.assertTrue((self.wd / "sub" / "c2.txt").is_file()) # 拷贝到子目录内(非顶层,无 DB 闸) (self.wd / "dest").mkdir(exist_ok=True) - r = _client.post("/v1/files/copy", - json={"paths": ["route-test-wd/sub/c2.txt"], "dest_dir": "route-test-wd/dest"}, - headers=_AUTH) + with patch("core.artifact_lifecycle.copy_active_artifacts", return_value=0): + r = _client.post("/v1/files/copy", + json={"paths": ["route-test-wd/sub/c2.txt"], "dest_dir": "route-test-wd/dest"}, + headers=_AUTH) self.assertEqual(r.status_code, 200) self.assertTrue((self.wd / "dest" / "c2.txt").is_file()) # 目标已存在 → 409(预检整批 abort) @@ -402,11 +404,13 @@ class FilesRoutesTests(unittest.TestCase): headers=_AUTH) self.assertEqual(r.status_code, 409) # 删文件 - r = _client.post("/v1/files/delete", json={"path": "route-test-wd/dest/c2.txt"}, headers=_AUTH) + with patch("core.artifact_lifecycle.trash_active_artifacts", return_value=0): + r = _client.post("/v1/files/delete", json={"path": "route-test-wd/dest/c2.txt"}, headers=_AUTH) self.assertEqual(r.status_code, 200) self.assertFalse((self.wd / "dest" / "c2.txt").exists()) # 删非空目录不带 recursive → 400 - r = _client.post("/v1/files/delete", json={"path": "route-test-wd/sub"}, headers=_AUTH) + with patch("core.artifact_lifecycle.trash_active_artifacts", return_value=0): + r = _client.post("/v1/files/delete", json={"path": "route-test-wd/sub"}, headers=_AUTH) self.assertEqual(r.status_code, 400) diff --git a/web/routers/files.py b/web/routers/files.py index 29989ef..c15b863 100644 --- a/web/routers/files.py +++ b/web/routers/files.py @@ -103,6 +103,30 @@ def _task_file_target(root: Path, working_dir: Path, path: str, legacy: bool) -> return candidates[0] +def _artifact_target( + root: Path, + user_id: UUID, + artifact_id: str, +) -> Path: + try: + aid = UUID(artifact_id) + except ValueError: + raise HTTPException(404, "invalid artifact id") + from core.storage.models import Artifact + + with session_scope() as s: + current_path = s.execute( + select(Artifact.current_path).where( + Artifact.artifact_id == aid, + Artifact.user_id == user_id, + Artifact.status == "active", + ) + ).scalar_one_or_none() + if not current_path: + raise HTTPException(404, "artifact not found") + return safe_join(root, current_path) + + async def _pptx_preview_response(target: Path, display_path: str) -> FileResponse: from ..pptx_render import ( PptxConvertError, @@ -221,12 +245,17 @@ def register_file_routes(app, *, require_user) -> None: task_id: str, path: str, legacy: bool = False, + artifact_id: str = "", 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) + target = ( + _artifact_target(root, user_id, artifact_id) + if artifact_id + else _task_file_target(root, working_dir, path, legacy) + ) return _regular_file_response(target, path) @app.get("/v1/files/preview_pdf", tags=["files"]) @@ -248,12 +277,17 @@ def register_file_routes(app, *, require_user) -> None: task_id: str, path: str, legacy: bool = False, + artifact_id: str = "", 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) + target = ( + _artifact_target(root, user_id, artifact_id) + if artifact_id + else _task_file_target(root, working_dir, path, legacy) + ) return await _pptx_preview_response(target, path) @app.post("/v1/files/upload", tags=["files"]) @@ -353,6 +387,8 @@ def register_file_routes(app, *, require_user) -> None: - 顶层空目录 / 子级空目录无论 recursive 与否都可删:task.working_dir 字段不动, 下次 build_agent 按需 mkdir 重建,FS 目录视为可重生 - root → 400;不存在 → 404 + - 已通过结构化 artifact_refs 发布的文件先移入平台隐藏回收区;用户侧仍立即消失 + 普通文件仍物理删除;递归目录仅回收其中的 artifact """ root = load_user_root(user_id) target = safe_join(root, body.path) @@ -360,8 +396,15 @@ def register_file_routes(app, *, require_user) -> None: raise HTTPException(400, "cannot delete user_root") if not target.exists(): raise HTTPException(404, f"path not found: {body.path}") + target_is_dir = target.is_dir() + if target_is_dir and not body.recursive: + try: + if any(target.iterdir()): + raise HTTPException(400, "delete failed: directory is not empty") + except OSError as e: + raise HTTPException(400, f"delete failed: {e}") - if target.is_dir() and body.recursive: + if target_is_dir and body.recursive: is_top_level = target.parent.resolve() == root.resolve() if is_top_level: db_form = to_db_path(target) @@ -381,17 +424,28 @@ def register_file_routes(app, *, require_user) -> None: ) try: - if target.is_dir(): + from core.artifact_lifecycle import trash_active_artifacts + + trashed_artifacts = trash_active_artifacts( + user_id=user_id, + user_root=root, + target=target, + ) + if target_is_dir: if body.recursive: import shutil shutil.rmtree(target) else: - target.rmdir() # 非空目录会触发 OSError - else: + target.rmdir() + elif not trashed_artifacts: target.unlink() except OSError as e: raise HTTPException(400, f"delete failed: {e}") - return {"ok": True, "path": body.path} + return { + "ok": True, + "path": body.path, + "artifacts_trashed": trashed_artifacts, + } @app.post("/v1/files/rename", tags=["files"]) def rename_path( @@ -443,8 +497,21 @@ def register_file_routes(app, *, require_user) -> None: if not is_top_level_dir: try: target.rename(new_target) + from core.artifact_lifecycle import rename_active_artifacts + rename_active_artifacts( + user_id=user_id, + user_root=root, + old_path=target, + new_path=new_target, + ) except OSError as e: raise HTTPException(400, f"rename failed: {e}") + except Exception as e: + try: + new_target.rename(target) + except OSError: + pass + raise HTTPException(500, f"artifact metadata update failed: {e}") return { "ok": True, "old": body.path, @@ -481,7 +548,8 @@ def register_file_routes(app, *, require_user) -> None: - 不覆盖(任一目标已存在 → 409) - 不能拷到自己 / 自身子树 - - 顶层目录(可能是某 task 的 working_dir)可以拷:新副本无 task 关联,不动 DB + - 顶层目录(可能是某 task 的 working_dir)可以拷:不创建 task;其中 artifact + 为副本创建独立身份并记录 copied_from_artifact_id - 部分失败语义:任一 FS 拷贝抛错 → 抛 HTTPException,**前面已成功的拷贝保留** (无 FS 事务可回滚;预检通过后通常不会失败,失败也是磁盘满 / 权限这类不能恢复的) """ @@ -496,15 +564,31 @@ def register_file_routes(app, *, require_user) -> None: shutil.copytree(src, target) else: shutil.copy2(src, target) + from core.artifact_lifecycle import copy_active_artifacts + artifacts_copied = copy_active_artifacts( + user_id=user_id, + user_root=root, + source=src, + target=target, + ) except OSError as e: raise HTTPException( 500, f"copy failed at {src.name!r}: {e} " f"(已成功 {len(transferred)} 项,剩余未处理)", ) + except Exception as e: + if target.is_dir(): + shutil.rmtree(target, ignore_errors=True) + else: + target.unlink(missing_ok=True) + raise HTTPException( + 500, f"copy metadata failed at {src.name!r}: {e}" + ) transferred.append({ "old": rel_to(root, src), "new": rel_to(root, target), + "artifacts_copied": artifacts_copied, }) return {"ok": True, "count": len(transferred), "transferred": transferred} @@ -519,7 +603,7 @@ def register_file_routes(app, *, require_user) -> None: - **顶层目录是某 task 的 working_dir → 409**,维持 "working_dir = 顶层目录" invariant (允许的话 task working_dir 沉到子目录会让 rename 顶层的 DB-aware 逻辑失效; 用户想归档:先 DELETE task) - - 拷贝(`/copy`)无此限制,因为新副本无 task 关联 + - 拷贝(`/copy`)无此限制,因为副本不创建 task;artifact 身份独立复制 - 部分失败:同 /copy,前面成功的不回滚(`shutil.move` 失败几乎只发生在 跨卷拷贝中断,workspace 都在同一磁盘下罕见) """ @@ -562,14 +646,30 @@ def register_file_routes(app, *, require_user) -> None: target = dest / src.name try: shutil.move(str(src), str(target)) + from core.artifact_lifecycle import rename_active_artifacts + artifacts_moved = rename_active_artifacts( + user_id=user_id, + user_root=root, + old_path=src, + new_path=target, + ) except OSError as e: raise HTTPException( 500, f"move failed at {src.name!r}: {e} " f"(已成功 {len(transferred)} 项,剩余未处理)", ) + except Exception as e: + try: + shutil.move(str(target), str(src)) + except OSError: + pass + raise HTTPException( + 500, f"move metadata failed at {src.name!r}: {e}" + ) transferred.append({ "old": rel_to(root, src), "new": rel_to(root, target), + "artifacts_moved": artifacts_moved, }) return {"ok": True, "count": len(transferred), "transferred": transferred} diff --git a/web/static/js/chat.js b/web/static/js/chat.js index 0d833ac..f88bb4c 100644 --- a/web/static/js/chat.js +++ b/web/static/js/chat.js @@ -2532,19 +2532,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, chip.dataset.taskId || "", chip.dataset.legacyPath === "1"); + if (rel) openFilePreview(rel, chip.dataset.taskId || "", chip.dataset.legacyPath === "1", chip.dataset.artifactId || ""); 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, htmlOpen.dataset.taskId || "", htmlOpen.dataset.legacyPath === "1"); + if (rel) openFilePreview(rel, htmlOpen.dataset.taskId || "", htmlOpen.dataset.legacyPath === "1", htmlOpen.dataset.artifactId || ""); 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, inlineImg.dataset.taskId || "", inlineImg.dataset.legacyPath === "1"); + if (rel) openFilePreview(rel, inlineImg.dataset.taskId || "", inlineImg.dataset.legacyPath === "1", inlineImg.dataset.artifactId || ""); return; } // 正文里的 markdown 链接:模型常把工作区相对路径写成 [](),renderMd 出 。 diff --git a/web/static/js/media.js b/web/static/js/media.js index 3db2d03..8e0966f 100644 --- a/web/static/js/media.js +++ b/web/static/js/media.js @@ -188,21 +188,24 @@ export function renderArtifactBarHtml(rels, inlineMode = true, taskId = "", lega const items = rels.map((item) => { const ref = (item && typeof item === "object") ? item : { path: item }; const rel = String(ref.path || ""); + const artifactAttr = ref.artifact_id + ? ` data-artifact-id="${escapeHtml(String(ref.artifact_id))}"` + : ""; 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 → 填 /