feat(artifacts): add lifecycle tracking and trash
This commit is contained in:
parent
a720306b16
commit
4932f00e90
|
|
@ -5,6 +5,11 @@
|
||||||
> 所以不是每个版本号都有条目。条目格式 `## <版本> — <日期>`,新条目加在最上面。
|
> 所以不是每个版本号都有条目。条目格式 `## <版本> — <日期>`,新条目加在最上面。
|
||||||
> 工程口径的完整记录见 `PROGRESS.md` / git log。
|
> 工程口径的完整记录见 `PROGRESS.md` / git log。
|
||||||
|
|
||||||
|
## 0.64.0 — 2026-08-12
|
||||||
|
|
||||||
|
- 已发布产物现在拥有稳定身份:重命名和移动后,历史对话中的产物仍可正常预览和下载;复制产物会创建可独立管理的副本。
|
||||||
|
- 删除已发布产物时,文件会进入平台隐藏回收区而不是立即永久丢失;普通工作文件仍按原方式删除。
|
||||||
|
|
||||||
## 0.63.11 — 2026-08-12
|
## 0.63.11 — 2026-08-12
|
||||||
|
|
||||||
- 修复 PDF 和 PPT 连续预览无法正常滚动的问题,现在可直接上下滚动浏览全部页面。
|
- 修复 PDF 和 PPT 连续预览无法正常滚动的问题,现在可直接上下滚动浏览全部页面。
|
||||||
|
|
|
||||||
|
|
@ -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 留给后续消息误命名。
|
**新对话入口(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)
|
### 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。
|
- **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,失强制力且端点翻倍。
|
- **files API 单一 mutation 入口**(2026-05-18):"顶层目录分支"从数据状态派生而非客户端意图,放服务端才有强制力;双命名空间(/folders vs /files)把分支搬给 client,失强制力且端点翻倍。
|
||||||
- **task 软删除(2026-06-17 推翻 hard cascade)**:公测后对话轨迹是训练/研究语料,`deleted_at` 置位 + restore,避免用户误删立即永久丢失。**当前实现仍无限期保留软删数据**,物理清理仅有管理员手段;后续生命周期已定为“软删除后保留 30 天再物理清理”(待容量信号实施,见 §8.5),届时恢复能力明确限于宽限期内。
|
- **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 元数据,真要细粒度审计再补(届时是新需求非技术债)。
|
- **0004 删 runs/usage_events 旧表**:只写不读的死代码;代价是失历史 run 元数据,真要细粒度审计再补(届时是新需求非技术债)。
|
||||||
- **本地也用 PG 不用 SQLite**:dogfood ≡ 真实路径;Docker 已是必然依赖;双 adapter 维护税 > 一次性配置。
|
- **本地也用 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 都覆盖不了)。
|
- **API-only,UI 由 platform 实现**(2026-05-15):本仓库再维护一套 UI 是双套浪费;SSE payload 从 HTML 切 JSON;沉淀的 sink/broker/路径安全全保留。**dev SPA 留一份**作 dogfood 主路径(SSE 调试 curl/Swagger 都覆盖不了)。
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,7 @@
|
||||||
|
|
||||||
> 配合 `DESIGN.md`。本文件只记 phase 状态、决策偏差、文件量、下一步。每条 1-2 句:做了啥 + 关键判断;细节查 `git log` / `git diff` / `DESIGN §7.9`。
|
> 配合 `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
|
### 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。
|
- **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
|
### 2026-08-11
|
||||||
|
|
|
||||||
1
RUN.md
1
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`。
|
- **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)。
|
- **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。
|
- **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,不解密或改写用户凭据。
|
- **旧 `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
|
```powershell
|
||||||
$env:ZCBOT_MIGRATION_DB_URL="postgresql+psycopg://user:pass@host:5432/zcbot"
|
$env:ZCBOT_MIGRATION_DB_URL="postgresql+psycopg://user:pass@host:5432/zcbot"
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,3 @@
|
||||||
# zcbot 版本号单一事实源:web/app.py 的 FastAPI version、/healthz 返回、前端展示都引这里。
|
# zcbot 版本号单一事实源:web/app.py 的 FastAPI version、/healthz 返回、前端展示都引这里。
|
||||||
# 改版本只动这一行。
|
# 改版本只动这一行。
|
||||||
__version__ = "0.63.11"
|
__version__ = "0.64.0"
|
||||||
|
|
|
||||||
|
|
@ -674,7 +674,7 @@ def build_agent(
|
||||||
|
|
||||||
agent = AgentLoop(
|
agent = AgentLoop(
|
||||||
llm, executor, session, caps,
|
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,
|
skill_model_switch=_skill_model_switch,
|
||||||
deferred_actions=deferred_actions,
|
deferred_actions=deferred_actions,
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
@ -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:
|
Files remain the content source of truth. Version-2 refs carry a stable database identity
|
||||||
they identify a user-facing deliverable relative to a task's mutable working_dir.
|
plus a task-relative path snapshot; version-1 path-only refs remain readable.
|
||||||
"""
|
"""
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from collections.abc import Iterable
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Iterable, Optional
|
from typing import Optional
|
||||||
|
from uuid import UUID
|
||||||
|
|
||||||
ARTIFACT_REF_VERSION = 1
|
ARTIFACT_REF_VERSION = 1
|
||||||
|
ARTIFACT_REF_CURRENT_VERSION = 2
|
||||||
MAX_ARTIFACTS_PER_MESSAGE = 10
|
MAX_ARTIFACTS_PER_MESSAGE = 10
|
||||||
_CONTAINER_ROOT = Path("/workspace")
|
_CONTAINER_ROOT = Path("/workspace")
|
||||||
|
ARTIFACT_TRASH_DIR = ".zcbot_artifact_trash"
|
||||||
|
|
||||||
|
|
||||||
class ArtifactPathError(ValueError):
|
class ArtifactPathError(ValueError):
|
||||||
|
|
@ -23,6 +26,7 @@ class ArtifactPathError(ValueError):
|
||||||
class ArtifactRef:
|
class ArtifactRef:
|
||||||
path: str
|
path: str
|
||||||
label: str = ""
|
label: str = ""
|
||||||
|
artifact_id: Optional[UUID] = None
|
||||||
scope: str = "working_dir"
|
scope: str = "working_dir"
|
||||||
version: int = ARTIFACT_REF_VERSION
|
version: int = ARTIFACT_REF_VERSION
|
||||||
|
|
||||||
|
|
@ -32,6 +36,8 @@ class ArtifactRef:
|
||||||
"scope": self.scope,
|
"scope": self.scope,
|
||||||
"path": self.path,
|
"path": self.path,
|
||||||
}
|
}
|
||||||
|
if self.artifact_id:
|
||||||
|
out["artifact_id"] = str(self.artifact_id)
|
||||||
if self.label:
|
if self.label:
|
||||||
out["label"] = self.label
|
out["label"] = self.label
|
||||||
return out
|
return out
|
||||||
|
|
@ -125,7 +131,10 @@ def normalize_artifact_refs(refs: Iterable[ArtifactRef]) -> list[dict]:
|
||||||
out: list[dict] = []
|
out: list[dict] = []
|
||||||
seen: set[tuple[str, str]] = set()
|
seen: set[tuple[str, str]] = set()
|
||||||
for ref in refs:
|
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
|
continue
|
||||||
key = (ref.scope, ref.path)
|
key = (ref.scope, ref.path)
|
||||||
if key in seen:
|
if key in seen:
|
||||||
|
|
|
||||||
26
core/loop.py
26
core/loop.py
|
|
@ -221,6 +221,7 @@ class AgentLoop:
|
||||||
capabilities: ModelCapabilities,
|
capabilities: ModelCapabilities,
|
||||||
user_id: UUID,
|
user_id: UUID,
|
||||||
working_dir: Path,
|
working_dir: Path,
|
||||||
|
user_root: Optional[Path] = None,
|
||||||
sink: Optional[Any] = None,
|
sink: Optional[Any] = None,
|
||||||
max_iterations: Optional[int] = None,
|
max_iterations: Optional[int] = None,
|
||||||
cancel_check: Optional[Callable[[], bool]] = None,
|
cancel_check: Optional[Callable[[], bool]] = None,
|
||||||
|
|
@ -235,6 +236,7 @@ class AgentLoop:
|
||||||
# ExecCtx 字段:user_id / task_id 已在,working_dir 单独传 —— 供 docker backend
|
# ExecCtx 字段:user_id / task_id 已在,working_dir 单独传 —— 供 docker backend
|
||||||
# (Step 3)拼 `--workdir /workspace/<wd_name>` 与临时文件命名空间使用。
|
# (Step 3)拼 `--workdir /workspace/<wd_name>` 与临时文件命名空间使用。
|
||||||
self.working_dir = working_dir
|
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.max_iterations = max_iterations or capabilities.max_iterations
|
||||||
self.sink = sink
|
self.sink = sink
|
||||||
# 协作式 cancel:web 层注入 `lambda: broker.is_cancelled(task_id)`;
|
# 协作式 cancel:web 层注入 `lambda: broker.is_cancelled(task_id)`;
|
||||||
|
|
@ -756,13 +758,31 @@ class AgentLoop:
|
||||||
|
|
||||||
def _remember_artifacts(self, refs: tuple[dict, ...]) -> None:
|
def _remember_artifacts(self, refs: tuple[dict, ...]) -> None:
|
||||||
"""Accumulate a bounded, ordered set for the final assistant message."""
|
"""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 = {
|
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 self._pending_artifact_refs
|
||||||
}
|
}
|
||||||
for ref in refs:
|
for ref in refs:
|
||||||
key = (str(ref.get("scope") or ""), str(ref.get("path") or ""))
|
key = (
|
||||||
if not key[1] or key in seen:
|
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
|
continue
|
||||||
self._pending_artifact_refs.append(dict(ref))
|
self._pending_artifact_refs.append(dict(ref))
|
||||||
seen.add(key)
|
seen.add(key)
|
||||||
|
|
|
||||||
|
|
@ -196,6 +196,54 @@ class Message(Base):
|
||||||
return sanitize_jsonb_nul(value)
|
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):
|
class UsageEvent(Base):
|
||||||
"""per-event 用量记账(0006 v2 形态)。
|
"""per-event 用量记账(0006 v2 形态)。
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -13,7 +13,7 @@ from sqlalchemy import select, update
|
||||||
|
|
||||||
from .paths import to_db_path
|
from .paths import to_db_path
|
||||||
from .storage import NoSubtaskError, check_no_subtask, session_scope
|
from .storage import NoSubtaskError, check_no_subtask, session_scope
|
||||||
from .storage.models import Task
|
from .storage.models import Artifact, Task
|
||||||
|
|
||||||
|
|
||||||
class WorkingDirRenameError(RuntimeError):
|
class WorkingDirRenameError(RuntimeError):
|
||||||
|
|
@ -94,6 +94,22 @@ def rename_working_dir(
|
||||||
.where(Task.task_id.in_(tids))
|
.where(Task.task_id.in_(tids))
|
||||||
.values(working_dir=new_db)
|
.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:
|
try:
|
||||||
old.rename(new)
|
old.rename(new)
|
||||||
except OSError as e:
|
except OSError as e:
|
||||||
|
|
|
||||||
|
|
@ -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/<uuid>/<user-relative-dir>.
|
||||||
|
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")
|
||||||
|
|
@ -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, /Array\.isArray\(m\.artifact_refs\)/);
|
||||||
assert.match(chatJs, /renderArtifactBarHtml\(m\.artifact_refs, true, state\.taskId/);
|
assert.match(chatJs, /renderArtifactBarHtml\(m\.artifact_refs, true, state\.taskId/);
|
||||||
assert.match(previewJs, /\/v1\/tasks\/\$\{encodeURIComponent\(taskId\)\}\/files\/download/);
|
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"/);
|
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]")');
|
||||||
|
|
|
||||||
|
|
@ -1,10 +1,20 @@
|
||||||
import tempfile
|
import tempfile
|
||||||
import unittest
|
import unittest
|
||||||
|
from contextlib import contextmanager
|
||||||
from pathlib import Path
|
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 import ExecCtx
|
||||||
from core.executor_host import HostExecutor
|
from core.executor_host import HostExecutor
|
||||||
|
from core.artifact_lifecycle import trash_active_artifacts
|
||||||
from tools.publish_artifacts import PublishArtifactsTool
|
from tools.publish_artifacts import PublishArtifactsTool
|
||||||
from web.routers.files import _task_file_target
|
from web.routers.files import _task_file_target
|
||||||
|
|
||||||
|
|
@ -35,6 +45,47 @@ class ArtifactPathTests(unittest.TestCase):
|
||||||
self.assertEqual(actual, expected.resolve())
|
self.assertEqual(actual, expected.resolve())
|
||||||
self.assertEqual(rel, "report.pdf")
|
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:
|
def test_explicit_dot_slash_disambiguates_same_named_subdirectory(self) -> None:
|
||||||
nested = self.wd / "技术讨论" / "nested.html"
|
nested = self.wd / "技术讨论" / "nested.html"
|
||||||
nested.parent.mkdir()
|
nested.parent.mkdir()
|
||||||
|
|
|
||||||
|
|
@ -48,7 +48,7 @@ try:
|
||||||
if not _test_db_ready():
|
if not _test_db_ready():
|
||||||
raise RuntimeError("ZCBOT_TEST_DB_URL 未设")
|
raise RuntimeError("ZCBOT_TEST_DB_URL 未设")
|
||||||
from core.storage import session_scope
|
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:
|
with session_scope() as _s:
|
||||||
_s.execute(__import__("sqlalchemy").select(1))
|
_s.execute(__import__("sqlalchemy").select(1))
|
||||||
|
|
@ -57,6 +57,7 @@ except Exception:
|
||||||
_DB_OK = False
|
_DB_OK = False
|
||||||
|
|
||||||
if _DB_OK:
|
if _DB_OK:
|
||||||
|
from sqlalchemy import select
|
||||||
from starlette.testclient import TestClient
|
from starlette.testclient import TestClient
|
||||||
from web.app import create_app
|
from web.app import create_app
|
||||||
from web.auth import AuthConfig, mint_token
|
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()
|
tids = s.execute(select(Task.task_id).where(Task.user_id == _UID)).scalars().all()
|
||||||
if tids:
|
if tids:
|
||||||
s.execute(delete(Message).where(Message.task_id.in_(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(UsageEvent).where(UsageEvent.user_id == _UID))
|
||||||
s.execute(delete(Task).where(Task.user_id == _UID))
|
s.execute(delete(Task).where(Task.user_id == _UID))
|
||||||
s.execute(delete(User).where(User.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)
|
self.assertEqual(r.status_code, 201, r.text)
|
||||||
return r.json()["task_id"]
|
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):
|
def test_toplevel_rename_cascades_db(self):
|
||||||
tid = self._mk_task("改名任务", "改名前目录")
|
tid = self._mk_task("改名任务", "改名前目录")
|
||||||
r = _client.post("/v1/files/rename",
|
r = _client.post("/v1/files/rename",
|
||||||
|
|
|
||||||
|
|
@ -384,16 +384,18 @@ class FilesRoutesTests(unittest.TestCase):
|
||||||
def test_rename_delete_copy_nontop(self):
|
def test_rename_delete_copy_nontop(self):
|
||||||
(self.wd / "sub" / "c.txt").write_text("c", encoding="utf-8")
|
(self.wd / "sub" / "c.txt").write_text("c", encoding="utf-8")
|
||||||
# 非顶层改名:纯 FS,tasks_updated=0
|
# 非顶层改名:纯 FS,tasks_updated=0
|
||||||
r = _client.post("/v1/files/rename",
|
with patch("core.artifact_lifecycle.rename_active_artifacts", return_value=0):
|
||||||
json={"path": "route-test-wd/sub/c.txt", "new_name": "c2.txt"}, headers=_AUTH)
|
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.status_code, 200)
|
||||||
self.assertEqual(r.json()["tasks_updated"], 0)
|
self.assertEqual(r.json()["tasks_updated"], 0)
|
||||||
self.assertTrue((self.wd / "sub" / "c2.txt").is_file())
|
self.assertTrue((self.wd / "sub" / "c2.txt").is_file())
|
||||||
# 拷贝到子目录内(非顶层,无 DB 闸)
|
# 拷贝到子目录内(非顶层,无 DB 闸)
|
||||||
(self.wd / "dest").mkdir(exist_ok=True)
|
(self.wd / "dest").mkdir(exist_ok=True)
|
||||||
r = _client.post("/v1/files/copy",
|
with patch("core.artifact_lifecycle.copy_active_artifacts", return_value=0):
|
||||||
json={"paths": ["route-test-wd/sub/c2.txt"], "dest_dir": "route-test-wd/dest"},
|
r = _client.post("/v1/files/copy",
|
||||||
headers=_AUTH)
|
json={"paths": ["route-test-wd/sub/c2.txt"], "dest_dir": "route-test-wd/dest"},
|
||||||
|
headers=_AUTH)
|
||||||
self.assertEqual(r.status_code, 200)
|
self.assertEqual(r.status_code, 200)
|
||||||
self.assertTrue((self.wd / "dest" / "c2.txt").is_file())
|
self.assertTrue((self.wd / "dest" / "c2.txt").is_file())
|
||||||
# 目标已存在 → 409(预检整批 abort)
|
# 目标已存在 → 409(预检整批 abort)
|
||||||
|
|
@ -402,11 +404,13 @@ class FilesRoutesTests(unittest.TestCase):
|
||||||
headers=_AUTH)
|
headers=_AUTH)
|
||||||
self.assertEqual(r.status_code, 409)
|
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.assertEqual(r.status_code, 200)
|
||||||
self.assertFalse((self.wd / "dest" / "c2.txt").exists())
|
self.assertFalse((self.wd / "dest" / "c2.txt").exists())
|
||||||
# 删非空目录不带 recursive → 400
|
# 删非空目录不带 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)
|
self.assertEqual(r.status_code, 400)
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -103,6 +103,30 @@ def _task_file_target(root: Path, working_dir: Path, path: str, legacy: bool) ->
|
||||||
return candidates[0]
|
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:
|
async def _pptx_preview_response(target: Path, display_path: str) -> FileResponse:
|
||||||
from ..pptx_render import (
|
from ..pptx_render import (
|
||||||
PptxConvertError,
|
PptxConvertError,
|
||||||
|
|
@ -221,12 +245,17 @@ def register_file_routes(app, *, require_user) -> None:
|
||||||
task_id: str,
|
task_id: str,
|
||||||
path: str,
|
path: str,
|
||||||
legacy: bool = False,
|
legacy: bool = False,
|
||||||
|
artifact_id: str = "",
|
||||||
user_id: UUID = Depends(require_user),
|
user_id: UUID = Depends(require_user),
|
||||||
):
|
):
|
||||||
"""Download a file addressed relative to the task's current working_dir."""
|
"""Download a file addressed relative to the task's current working_dir."""
|
||||||
root = load_user_root(user_id)
|
root = load_user_root(user_id)
|
||||||
_tid, working_dir = _task_working_dir(task_id, user_id, root)
|
_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)
|
return _regular_file_response(target, path)
|
||||||
|
|
||||||
@app.get("/v1/files/preview_pdf", tags=["files"])
|
@app.get("/v1/files/preview_pdf", tags=["files"])
|
||||||
|
|
@ -248,12 +277,17 @@ def register_file_routes(app, *, require_user) -> None:
|
||||||
task_id: str,
|
task_id: str,
|
||||||
path: str,
|
path: str,
|
||||||
legacy: bool = False,
|
legacy: bool = False,
|
||||||
|
artifact_id: str = "",
|
||||||
user_id: UUID = Depends(require_user),
|
user_id: UUID = Depends(require_user),
|
||||||
):
|
):
|
||||||
"""Preview a PPT addressed relative to the task's current working_dir."""
|
"""Preview a PPT addressed relative to the task's current working_dir."""
|
||||||
root = load_user_root(user_id)
|
root = load_user_root(user_id)
|
||||||
_tid, working_dir = _task_working_dir(task_id, user_id, root)
|
_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)
|
return await _pptx_preview_response(target, path)
|
||||||
|
|
||||||
@app.post("/v1/files/upload", tags=["files"])
|
@app.post("/v1/files/upload", tags=["files"])
|
||||||
|
|
@ -353,6 +387,8 @@ def register_file_routes(app, *, require_user) -> None:
|
||||||
- 顶层空目录 / 子级空目录无论 recursive 与否都可删:task.working_dir 字段不动,
|
- 顶层空目录 / 子级空目录无论 recursive 与否都可删:task.working_dir 字段不动,
|
||||||
下次 build_agent 按需 mkdir 重建,FS 目录视为可重生
|
下次 build_agent 按需 mkdir 重建,FS 目录视为可重生
|
||||||
- root → 400;不存在 → 404
|
- root → 400;不存在 → 404
|
||||||
|
- 已通过结构化 artifact_refs 发布的文件先移入平台隐藏回收区;用户侧仍立即消失
|
||||||
|
普通文件仍物理删除;递归目录仅回收其中的 artifact
|
||||||
"""
|
"""
|
||||||
root = load_user_root(user_id)
|
root = load_user_root(user_id)
|
||||||
target = safe_join(root, body.path)
|
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")
|
raise HTTPException(400, "cannot delete user_root")
|
||||||
if not target.exists():
|
if not target.exists():
|
||||||
raise HTTPException(404, f"path not found: {body.path}")
|
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()
|
is_top_level = target.parent.resolve() == root.resolve()
|
||||||
if is_top_level:
|
if is_top_level:
|
||||||
db_form = to_db_path(target)
|
db_form = to_db_path(target)
|
||||||
|
|
@ -381,17 +424,28 @@ def register_file_routes(app, *, require_user) -> None:
|
||||||
)
|
)
|
||||||
|
|
||||||
try:
|
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:
|
if body.recursive:
|
||||||
import shutil
|
import shutil
|
||||||
shutil.rmtree(target)
|
shutil.rmtree(target)
|
||||||
else:
|
else:
|
||||||
target.rmdir() # 非空目录会触发 OSError
|
target.rmdir()
|
||||||
else:
|
elif not trashed_artifacts:
|
||||||
target.unlink()
|
target.unlink()
|
||||||
except OSError as e:
|
except OSError as e:
|
||||||
raise HTTPException(400, f"delete failed: {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"])
|
@app.post("/v1/files/rename", tags=["files"])
|
||||||
def rename_path(
|
def rename_path(
|
||||||
|
|
@ -443,8 +497,21 @@ def register_file_routes(app, *, require_user) -> None:
|
||||||
if not is_top_level_dir:
|
if not is_top_level_dir:
|
||||||
try:
|
try:
|
||||||
target.rename(new_target)
|
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:
|
except OSError as e:
|
||||||
raise HTTPException(400, f"rename failed: {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 {
|
return {
|
||||||
"ok": True,
|
"ok": True,
|
||||||
"old": body.path,
|
"old": body.path,
|
||||||
|
|
@ -481,7 +548,8 @@ def register_file_routes(app, *, require_user) -> None:
|
||||||
|
|
||||||
- 不覆盖(任一目标已存在 → 409)
|
- 不覆盖(任一目标已存在 → 409)
|
||||||
- 不能拷到自己 / 自身子树
|
- 不能拷到自己 / 自身子树
|
||||||
- 顶层目录(可能是某 task 的 working_dir)可以拷:新副本无 task 关联,不动 DB
|
- 顶层目录(可能是某 task 的 working_dir)可以拷:不创建 task;其中 artifact
|
||||||
|
为副本创建独立身份并记录 copied_from_artifact_id
|
||||||
- 部分失败语义:任一 FS 拷贝抛错 → 抛 HTTPException,**前面已成功的拷贝保留**
|
- 部分失败语义:任一 FS 拷贝抛错 → 抛 HTTPException,**前面已成功的拷贝保留**
|
||||||
(无 FS 事务可回滚;预检通过后通常不会失败,失败也是磁盘满 / 权限这类不能恢复的)
|
(无 FS 事务可回滚;预检通过后通常不会失败,失败也是磁盘满 / 权限这类不能恢复的)
|
||||||
"""
|
"""
|
||||||
|
|
@ -496,15 +564,31 @@ def register_file_routes(app, *, require_user) -> None:
|
||||||
shutil.copytree(src, target)
|
shutil.copytree(src, target)
|
||||||
else:
|
else:
|
||||||
shutil.copy2(src, target)
|
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:
|
except OSError as e:
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
500,
|
500,
|
||||||
f"copy failed at {src.name!r}: {e} "
|
f"copy failed at {src.name!r}: {e} "
|
||||||
f"(已成功 {len(transferred)} 项,剩余未处理)",
|
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({
|
transferred.append({
|
||||||
"old": rel_to(root, src),
|
"old": rel_to(root, src),
|
||||||
"new": rel_to(root, target),
|
"new": rel_to(root, target),
|
||||||
|
"artifacts_copied": artifacts_copied,
|
||||||
})
|
})
|
||||||
return {"ok": True, "count": len(transferred), "transferred": transferred}
|
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 → 409**,维持 "working_dir = 顶层目录" invariant
|
||||||
(允许的话 task working_dir 沉到子目录会让 rename 顶层的 DB-aware 逻辑失效;
|
(允许的话 task working_dir 沉到子目录会让 rename 顶层的 DB-aware 逻辑失效;
|
||||||
用户想归档:先 DELETE task)
|
用户想归档:先 DELETE task)
|
||||||
- 拷贝(`/copy`)无此限制,因为新副本无 task 关联
|
- 拷贝(`/copy`)无此限制,因为副本不创建 task;artifact 身份独立复制
|
||||||
- 部分失败:同 /copy,前面成功的不回滚(`shutil.move` 失败几乎只发生在
|
- 部分失败:同 /copy,前面成功的不回滚(`shutil.move` 失败几乎只发生在
|
||||||
跨卷拷贝中断,workspace 都在同一磁盘下罕见)
|
跨卷拷贝中断,workspace 都在同一磁盘下罕见)
|
||||||
"""
|
"""
|
||||||
|
|
@ -562,14 +646,30 @@ def register_file_routes(app, *, require_user) -> None:
|
||||||
target = dest / src.name
|
target = dest / src.name
|
||||||
try:
|
try:
|
||||||
shutil.move(str(src), str(target))
|
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:
|
except OSError as e:
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
500,
|
500,
|
||||||
f"move failed at {src.name!r}: {e} "
|
f"move failed at {src.name!r}: {e} "
|
||||||
f"(已成功 {len(transferred)} 项,剩余未处理)",
|
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({
|
transferred.append({
|
||||||
"old": rel_to(root, src),
|
"old": rel_to(root, src),
|
||||||
"new": rel_to(root, target),
|
"new": rel_to(root, target),
|
||||||
|
"artifacts_moved": artifacts_moved,
|
||||||
})
|
})
|
||||||
return {"ok": True, "count": len(transferred), "transferred": transferred}
|
return {"ok": True, "count": len(transferred), "transferred": transferred}
|
||||||
|
|
|
||||||
|
|
@ -2532,19 +2532,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, chip.dataset.taskId || "", chip.dataset.legacyPath === "1");
|
if (rel) openFilePreview(rel, chip.dataset.taskId || "", chip.dataset.legacyPath === "1", chip.dataset.artifactId || "");
|
||||||
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, htmlOpen.dataset.taskId || "", htmlOpen.dataset.legacyPath === "1");
|
if (rel) openFilePreview(rel, htmlOpen.dataset.taskId || "", htmlOpen.dataset.legacyPath === "1", htmlOpen.dataset.artifactId || "");
|
||||||
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, inlineImg.dataset.taskId || "", inlineImg.dataset.legacyPath === "1");
|
if (rel) openFilePreview(rel, inlineImg.dataset.taskId || "", inlineImg.dataset.legacyPath === "1", inlineImg.dataset.artifactId || "");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
// 正文里的 markdown 链接:模型常把工作区相对路径写成 [<rel>](<rel>),renderMd 出 <a>。
|
// 正文里的 markdown 链接:模型常把工作区相对路径写成 [<rel>](<rel>),renderMd 出 <a>。
|
||||||
|
|
|
||||||
|
|
@ -188,21 +188,24 @@ export function renderArtifactBarHtml(rels, inlineMode = true, taskId = "", lega
|
||||||
const items = rels.map((item) => {
|
const items = rels.map((item) => {
|
||||||
const ref = (item && typeof item === "object") ? item : { path: item };
|
const ref = (item && typeof item === "object") ? item : { path: item };
|
||||||
const rel = String(ref.path || "");
|
const rel = String(ref.path || "");
|
||||||
|
const artifactAttr = ref.artifact_id
|
||||||
|
? ` data-artifact-id="${escapeHtml(String(ref.artifact_id))}"`
|
||||||
|
: "";
|
||||||
if (!rel) return "";
|
if (!rel) return "";
|
||||||
const name = String(ref.label || rel.split("/").pop() || rel);
|
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)}"${taskAttr}${legacyAttr} title="${escapeHtml(rel)}">
|
return `<section class="art-html" data-rel="${escapeHtml(rel)}"${taskAttr}${legacyAttr}${artifactAttr} title="${escapeHtml(rel)}">
|
||||||
<div class="art-html-head"><span>${escapeHtml(name)}</span><button type="button" class="art-html-open" data-rel="${escapeHtml(rel)}"${taskAttr}${legacyAttr}>放大</button></div>
|
<div class="art-html-head"><span>${escapeHtml(name)}</span><button type="button" class="art-html-open" data-rel="${escapeHtml(rel)}"${taskAttr}${legacyAttr}${artifactAttr}>放大</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}"${taskAttr}${legacyAttr} 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}${artifactAttr} title="${escapeHtml(rel)}"><span class="art-media-loading">${escapeHtml(name)} 加载中…</span></span>`;
|
||||||
}
|
}
|
||||||
return `<button type="button" class="art-chip" data-rel="${escapeHtml(rel)}"${taskAttr}${legacyAttr} title="${escapeHtml(rel)} · 点击预览(可下载)">${renderArtifactChipContent(name, rel)}</button>`;
|
return `<button type="button" class="art-chip" data-rel="${escapeHtml(rel)}"${taskAttr}${legacyAttr}${artifactAttr} title="${escapeHtml(rel)} · 点击预览(可下载)">${renderArtifactChipContent(name, rel)}</button>`;
|
||||||
}).join("");
|
}).join("");
|
||||||
return `<div class="artifact-bar">${items}</div>`;
|
return `<div class="artifact-bar">${items}</div>`;
|
||||||
}
|
}
|
||||||
|
|
@ -214,17 +217,18 @@ 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 _artifactDownloadUrl(rel, taskId = "", legacy = false) {
|
function _artifactDownloadUrl(rel, taskId = "", legacy = false, artifactId = "") {
|
||||||
const base = taskId
|
const base = taskId
|
||||||
? `/v1/tasks/${encodeURIComponent(taskId)}/files/download`
|
? `/v1/tasks/${encodeURIComponent(taskId)}/files/download`
|
||||||
: "/v1/files/download";
|
: "/v1/files/download";
|
||||||
return base + "?path=" + encodeURIComponent(rel) + (legacy ? "&legacy=true" : "");
|
return base + "?path=" + encodeURIComponent(rel) + (legacy ? "&legacy=true" : "")
|
||||||
|
+ (artifactId ? "&artifact_id=" + encodeURIComponent(artifactId) : "");
|
||||||
}
|
}
|
||||||
|
|
||||||
function _fetchMediaBlobUrl(rel, taskId = "", legacy = false) {
|
function _fetchMediaBlobUrl(rel, taskId = "", legacy = false, artifactId = "") {
|
||||||
const key = `${taskId}:${legacy ? "legacy:" : ""}${rel}`;
|
const key = `${taskId}:${artifactId}:${legacy ? "legacy:" : ""}${rel}`;
|
||||||
if (_mediaArtifactCache.has(key)) return _mediaArtifactCache.get(key);
|
if (_mediaArtifactCache.has(key)) return _mediaArtifactCache.get(key);
|
||||||
const p = fetch(_artifactDownloadUrl(rel, taskId, legacy), {
|
const p = fetch(_artifactDownloadUrl(rel, taskId, legacy, artifactId), {
|
||||||
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);
|
||||||
|
|
@ -235,10 +239,10 @@ function _fetchMediaBlobUrl(rel, taskId = "", legacy = false) {
|
||||||
return p;
|
return p;
|
||||||
}
|
}
|
||||||
|
|
||||||
function _fetchHtmlArtifact(rel, taskId = "", legacy = false) {
|
function _fetchHtmlArtifact(rel, taskId = "", legacy = false, artifactId = "") {
|
||||||
const key = `${taskId}:${legacy ? "legacy:" : ""}${rel}`;
|
const key = `${taskId}:${artifactId}:${legacy ? "legacy:" : ""}${rel}`;
|
||||||
if (_htmlArtifactCache.has(key)) return _htmlArtifactCache.get(key);
|
if (_htmlArtifactCache.has(key)) return _htmlArtifactCache.get(key);
|
||||||
const p = fetch(_artifactDownloadUrl(rel, taskId, legacy), {
|
const p = fetch(_artifactDownloadUrl(rel, taskId, legacy, artifactId), {
|
||||||
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);
|
||||||
|
|
@ -258,7 +262,7 @@ function _loadInlineHtml(node) {
|
||||||
const taskId = node.dataset.taskId || "";
|
const taskId = node.dataset.taskId || "";
|
||||||
const legacy = node.dataset.legacyPath === "1";
|
const legacy = node.dataset.legacyPath === "1";
|
||||||
const viewport = node.querySelector(".art-html-viewport");
|
const viewport = node.querySelector(".art-html-viewport");
|
||||||
_fetchHtmlArtifact(rel, taskId, legacy).then((source) => {
|
_fetchHtmlArtifact(rel, taskId, legacy, node.dataset.artifactId || "").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");
|
||||||
|
|
@ -308,7 +312,7 @@ export function upgradeMediaArtifacts(root) {
|
||||||
const taskId = node.dataset.taskId || "";
|
const taskId = node.dataset.taskId || "";
|
||||||
const legacy = node.dataset.legacyPath === "1";
|
const legacy = node.dataset.legacyPath === "1";
|
||||||
const cat = node.dataset.cat;
|
const cat = node.dataset.cat;
|
||||||
_fetchMediaBlobUrl(rel, taskId, legacy).then((url) => {
|
_fetchMediaBlobUrl(rel, taskId, legacy, node.dataset.artifactId || "").then((url) => {
|
||||||
node.innerHTML = "";
|
node.innerHTML = "";
|
||||||
if (cat === "image") {
|
if (cat === "image") {
|
||||||
const img = document.createElement("img");
|
const img = document.createElement("img");
|
||||||
|
|
@ -334,11 +338,12 @@ export function upgradeMediaArtifacts(root) {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export function downloadFile(rel, taskId = "", legacy = false) {
|
export function downloadFile(rel, taskId = "", legacy = false, artifactId = "") {
|
||||||
const base = taskId
|
const base = taskId
|
||||||
? `/v1/tasks/${encodeURIComponent(taskId)}/files/download`
|
? `/v1/tasks/${encodeURIComponent(taskId)}/files/download`
|
||||||
: "/v1/files/download";
|
: "/v1/files/download";
|
||||||
fetch(base + "?path=" + encodeURIComponent(rel) + (legacy ? "&legacy=true" : ""), {
|
fetch(base + "?path=" + encodeURIComponent(rel) + (legacy ? "&legacy=true" : "")
|
||||||
|
+ (artifactId ? "&artifact_id=" + encodeURIComponent(artifactId) : ""), {
|
||||||
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; }
|
||||||
|
|
|
||||||
|
|
@ -68,6 +68,7 @@ export function _categorize(rel) {
|
||||||
let _fpCurrentRel = null;
|
let _fpCurrentRel = null;
|
||||||
let _fpCurrentTaskId = "";
|
let _fpCurrentTaskId = "";
|
||||||
let _fpCurrentLegacy = false;
|
let _fpCurrentLegacy = false;
|
||||||
|
let _fpCurrentArtifactId = "";
|
||||||
|
|
||||||
// Markdown / HTML 共用“预览 / 源文件”切换。HTML 在 opaque-origin sandbox iframe
|
// Markdown / HTML 共用“预览 / 源文件”切换。HTML 在 opaque-origin sandbox iframe
|
||||||
// 中运行:可执行脚本、加载 HTTPS 资源,但不能读取 zcbot 页面或发起表单/顶层跳转。
|
// 中运行:可执行脚本、加载 HTTPS 资源,但不能读取 zcbot 页面或发起表单/顶层跳转。
|
||||||
|
|
@ -248,24 +249,27 @@ function _bindBodyWheel(bodyEl) {
|
||||||
}, { passive: false });
|
}, { passive: false });
|
||||||
}
|
}
|
||||||
|
|
||||||
function _fileDownloadUrl(rel, taskId = "", legacy = false) {
|
function _fileDownloadUrl(rel, taskId = "", legacy = false, artifactId = "") {
|
||||||
const base = taskId
|
const base = taskId
|
||||||
? `/v1/tasks/${encodeURIComponent(taskId)}/files/download`
|
? `/v1/tasks/${encodeURIComponent(taskId)}/files/download`
|
||||||
: "/v1/files/download";
|
: "/v1/files/download";
|
||||||
return base + "?path=" + encodeURIComponent(rel) + (legacy ? "&legacy=true" : "");
|
return base + "?path=" + encodeURIComponent(rel) + (legacy ? "&legacy=true" : "")
|
||||||
|
+ (artifactId ? "&artifact_id=" + encodeURIComponent(artifactId) : "");
|
||||||
}
|
}
|
||||||
|
|
||||||
function _pptPreviewUrl(rel, taskId = "", legacy = false) {
|
function _pptPreviewUrl(rel, taskId = "", legacy = false, artifactId = "") {
|
||||||
const base = taskId
|
const base = taskId
|
||||||
? `/v1/tasks/${encodeURIComponent(taskId)}/files/preview_pdf`
|
? `/v1/tasks/${encodeURIComponent(taskId)}/files/preview_pdf`
|
||||||
: "/v1/files/preview_pdf";
|
: "/v1/files/preview_pdf";
|
||||||
return base + "?path=" + encodeURIComponent(rel) + (legacy ? "&legacy=true" : "");
|
return base + "?path=" + encodeURIComponent(rel) + (legacy ? "&legacy=true" : "")
|
||||||
|
+ (artifactId ? "&artifact_id=" + encodeURIComponent(artifactId) : "");
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function openFilePreview(rel, taskId = "", legacy = false) {
|
export async function openFilePreview(rel, taskId = "", legacy = false, artifactId = "") {
|
||||||
_fpCurrentRel = rel;
|
_fpCurrentRel = rel;
|
||||||
_fpCurrentTaskId = taskId;
|
_fpCurrentTaskId = taskId;
|
||||||
_fpCurrentLegacy = legacy;
|
_fpCurrentLegacy = legacy;
|
||||||
|
_fpCurrentArtifactId = artifactId;
|
||||||
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 = "";
|
||||||
|
|
@ -284,9 +288,9 @@ export async function openFilePreview(rel, taskId = "", legacy = false) {
|
||||||
|
|
||||||
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, taskId, legacy); return; }
|
if (cat === "ppt") { await _showPptAsPdf(rel, $("fp-body"), $("fp-meta"), _showFallback, taskId, legacy, artifactId); return; }
|
||||||
try {
|
try {
|
||||||
const r = await fetch(_fileDownloadUrl(rel, taskId, legacy), {
|
const r = await fetch(_fileDownloadUrl(rel, taskId, legacy, artifactId), {
|
||||||
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);
|
||||||
|
|
@ -580,13 +584,13 @@ async function _showPdf(blob) {
|
||||||
}
|
}
|
||||||
|
|
||||||
// pptx/ppt → 后端转 PDF → PDF.js。main / mini 共用各自 body / meta / fallback。
|
// pptx/ppt → 后端转 PDF → PDF.js。main / mini 共用各自 body / meta / fallback。
|
||||||
async function _showPptAsPdf(rel, body, metaEl, fallbackFn, taskId = "", legacy = false) {
|
async function _showPptAsPdf(rel, body, metaEl, fallbackFn, taskId = "", legacy = false, artifactId = "") {
|
||||||
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(_pptPreviewUrl(rel, taskId, legacy), {
|
r = await fetch(_pptPreviewUrl(rel, taskId, legacy, artifactId), {
|
||||||
headers: { "Authorization": "Bearer " + state.token },
|
headers: { "Authorization": "Bearer " + state.token },
|
||||||
});
|
});
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
|
|
@ -690,7 +694,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, _fpCurrentTaskId, _fpCurrentLegacy); };
|
dl.onclick = () => { if (_fpCurrentRel) downloadFile(_fpCurrentRel, _fpCurrentTaskId, _fpCurrentLegacy, _fpCurrentArtifactId); };
|
||||||
ph.appendChild(document.createElement("br"));
|
ph.appendChild(document.createElement("br"));
|
||||||
ph.appendChild(br);
|
ph.appendChild(br);
|
||||||
ph.appendChild(dl);
|
ph.appendChild(dl);
|
||||||
|
|
@ -709,6 +713,7 @@ export function closeFilePreview() {
|
||||||
_fpCurrentRel = null;
|
_fpCurrentRel = null;
|
||||||
_fpCurrentTaskId = "";
|
_fpCurrentTaskId = "";
|
||||||
_fpCurrentLegacy = false;
|
_fpCurrentLegacy = false;
|
||||||
|
_fpCurrentArtifactId = "";
|
||||||
}
|
}
|
||||||
|
|
||||||
let _mpCurrentRel = null;
|
let _mpCurrentRel = null;
|
||||||
|
|
@ -835,7 +840,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, _fpCurrentTaskId, _fpCurrentLegacy); };
|
$("fp-download").onclick = () => { if (_fpCurrentRel) downloadFile(_fpCurrentRel, _fpCurrentTaskId, _fpCurrentLegacy, _fpCurrentArtifactId); };
|
||||||
$("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