feat(software): 回传任务预览文件

This commit is contained in:
caoqianming 2026-08-19 14:56:24 +08:00
parent 1aa79ae0b1
commit 2be9528967
9 changed files with 116 additions and 14 deletions

View File

@ -8,6 +8,8 @@
## Unreleased
- Origin 专业软件任务完成后会把轻量预览图回传到当前任务文件夹,并在图片下方显示可预览、下载的文件入口;预览图仍不会自动登记为正式产物,只有明确导出时才获得稳定产物身份。
- Windows Node 的每个专业软件 adapter 现在自带唯一运行时合同,不再受安装目录遗留共享合同影响;诊断信息会显示实际合同路径、摘要和 Workspace 协议状态,服务端也会阻止协议不匹配的任务反复派发。
- 修复专业软件持久工程首次建单失败后反复重试的问题;新任务会先建立工程空间再提交执行,并在数据库完整性异常时返回可诊断的错误。

View File

@ -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 IDNode 才流式上传,云端复核后移入 task 输出目录并登记正式 Artifact。预览缓存与 Artifact 因此是两条独立生命周期,避免中间截图、低清视频和多轮工程文件污染用户产物
Workspace capability 的默认完成协议不发布正式产物。Node 只自动上传契约声明的轻量 `preview_outputs`,云端校验后放入 task 的 `<output_namespace>/<job_id>/` 并记录带工作目录相对路径的 `preview_manifest`;预览可在对话和文件面板中查看,但没有 artifact UUID不进入正式产物清单,也不作为后续加工的事实源。完整工程和其他重输出保留在 home node 的 `current` 中,`local_manifest` 只记录固定 output ID、摘要、大小和媒体类型。只有用户明确要求下载、交付或导出时Agent 调用 `software_job_export`,服务端向 home node 下发所选 output IDNode 才流式上传,云端复核后移入 task 输出目录并登记正式 Artifact;若所选输出就是已回传且哈希一致的预览文件,则直接为现有文件补登记。预览文件与 Artifact 身份因此仍是两条独立生命周期,既让用户在文件夹中拿到轻量结果,也避免工程和其他重文件自动回传
无状态 capability 继续使用原完成即发布协议Node 按固定 manifest ID 逐项流式 PUT云端重新绑定路径与媒体类型校验完整集合后原子发布。两种协议都以 Node、lease、request digest、大小和 SHA-256 做幂等校验Origin 执行槽在本地终态和 Workspace 提升完成后释放,预览或显式导出的传输失败可在重连时继续,不重复驱动专业软件。

View File

@ -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:

View File

@ -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\(\)/);

View File

@ -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)

View File

@ -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"]

View File

@ -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",

View File

@ -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;

View File

@ -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 `<span class="art-chip-icon" aria-hidden="true">${escapeHtml(ext)}</span>`
+ `<span class="art-chip-name">${escapeHtml(name)}</span>`
+ `<span class="art-chip-open">预览</span>`;
+ `<span class="art-chip-open">${escapeHtml(action)}</span>`;
}
// inlineMode 控制升级范围:true=图片/视频/HTML"html"=仅 HTMLfalse=全走 chip。
@ -206,7 +206,15 @@ export function renderArtifactBarHtml(rels, inlineMode = true, taskId = "", lega
if (inlineMode === true && (cat === "image" || cat === "video")) {
// 占位元素;插入 DOM 后 upgradeMediaArtifacts 异步 fetch blob → 填 <img>/<video>。
// 不在这里发请求避免 string-build 阶段失控的并发;upgrade 走 DOM walk 一次。
return `<span class="art-media art-media-${cat}" data-rel="${escapeHtml(rel)}" data-cat="${cat}"${taskAttr}${legacyAttr}${artifactAttr}${previewAttr} title="${escapeHtml(rel)}"><span class="art-media-loading">${escapeHtml(name)} 加载中…</span></span>`;
const media = `<span class="art-media art-media-${cat}" data-rel="${escapeHtml(rel)}" data-cat="${cat}"${taskAttr}${legacyAttr}${artifactAttr}${previewAttr} title="${escapeHtml(rel)}"><span class="art-media-loading">${escapeHtml(name)} 加载中…</span></span>`;
// 新 workspace 预览已经回传到 task 文件夹:图片负责快速查看,弱化 chip
// 明确文件确实存在且可打开。历史预览只有裸文件名、仍在隐藏缓存中,不生成
// 一个无法通过 task files API 打开的 chip。
if (ref.kind === "software_preview" && rel.includes("/")) {
const chip = `<button type="button" class="art-chip software-preview-chip" data-rel="${escapeHtml(rel)}"${taskAttr} title="${escapeHtml(rel)} · 预览文件(可下载)">${renderArtifactChipContent(name, rel, "预览文件")}</button>`;
return `<span class="software-preview-file">${media}${chip}</span>`;
}
return media;
}
return `<button type="button" class="art-chip" data-rel="${escapeHtml(rel)}"${taskAttr}${legacyAttr}${artifactAttr} title="${escapeHtml(rel)} · 点击预览(可下载)">${renderArtifactChipContent(name, rel)}</button>`;
}).join("");