From 2be95289671ff644801dd771452dd2f4cf8c92f7 Mon Sep 17 00:00:00 2001 From: caoqianming Date: Wed, 19 Aug 2026 14:56:24 +0800 Subject: [PATCH] =?UTF-8?q?feat(software):=20=E5=9B=9E=E4=BC=A0=E4=BB=BB?= =?UTF-8?q?=E5=8A=A1=E9=A2=84=E8=A7=88=E6=96=87=E4=BB=B6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- CHANGELOG.md | 2 ++ DESIGN.md | 2 +- core/software_jobs.py | 10 ++++++ tests/frontend_preview.test.mjs | 13 +++++++- tests/test_software_output_publish.py | 47 +++++++++++++++++++++++++-- web/routers/software_nodes.py | 29 +++++++++++++---- web/software_followups.py | 2 +- web/static/dev.html | 11 +++++++ web/static/js/media.js | 14 ++++++-- 9 files changed, 116 insertions(+), 14 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 78f1437..594dd71 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,8 @@ ## Unreleased +- Origin 专业软件任务完成后会把轻量预览图回传到当前任务文件夹,并在图片下方显示可预览、下载的文件入口;预览图仍不会自动登记为正式产物,只有明确导出时才获得稳定产物身份。 + - Windows Node 的每个专业软件 adapter 现在自带唯一运行时合同,不再受安装目录遗留共享合同影响;诊断信息会显示实际合同路径、摘要和 Workspace 协议状态,服务端也会阻止协议不匹配的任务反复派发。 - 修复专业软件持久工程首次建单失败后反复重试的问题;新任务会先建立工程空间再提交执行,并在数据库完整性异常时返回可诊断的错误。 diff --git a/DESIGN.md b/DESIGN.md index fcc94b9..e2bacc0 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -482,7 +482,7 @@ Node 对每个 Workspace 只保留 `current` 和 `rollback` 两代。续作开 科研统计图继续以独立 feature 增量扩展同一契约:`box/histogram` 的系列只绑定原始 Y 列,统计规则由 Origin 固定模板决定;`bubble` 增加正数 `size` 数据角色,由受控 modifier column 驱动符号尺寸;`band` 要求同一输入的 `x/y/lower/upper`,先画上下界并填充到下一曲线,再叠加中心线,Worker 在打开 Origin 前拒绝非有限尺寸、非正尺寸和倒置边界。`stacked_column/stacked_area/stacked_bar` 统一把两条以上 XY 系列复制进内部连续 XYY 工作表,严格校验横坐标相同后建立 plot group,并只执行 Worker 内置的固定累计图层命令;请求不能提供命令、模板或工作表范围。`area/polar/pie` 继续使用固定 Origin 类型 ID。未经过目标 Origin 版本真机验证的统计属性不进入公共 schema,避免暴露看似可配但不能稳定复现的参数。 -Workspace capability 的默认完成协议不发布正式产物。Node 只自动上传契约声明的轻量 `preview_outputs`,云端校验后放入 `.zcbot_cache/software-previews/` 并记录 `preview_manifest`;预览可在对话中展示,但没有 artifact UUID,不进入正式文件列表,也不作为后续加工的事实源。完整工程和其他重输出保留在 home node 的 `current` 中,`local_manifest` 只记录固定 output ID、摘要、大小和媒体类型。只有用户明确要求下载、交付或导出时,Agent 调用 `software_job_export`,服务端向 home node 下发所选 output ID,Node 才流式上传,云端复核后移入 task 输出目录并登记正式 Artifact。预览缓存与 Artifact 因此是两条独立生命周期,避免中间截图、低清视频和多轮工程文件污染用户产物。 +Workspace capability 的默认完成协议不发布正式产物。Node 只自动上传契约声明的轻量 `preview_outputs`,云端校验后放入 task 的 `//` 并记录带工作目录相对路径的 `preview_manifest`;预览可在对话和文件面板中查看,但没有 artifact UUID,不进入正式产物清单,也不作为后续加工的事实源。完整工程和其他重输出保留在 home node 的 `current` 中,`local_manifest` 只记录固定 output ID、摘要、大小和媒体类型。只有用户明确要求下载、交付或导出时,Agent 调用 `software_job_export`,服务端向 home node 下发所选 output ID,Node 才流式上传,云端复核后移入 task 输出目录并登记正式 Artifact;若所选输出就是已回传且哈希一致的预览文件,则直接为现有文件补登记。预览文件与 Artifact 身份因此仍是两条独立生命周期,既让用户在文件夹中拿到轻量结果,也避免工程和其他重文件自动回传。 无状态 capability 继续使用原完成即发布协议:Node 按固定 manifest ID 逐项流式 PUT,云端重新绑定路径与媒体类型,校验完整集合后原子发布。两种协议都以 Node、lease、request digest、大小和 SHA-256 做幂等校验;Origin 执行槽在本地终态和 Workspace 提升完成后释放,预览或显式导出的传输失败可在重连时继续,不重复驱动专业软件。 diff --git a/core/software_jobs.py b/core/software_jobs.py index 71dc832..dfe6502 100644 --- a/core/software_jobs.py +++ b/core/software_jobs.py @@ -482,6 +482,16 @@ def get_job(user_id: UUID, job_id: UUID) -> dict | None: return _job_dict(row) if row else None +def get_job_working_dir(user_id: UUID, job_id: UUID) -> str | None: + """返回当前用户指定 Job 所属 task 的工作目录。""" + with session_scope() as session: + return session.execute( + select(Task.working_dir) + .join(SoftwareJob, SoftwareJob.task_id == Task.task_id) + .where(SoftwareJob.job_id == job_id, SoftwareJob.user_id == user_id) + ).scalar_one_or_none() + + def get_job_request(user_id: UUID, task_id: UUID, job_id: UUID) -> dict | None: """返回当前用户和任务内可供 Agent 修改的规范化请求。""" with session_scope() as session: diff --git a/tests/frontend_preview.test.mjs b/tests/frontend_preview.test.mjs index bdab1fc..6a6a3d3 100644 --- a/tests/frontend_preview.test.mjs +++ b/tests/frontend_preview.test.mjs @@ -120,11 +120,22 @@ test("artifact chips expose a compact file type and preview affordance", () => { const mediaJs = readFileSync(new URL("../web/static/js/media.js", import.meta.url), "utf8"); assert.match(mediaJs, /class="art-chip-icon"/); assert.match(mediaJs, /class="art-chip-name"/); - assert.match(mediaJs, /class="art-chip-open">预览/); + assert.match(mediaJs, /renderArtifactChipContent\(name, rel, action = "预览"\)/); + assert.match(mediaJs, /class="art-chip-open">\$\{escapeHtml\(action\)\}/); assert.match(pageHtml, /\.art-chip-icon/); assert.match(pageHtml, /\.art-chip:focus-visible/); }); +test("software job previews expose a distinct file chip below inline media", () => { + const mediaJs = readFileSync(new URL("../web/static/js/media.js", import.meta.url), "utf8"); + assert.match(mediaJs, /ref\.kind === "software_preview"/); + assert.match(mediaJs, /class="software-preview-file"/); + assert.match(mediaJs, /class="art-chip software-preview-chip"/); + assert.match(mediaJs, /renderArtifactChipContent\(name, rel, "预览文件"\)/); + assert.match(pageHtml, /\.software-preview-file\s*\{/); + assert.match(pageHtml, /\.software-preview-chip\s*\{/); +}); + test("sending a message closes every open file preview", () => { assert.match(previewJs, /export function closeAllPreviews\(\)/); assert.match(previewJs, /closeFilePreview\(\)[\s\S]*closeMiniPreview\(\)/); diff --git a/tests/test_software_output_publish.py b/tests/test_software_output_publish.py index b4b8f07..6293602 100644 --- a/tests/test_software_output_publish.py +++ b/tests/test_software_output_publish.py @@ -8,13 +8,14 @@ from unittest.mock import patch from uuid import uuid4 from web.routers.software_nodes import ( + _publish_software_job_export, _publish_software_job_outputs, _publish_software_job_previews, ) class SoftwareOutputPublishTests(unittest.TestCase): - def test_workspace_preview_uses_hidden_cache_without_artifact_registration(self) -> None: + def test_workspace_preview_returns_to_task_folder_without_artifact_registration(self) -> None: with tempfile.TemporaryDirectory() as directory: root = Path(directory) job_id = uuid4() @@ -32,7 +33,9 @@ class SoftwareOutputPublishTests(unittest.TestCase): context = { "capability": "origin.plot@v2", "user_id": uuid4(), + "working_dir": str(root / "research"), } + (root / "research").mkdir() with ( patch("web.routers.software_nodes.load_user_root", return_value=root), patch("web.routers.software_nodes.register_published_artifacts") as register, @@ -41,11 +44,51 @@ class SoftwareOutputPublishTests(unittest.TestCase): self.assertFalse(register.called) self.assertNotIn("artifact_id", previews[0]) self.assertEqual(previews[0]["preview_id"], "figure_png") + self.assertEqual(previews[0]["path"], f"origin/{job_id}/figure.png") self.assertEqual( - (root / ".zcbot_cache" / "software-previews" / str(job_id) / "figure.png").read_bytes(), + (root / "research" / "origin" / str(job_id) / "figure.png").read_bytes(), content, ) + def test_explicit_export_registers_an_existing_preview_file(self) -> None: + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + job_id = uuid4() + working_dir = root / "research" + preview = working_dir / "origin" / str(job_id) / "figure.png" + preview.parent.mkdir(parents=True) + content = b"preview" + preview.write_bytes(content) + manifest = [{ + "artifact_id": "figure_png", + "filename": "figure.png", + "media_type": "image/png", + "size_bytes": len(content), + "sha256": hashlib.sha256(content).hexdigest(), + }] + context = { + "capability": "origin.plot@v2", + "user_id": uuid4(), + "task_id": uuid4(), + "working_dir": str(working_dir), + } + published_id = str(uuid4()) + with ( + patch("web.routers.software_nodes.load_user_root", return_value=root), + patch( + "web.routers.software_nodes.register_published_artifacts", + return_value=({ + "path": f"origin/{job_id}/figure.png", + "artifact_id": published_id, + },), + ) as register, + ): + published = _publish_software_job_export(job_id, context, manifest) + + self.assertEqual(preview.read_bytes(), content) + self.assertEqual(published[0]["artifact_id"], published_id) + self.assertEqual(register.call_args.kwargs["software_job_id"], job_id) + def test_complete_set_moves_atomically_and_can_be_replayed(self) -> None: with tempfile.TemporaryDirectory() as directory: root = Path(directory) diff --git a/web/routers/software_nodes.py b/web/routers/software_nodes.py index 75aa7b4..61223d9 100644 --- a/web/routers/software_nodes.py +++ b/web/routers/software_nodes.py @@ -38,6 +38,7 @@ from core.software_jobs import ( get_job, get_job_input, get_job_output_context, + get_job_working_dir, list_jobs, mark_node_jobs_disconnected, offer_next_job, @@ -306,15 +307,21 @@ def _publish_software_job_previews( if len(selected) != len(preview_ids): raise SoftwareJobError("software job preview manifest is incomplete") root = load_user_root(context["user_id"]) + working_dir = _task_working_dir(root, context["working_dir"]) staging = safe_join(root, f".zcbot_software_job_staging/{job_id}") - destination = safe_join(root, f".zcbot_cache/software-previews/{job_id}") + relative_output = Path(contract.output_namespace) / str(job_id) + destination = safe_join(working_dir, relative_output.as_posix()) _reject_symlink_path(root, staging) _reject_symlink_path(root, destination) destination.mkdir(parents=True, exist_ok=True) previews: list[dict] = [] for item in selected: source = staging / item["filename"] - target = destination / item["filename"] + relative_path = relative_output / software_job_output_path( + context["capability"], item["artifact_id"] + ) + target = safe_join(working_dir, relative_path.as_posix()) + target.parent.mkdir(parents=True, exist_ok=True) if target.is_file(): if ( target.stat().st_size != item["size_bytes"] @@ -335,6 +342,7 @@ def _publish_software_job_previews( "media_type": item["media_type"], "size_bytes": item["size_bytes"], "sha256": item["sha256"], + "path": relative_path.as_posix(), "url": f"/v1/software-jobs/{job_id}/previews/{item['artifact_id']}", }) if staging.is_dir(): @@ -749,10 +757,19 @@ def register_software_node_routes(app, *, require_user, require_admin) -> None: if item is None: raise HTTPException(404, "software job preview not found") root = load_user_root(user_id) - target = safe_join( - root, - f".zcbot_cache/software-previews/{job_id}/{item['filename']}", - ) + stored_path = item.get("path") + working_dir = get_job_working_dir(user_id, job_id) + if isinstance(stored_path, str) and stored_path and working_dir: + target = safe_join( + _task_working_dir(root, working_dir), + stored_path, + ) + else: + # 兼容迁移前已经落在隐藏缓存中的 workspace 预览。 + target = safe_join( + root, + f".zcbot_cache/software-previews/{job_id}/{item['filename']}", + ) if ( not target.is_file() or target.stat().st_size != item["size_bytes"] diff --git a/web/software_followups.py b/web/software_followups.py index bc0131b..e04ea5c 100644 --- a/web/software_followups.py +++ b/web/software_followups.py @@ -44,7 +44,7 @@ def _artifact_refs(manifest: list) -> list[dict]: def _preview_refs(manifest: list) -> list[dict]: return [ { - "path": item["filename"], + "path": item.get("path") or item["filename"], "label": item["filename"], "preview_url": item["url"], "kind": "software_preview", diff --git a/web/static/dev.html b/web/static/dev.html index 7e63e4c..bf16aba 100644 --- a/web/static/dev.html +++ b/web/static/dev.html @@ -1193,6 +1193,17 @@ box-shadow: 0 2px 8px rgba(192,57,43,.10); transform: translateY(-1px); } .art-chip:hover .art-chip-open, .art-chip:focus-visible .art-chip-open { color: var(--accent); } + /* 软件 Job 的轻量预览已回传为普通文件,但尚未登记为正式 artifact。 */ + .software-preview-file { + display: inline-flex; flex-direction: column; align-items: flex-start; + gap: 6px; max-width: 100%; + } + .software-preview-chip { + height: 32px; max-width: min(340px, 100%); padding: 2px 8px 2px 3px; + border-style: dashed; background: transparent; box-shadow: none; color: var(--muted); + } + .software-preview-chip .art-chip-icon { flex-basis: 26px; height: 26px; opacity: .8; } + .software-preview-chip .art-chip-open { padding-left: 2px; } #chat-hint .art-chip { margin: 0 2px; vertical-align: middle; } .paste-chip-wrap { display: inline-flex; align-items: center; max-width: 280px; margin: 0 2px; diff --git a/web/static/js/media.js b/web/static/js/media.js index 2c5788a..89f35e8 100644 --- a/web/static/js/media.js +++ b/web/static/js/media.js @@ -169,13 +169,13 @@ export function extractArtifactRels(text, workingDir) { // 文件 chip 的内容结构保持纯前端生成:扩展名充当轻量类型图标,不额外请求 // 文件元数据;长文件名仍由 CSS 单行省略,完整路径保留在 button title 中。 -export function renderArtifactChipContent(name, rel) { +export function renderArtifactChipContent(name, rel, action = "预览") { const rawExt = String(rel || "").split("/").pop().split(".").pop(); const hasExt = rawExt && rawExt !== String(rel || "").split("/").pop(); const ext = hasExt ? rawExt.toUpperCase().slice(0, 4) : "FILE"; return `` + `${escapeHtml(name)}` - + `预览`; + + `${escapeHtml(action)}`; } // inlineMode 控制升级范围:true=图片/视频/HTML,"html"=仅 HTML,false=全走 chip。 @@ -206,7 +206,15 @@ export function renderArtifactBarHtml(rels, inlineMode = true, taskId = "", lega if (inlineMode === true && (cat === "image" || cat === "video")) { // 占位元素;插入 DOM 后 upgradeMediaArtifacts 异步 fetch blob → 填 /