feat(software): 回传任务预览文件
This commit is contained in:
parent
1aa79ae0b1
commit
2be9528967
|
|
@ -8,6 +8,8 @@
|
|||
|
||||
## Unreleased
|
||||
|
||||
- Origin 专业软件任务完成后会把轻量预览图回传到当前任务文件夹,并在图片下方显示可预览、下载的文件入口;预览图仍不会自动登记为正式产物,只有明确导出时才获得稳定产物身份。
|
||||
|
||||
- Windows Node 的每个专业软件 adapter 现在自带唯一运行时合同,不再受安装目录遗留共享合同影响;诊断信息会显示实际合同路径、摘要和 Workspace 协议状态,服务端也会阻止协议不匹配的任务反复派发。
|
||||
|
||||
- 修复专业软件持久工程首次建单失败后反复重试的问题;新任务会先建立工程空间再提交执行,并在数据库完整性异常时返回可诊断的错误。
|
||||
|
|
|
|||
|
|
@ -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 的 `<output_namespace>/<job_id>/` 并记录带工作目录相对路径的 `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 提升完成后释放,预览或显式导出的传输失败可在重连时继续,不重复驱动专业软件。
|
||||
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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\(\)/);
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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"]
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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"=仅 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 → 填 <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("");
|
||||
|
|
|
|||
Loading…
Reference in New Issue