diff --git a/README.md b/README.md index ab47686..717911d 100644 --- a/README.md +++ b/README.md @@ -120,6 +120,7 @@ Workflow One 嵌在 dsh 官方界面中,推荐从 AI 对话开始创建复杂 - **运行与调试**:SSE 实时状态(queued/running/success/error/skipped);就绪即发并发调度;分支容错(单支失败不拖垮其余);节点重试 / 失败继续 / 超时;运行取消 / 重放 / 导出;试运行(手填假输入、关闭即中断);节点级运行详情(实际输入、产物、token 用量、trace) - **多运行并发**:同一工作流可同时运行多个实例(节点工作区 / 产物 / 日志 / 取消按 runId 互不干扰);成果面板顶部运行胶囊条随时切换查看任一运行(LIVE 优先、含来源图标与实时进度),画布节点状态随选中运行联动;定时 / Webhook 触发的运行不抢占当前视图,以 toast + 胶囊提示 - **结果面板**:时间线按图拓扑稳定排序(跳过分支也可见);最终结果取输出节点、失败不被中间结果顶替;过程产物折叠分组;产物流式下载(Range 206 / 统一 MIME / HTML sandbox CSP)与全屏预览(PDF/DOCX/XLS(X)/PPTX 本地渲染) +- **文稿视图**:一键把整个主区切换成「文档墙」——左侧按执行顺序列节点,右侧大卡片渲染该节点产出的 Markdown/图片/视频;运行中流卡实时展示正在生成的文稿,点卡页内全屏预览 + 复制全文/链接,支持文件名搜索、类型过滤、密度切换、键盘导航与新文稿标记。详见 [docs/doc-wall.md](docs/doc-wall.md) - **消息通知节点**:运行级观察器,可独立放置或在线路中透传;支持仅运行结束、每个业务节点完成两种模式;当前通过飞书消息卡片推送到群聊或私聊,渠道层可继续扩展钉钉和企业微信 - **触发与集成**:webhook(token 鉴权)、cron 定时(画布「⋯ → 定时任务」可视化管理:预设 + 自定义表达式、下 3 次触发预览、重叠策略可选跳过/并行、错过触发点可选忽略/补跑、立即运行、停用与编辑)、飞书写回(输出节点可选) - **持久化**:工作流库(命名工作流 CRUD)、运行历史(含 graph 快照)、重启恢复(触发器落盘 + 链式定时等待,含触发/跳过/停机错过统计;错过补跑可按任务开启) diff --git a/docs/doc-wall.md b/docs/doc-wall.md new file mode 100644 index 0000000..80d9d37 --- /dev/null +++ b/docs/doc-wall.md @@ -0,0 +1,47 @@ +# 文稿视图(Doc Wall) + +一次工作流运行会产生几十份过程文稿——底稿、核查单、修订记录、审核意见……散落在成果面板的文件清单里根本没法读。**文稿视图**把整块画布主区切换成「文档墙」:左侧按执行顺序列出全部节点,右侧以大卡片横向铺开该节点产出的所有文稿,Markdown 直接渲染成可读排版。 + +![文稿视图总览](../images/docwall-overview.jpeg) + +## 入口 + +顶部导航 **画布 | 文稿 | 工作流** 三态切换,点「文稿」即进入当前所看运行的文档墙;左侧「成果 / 过程节点」列表可随时跳转。视图状态写入 URL(`#docs/`),刷新保持、链接可分享。 + +## 核心能力 + +### 过程与成果一墙看全 + +- **成果带**:output 节点的最终交付物 + 回写链接置顶展示 +- **过程节点**:按执行拓扑序铺开每个节点的产物;md / 图片 / 视频铺大卡,中间文件折成 chip 行 +- 左侧节点行带状态点与文件计数,点击定位(总览模式)或过滤(单节点模式) + +### 点卡即读,不离开墙 + +点任意卡片弹出页内全屏预览:Markdown 全文(表格、列表、代码块完整排版)、图片原比例、PDF/Office 走文档预览。预览态提供**复制全文**与**复制链接**,正文里行内引用的产物文件名自动变成可点击的预览链接。 + +![全屏预览与复制工具条](../images/docwall-preview.jpeg) + +### 实时流渲染 + +运行中的 agent 节点会出现「实时输出」流卡:优先展示**正在生成的目标文稿**(引擎随 agent-progress 推送文件尾部,标题显示「正在生成:xxx.md」及已写字节数),无文件产物时回退展示 agent 的对话文本。已完成节点的产物卡随跑随铺,运行结束流卡退场、落卡接管。 + +### 检索与浏览 + +| 能力 | 操作 | +| --- | --- | +| 文件名搜索 | toolbar 搜索框,或按 `/` 直接聚焦 | +| 类型过滤 | 全部 / 文档 / 图片 / 视频 | +| 只看有产物的节点 | toolbar ◈ 按钮 | +| 卡片密度 | S / M / L 三档卡宽(300 / 420 / 520px),偏好记忆 | +| 键盘导航 | `J` / `K`(或 `↑` `↓`)节点间移动,`Esc` 关预览,`/` 聚焦搜索 | +| 新文稿标记 | 上次阅读后新落盘的卡片带 NEW 标,离开视图自动记为已读 | +| 一键导出 | toolbar 下载按钮,打包本次运行全部产物为 zip | +| 新文稿提醒 | 画布视图期间运行落盘,「文稿」tab 挂圆点提示 | + +## 工程实现(给维护者) + +- 数据层 `web/src/doc-wall-data.js`:纯函数模型 `buildDocWallModel({ runResults, nodeStates, progressByNode, scopedArtifactUrl })`,磁盘事实投影 + SSE 实时叠加,node 直测 +- 卡内正文上限 2000 字符(全文只进预览);批量正文端点 `POST /wf1/api/artifacts/content` 一次拉全 run(预算 1.5MB,超预算回退单卡拉取);懒挂载 + `React.memo` 控制重渲染 +- 异常恢复原则:SSE 断线重连、成果落盘一律全量再投影,不做增量修补终态 +- 续跑产物断链修复在引擎侧:持久化前把祖先运行可复用节点的产物物化拷贝进本次运行目录(`materializeResumedWorkspaces`),artifact 路由兜底再沿 `resumedFrom` 祖先链回退 diff --git a/dsh-plugins/dsh-ccpg-one/screenshots.json b/dsh-plugins/dsh-ccpg-one/screenshots.json index 18d377e..67cb830 100644 --- a/dsh-plugins/dsh-ccpg-one/screenshots.json +++ b/dsh-plugins/dsh-ccpg-one/screenshots.json @@ -1,4 +1,5 @@ [ "https://raw.githubusercontent.com/chumingjun/dsh-harness-one/main/images/workflow01.png", - "https://raw.githubusercontent.com/chumingjun/dsh-harness-one/main/images/workflow.png" + "https://raw.githubusercontent.com/chumingjun/dsh-harness-one/main/images/workflow.png", + "https://raw.githubusercontent.com/chumingjun/dsh-harness-one/main/images/docwall-overview.jpeg" ] diff --git a/dsh-plugins/dsh-ccpg-orchestrator/lib/index.js b/dsh-plugins/dsh-ccpg-orchestrator/lib/index.js index c101235..6dd4ddb 100644 --- a/dsh-plugins/dsh-ccpg-orchestrator/lib/index.js +++ b/dsh-plugins/dsh-ccpg-orchestrator/lib/index.js @@ -15,8 +15,8 @@ import { randomUUID } from 'node:crypto'; import { AsyncLocalStorage } from 'node:async_hooks'; -import { readFileSync, writeFileSync, existsSync, mkdirSync, readdirSync, statSync, copyFileSync, cpSync, unlinkSync, renameSync, realpathSync, rmSync } from 'node:fs'; -import { join, dirname, extname, isAbsolute, relative, resolve, sep } from 'node:path'; +import { readFileSync, writeFileSync, existsSync, mkdirSync, readdirSync, statSync, copyFileSync, cpSync, unlinkSync, renameSync, realpathSync, rmSync, openSync, readSync, closeSync, constants as fsConstants } from 'node:fs'; +import { join, dirname, extname, isAbsolute, relative, resolve, sep, basename } from 'node:path'; import { fileURLToPath } from 'node:url'; import { homedir } from 'node:os'; import z from '@deepseek-ai/schemastery'; @@ -849,6 +849,34 @@ export function apply(ctx, config) { ...(recoveredStatus === 'interrupted' ? { error: run.error || '运行进程异常终止' } : {}), }); }; + // 断点续跑的可复用节点不重新执行,其工作区文件仍留在祖先运行的 runtime 目录; + // 先物化拷贝到本次运行目录,快照与 /artifact 路由按 runId 定位才能命中。 + // 拷贝失败只记 issue 不阻塞持久化(祖先目录被清理时产物缺失属既成事实)。 + const materializeResumedWorkspaces = (run) => { + if (!run.resumedFrom || !run.nodeStates) return; + const scope = { workflowId: run.workflowId || 'draft', runId: run.runId }; + for (const [nodeId, state] of Object.entries(run.nodeStates)) { + if (state?.status !== 'success' || !Array.isArray(state.artifacts) || !state.artifacts.length) continue; + const sourceRoot = STORAGE.workspaceForNode({ workflowId: run.workflowId || 'draft', runId: run.resumedFrom, nodeId }); + const targetRoot = STORAGE.workspaceForNode({ ...scope, nodeId }); + for (const relativePath of state.artifacts) { + if (!relativePath || String(relativePath).endsWith('/')) continue; + try { + const source = resolveInside(sourceRoot, relativePath); + const target = resolveInside(targetRoot, relativePath); + if (!source || !target) continue; + if (existsSync(target)) continue; + if (!existsSync(source) || !statSync(source).isFile()) continue; + const realSource = realpathSync(source); + if (resolveInside(realpathSync(sourceRoot), realSource) !== realSource) continue; + mkdirSync(dirname(target), { recursive: true, mode: 0o700 }); + copyFileSync(realSource, target, fsConstants.COPYFILE_EXCL); + } catch (error) { + ctx.logger?.warn?.(`[wf1] 续跑产物物化失败(${run.runId}/${nodeId}/${relativePath}):${error.message}`); + } + } + } + }; const persistRun = (run, graph, workflowName, workflowId) => { try { const light = { ...run, _resolved: true }; @@ -866,6 +894,7 @@ export function apply(ctx, config) { graph: graphSnapshot, }); const scope = { workflowId: base.workflowId || 'draft', runId: base.runId }; + materializeResumedWorkspaces(base); const snapshot = snapshotRunArtifacts(base, { workspaceForNode: ({ nodeId }) => STORAGE.workspaceForNode({ ...scope, nodeId }), artifactRunDir: STORAGE.artifactRunDir(scope), @@ -1233,12 +1262,42 @@ export function apply(ctx, config) { } catch { /* session 已释放 */ } return { turns, preview, turnEnded }; }; + // 真·流式生成文稿:扫节点输出目录里最新的文本产物,附其尾部到 agent-progress, + // 前端流卡直接渲染「正在写的文件」而非 agent 对话文本。只 tail 一个文件, + // 2KB 尾部 + 轮询期读盘,开销可忽略;无文本产物时为 undefined(前端回退对话文本) + const DOC_TAIL_BYTES = 2048; + const scanDocTail = () => { + try { + const root = realpathSync(ws); + let newest = null; + for (const entry of readdirSync(root, { recursive: true })) { + if (!/\.(md|markdown|txt|csv)$/i.test(String(entry))) continue; + const full = resolveInside(root, entry); + if (!full || !existsSync(full) || !statSync(full).isFile()) continue; + const mtime = statSync(full).mtimeMs; + if (!newest || mtime > newest.mtime) newest = { full, mtime }; + } + if (!newest) return undefined; + const stat = statSync(newest.full); + const start = Math.max(0, stat.size - DOC_TAIL_BYTES); + const fd = openSync(newest.full, 'r'); + try { + const buf = Buffer.alloc(stat.size - start); + readSync(fd, buf, 0, buf.length, start); + return { name: basename(String(newest.full)), size: stat.size, tail: buf.toString('utf8'), growing: true }; + } finally { closeSync(fd); } + } catch { return undefined; } + }; const watchTick = () => { if (watchState.stop) return; const { turns, preview, turnEnded } = scanEvents(); emit('agent-progress', { runId, nodeId: node.id, turns, - preview: outputConfig.mode === 'structured' ? '' : preview.slice(0, 200), + // 实时输出流(文稿视图消费):assistant 全文拼接,4KB 截断——多工具轮 agent 生成期 + // 前端可看文稿长大;带宽 = 4KB × 并发 agent ÷ 2s,量级安全 + preview: outputConfig.mode === 'structured' ? '' : preview.slice(0, 4096), + // 真·流式:正在写的目标文稿尾部(2KB)。有则前端优先渲染它 + docTail: outputConfig.mode === 'structured' ? undefined : scanDocTail(), structured: outputConfig.mode === 'structured' || undefined, maxRounds: maxRounds || undefined, }); @@ -1246,6 +1305,18 @@ export function apply(ctx, config) { try { agent.cancel({ kind: 'user' }); } catch { /* noop */ } return watchDone(); } + // 首个 turn/end 后延迟复查一次:多工具轮 agent 常在首轮文本后继续调用工具, + // 立即退出会漏报后续轮次;复查仍无新 turn 才确认结束(单轮 agent 语义不变) + if (turnEnded && !watchState.rechecking) { + watchState.rechecking = true; + watchState.timer = setTimeout(() => { + if (watchState.stop) return; + const next = scanEvents(); + if (next.turns > turns) { watchState.rechecking = false; watchTick(); return; } + watchDone(); + }, 2500); + return; + } if (turnEnded) return watchDone(); watchState.timer = setTimeout(watchTick, 2000); }; @@ -2568,18 +2639,25 @@ export function apply(ctx, config) { file: resolved.file, filename: resolved.artifact.name, mediaType, preview, }); } - // 运行中/试运行:运行文档还没有快照,直接从节点工作区解析 - const ws = resolveInside(STORAGE.workspaceForNode({ - workflowId: run?.workflowId || 'draft', runId, nodeId: nodeParam, - }), file); - if (ws && existsSync(ws) && statSync(ws).isFile()) { + // 运行中/试运行:运行文档还没有快照,直接从节点工作区解析; + // 断点续跑的节点产物物理上在祖先运行目录,沿 resumedFrom 链回退(有限深度防环) + const ancestorRunIds = [runId]; + let cursor = readRun(runId); + for (let depth = 0; cursor?.resumedFrom && depth < 10; depth += 1) { + ancestorRunIds.push(cursor.resumedFrom); + cursor = readRun(cursor.resumedFrom); + } + for (const candidateRunId of ancestorRunIds) { + const ws = resolveInside(STORAGE.workspaceForNode({ + workflowId: run?.workflowId || 'draft', runId: candidateRunId, nodeId: nodeParam, + }), file); + if (!ws || !existsSync(ws) || !statSync(ws).isFile()) continue; const realWsParent = realpathSync(dirname(ws)); const realWs = realpathSync(ws); - if (resolveInside(realWsParent, realWs) === realWs) { - const mediaType = mediaTypeFor(file); - const preview = url.searchParams.get('preview') === '1' && isPreviewableMediaType(mediaType); - return streamArtifactResponse(req, res, { file: realWs, filename: file, mediaType, preview }); - } + if (resolveInside(realWsParent, realWs) !== realWs) continue; + const mediaType = mediaTypeFor(file); + const preview = url.searchParams.get('preview') === '1' && isPreviewableMediaType(mediaType); + return streamArtifactResponse(req, res, { file: realWs, filename: file, mediaType, preview }); } return json(res, 404, { error: '产物不存在' }); } @@ -2597,6 +2675,53 @@ export function apply(ctx, config) { return streamArtifactResponse(req, res, { file: realFull, filename: file, mediaType, preview }); } }); + // 文稿墙批量正文:一次请求返回运行内多个产物的文本截断稿,替代逐卡 fetch(37 卡 = 37 请求)。 + // POST { runId, items: [{ node, file }] } → { files: { "\u0000": { content, truncated } } }; + // 总字节预算 1.5MB,超预算的条目返回 { omitted: true },前端回退单卡惰性拉取。仅文本类产物。 + register({ kind: 'exact', path: '/wf1/api/artifacts/content', async handler(req, res) { + if (req.method !== 'POST') return json(res, 405, { error: 'method' }); + const body = await readBody(req); + const runId = String(body?.runId || ''); + const items = Array.isArray(body?.items) ? body.items.slice(0, 200) : []; + if (!runId || !items.length) return json(res, 400, { error: '需要 runId 和 items' }); + const run = readRun(runId); + // 与 /artifact 兜底同款:沿 resumedFrom 祖先链回退定位节点工作区 + const ancestorRunIds = [runId]; + let cursor = run; + for (let depth = 0; cursor?.resumedFrom && depth < 10; depth += 1) { + ancestorRunIds.push(cursor.resumedFrom); + cursor = readRun(cursor.resumedFrom); + } + const BUDGET = 1.5 * 1024 * 1024; + const CLIP = 4096; + const files = {}; + let used = 0; + for (const item of items) { + const nodeId = String(item?.node || ''); + const file = String(item?.file || ''); + if (!nodeId || !file) continue; + const ext = extname(file).toLowerCase(); + if (!['.md', '.markdown', '.txt', '.csv'].includes(ext)) continue; + let content = null; + for (const candidateRunId of ancestorRunIds) { + const ws = resolveInside(STORAGE.workspaceForNode({ + workflowId: run?.workflowId || 'draft', runId: candidateRunId, nodeId, + }), file); + if (!ws || !existsSync(ws) || !statSync(ws).isFile()) continue; + const realWs = realpathSync(ws); + if (resolveInside(realpathSync(dirname(realWs)), realWs) !== realWs) continue; + if (used > BUDGET) { files[`${nodeId}\u0000${file}`] = { omitted: true }; break; } + const raw = readFileSync(realWs, 'utf8'); + used += Buffer.byteLength(raw); + content = raw.length > CLIP ? { content: `${raw.slice(0, CLIP)}…`, truncated: true } : { content: raw, truncated: false }; + break; + } + if (content) files[`${nodeId}\u0000${file}`] = content; + else if (!files[`${nodeId}\u0000${file}`]) files[`${nodeId}\u0000${file}`] = { omitted: true }; + } + return json(res, 200, { files }); + } }); + // ---- 技能目录:dsh 原生 ctx.skills(skill-filesystem 发现 ~/.dsh/skills 等根)---- register({ kind: 'exact', path: '/wf1/api/skills', async handler(_req, res) { try { diff --git a/dsh-plugins/dsh-ccpg-orchestrator/test/plugin-storage.integration.test.mjs b/dsh-plugins/dsh-ccpg-orchestrator/test/plugin-storage.integration.test.mjs index 95a535c..b0dc5bb 100644 --- a/dsh-plugins/dsh-ccpg-orchestrator/test/plugin-storage.integration.test.mjs +++ b/dsh-plugins/dsh-ccpg-orchestrator/test/plugin-storage.integration.test.mjs @@ -6,6 +6,7 @@ import { tmpdir } from 'node:os'; import { DatabaseSync } from 'node:sqlite'; import { apply } from '../lib/index.js'; import { graphFingerprint } from '../lib/run-scope.js'; +import { hashedKey } from '../lib/storage-paths.js'; function responseCapture() { const listeners = new Map(); @@ -373,6 +374,20 @@ try { nodeStates: { resume_input: { status: 'success' }, resume_output: { status: 'running' } }, outputs: { resume_input: 'hello' }, structuredOutputs: {}, nodeOrder: ['resume_input', 'resume_output'], }); + // 续跑物化前置:祖先运行的可复用节点在工作区留了文件,新 run 目录初始为空 + const resumeSeedWorkspace = join( + workspaceB, '.workflow-one', 'runtime', + hashedKey('wf_resume_named'), hashedKey('run_resume_named_seed'), + 'nodes', hashedKey('resume_input'), 'workspace', + ); + mkdirSync(resumeSeedWorkspace, { recursive: true }); + writeFileSync(join(resumeSeedWorkspace, '底稿.md'), '# 祖先产物\n'); + // 同步进 seed 的 nodeStates.artifacts(真实运行里 success 节点都会带清单) + { + const seedDoc = readStoredRun(workspaceB, 'run_resume_named_seed'); + seedDoc.nodeStates.resume_input.artifacts = ['底稿.md']; + seedStoredRun(workspaceB, seedDoc); + } const namedMismatch = responseCapture(); await route('/wf1/api/runs/resume')(request('POST', withSession('/wf1/api/runs/resume', 'session-b'), { runId: 'run_resume_named_seed', graph: resumeGraph, @@ -392,6 +407,29 @@ try { assert.equal(namedResume.status, 200); assert.equal(namedResume.json().resumedFrom, 'run_resume_named_seed'); + // 续跑物化:可复用节点的祖先工作区文件必须拷进新 run 目录, + // 且 /artifact 兜底沿 resumedFrom 链也能命中(祖先目录被清后仍可读) + const resumedRunId = namedResume.json().runId; + let resumedRunDoc; + for (let attempt = 0; attempt < 60; attempt += 1) { + resumedRunDoc = readStoredRun(workspaceB, resumedRunId); + if (resumedRunDoc && resumedRunDoc.status !== 'running') break; + await new Promise((resolve) => setTimeout(resolve, 25)); + } + assert.equal(resumedRunDoc?.status, 'success', `续跑运行未完成:${resumedRunDoc?.status}`); + const resumedWorkspaceFile = join( + workspaceB, '.workflow-one', 'runtime', + hashedKey('wf_resume_named'), hashedKey(resumedRunId), + 'nodes', hashedKey('resume_input'), 'workspace', '底稿.md', + ); + assert.equal(readFileSync(resumedWorkspaceFile, 'utf8'), '# 祖先产物\n'); + const resumedArtifact = responseCapture(); + await route('/wf1/api/artifact')(request('GET', withSession( + `/wf1/api/artifact?run=${resumedRunId}&node=resume_input&file=${encodeURIComponent('底稿.md')}&preview=1`, 'session-b', + )), resumedArtifact); + assert.equal(resumedArtifact.status, 200); + assert.match(resumedArtifact.headers['Content-Type'] || '', /text\/markdown/); + const missingSession = responseCapture(); await route('/wf1/api/graph')(request('GET', '/wf1/api/graph'), missingSession); assert.equal(missingSession.status, 409); diff --git a/images/docwall-overview.jpeg b/images/docwall-overview.jpeg new file mode 100644 index 0000000..6030ff9 Binary files /dev/null and b/images/docwall-overview.jpeg differ diff --git a/images/docwall-preview.jpeg b/images/docwall-preview.jpeg new file mode 100644 index 0000000..f479221 Binary files /dev/null and b/images/docwall-preview.jpeg differ diff --git a/web/package.json b/web/package.json index 7c55bf5..aa623c7 100644 --- a/web/package.json +++ b/web/package.json @@ -7,7 +7,7 @@ "dev": "vite", "build": "vite build", "test": "npm run test:variables && npm run test:results && npm run test:scripts && npm run test:ui", - "test:variables": "node src/variables.test.mjs && node src/global-variables.test.mjs && node src/json-response.test.mjs && node src/api-base.test.mjs && node src/trial-request.test.mjs && node src/variable-scope.test.mjs && node src/run-event-routing.test.mjs && node src/workflow-serialization.test.mjs", + "test:variables": "node src/variables.test.mjs && node src/global-variables.test.mjs && node src/json-response.test.mjs && node src/api-base.test.mjs && node src/trial-request.test.mjs && node src/variable-scope.test.mjs && node src/run-event-routing.test.mjs && node src/workflow-serialization.test.mjs && node src/doc-wall-data.test.mjs", "test:results": "node src/result-adapter.test.mjs", "test:scripts": "node src/script-parameters.test.mjs", "test:ui": "node src/toolbar-responsive.test.mjs && node src/resume-workflow.test.mjs && node src/notify-node.test.mjs && node src/workflow-transfer-ui.test.mjs && node src/run-switcher.test.mjs && node src/schedule-center.test.mjs && node src/node-detail-polling.test.mjs" diff --git a/web/src/App.jsx b/web/src/App.jsx index befff8b..0bf38d0 100644 --- a/web/src/App.jsx +++ b/web/src/App.jsx @@ -36,6 +36,9 @@ import { TestRunModal } from './TestRunModal.jsx'; import { NodeDetailModal } from './NodeDetailModal.jsx'; import { VariableCenter } from './VariableCenter.jsx'; import { ScheduleCenter } from './ScheduleCenter.jsx'; +import { DocWallView } from './DocWallView.jsx'; +import { adaptRunResults, loadRunResults } from './result-adapter.js'; +import './doc-wall.css'; import { TEMPLATES, TemplateModal } from './templates.jsx'; export default function App() { @@ -46,7 +49,18 @@ export default function App() { const [selectedId, setSelectedId] = useState(null); const [selectedEdgeId, setSelectedEdgeId] = useState(null); const [logOpen, setLogOpen] = useState(true); - const [view, setView] = useState('canvas'); // 'canvas' | 'workflows' + // 视图三态(canvas | docs | workflows)+ 所看运行进 URL hash:刷新保持、可分享定位 + const initialHash = typeof window !== 'undefined' ? window.location.hash.replace(/^#/, '') : ''; + const [view, setView] = useState(() => { + const [head] = initialHash.split('/'); + return head === 'docs' || head === 'workflows' ? head : 'canvas'; + }); + useEffect(() => { + if (initialHash.startsWith('docs/')) { + const runId = initialHash.split('/')[1] || ''; + if (runId) setInspectedRunId(runId); + } + }, []); // eslint-disable-line react-hooks/exhaustive-deps const [historyOpen, setHistoryOpen] = useState(false); const [templateOpen, setTemplateOpen] = useState(false); const [currentWf, setCurrentWfRaw] = useState(null); // normalized workflow document, including id/name @@ -64,6 +78,14 @@ export default function App() { const runDetailsRef = useRef({}); useEffect(() => { runDetailsRef.current = runDetails; }, [runDetails]); const [inspectedRunId, setInspectedRunId] = useState(null); + // 文稿视图状态同步 URL hash(#docs/):换运行/换视图即写,其余视图清掉 + useEffect(() => { + const next = view === 'docs' && inspectedRunId ? `#docs/${inspectedRunId}` : ''; + if (window.location.hash !== next) { + const url = `${window.location.pathname}${window.location.search}${next}`; + window.history.replaceState(null, '', url); + } + }, [view, inspectedRunId]); const [resultsReadyByRunId, setResultsReadyByRunId] = useState({}); const [hostSession, setHostSession] = useState({ id: null, canSaveToWorkspace: false }); const terminalNodesByRunRef = useRef(new Map()); @@ -76,6 +98,11 @@ export default function App() { const [dirty, setDirty] = useState(false); const [modal, setModal] = useState(null); // { type: 'confirm'|'prompt'|'rename', ... } const [progress, setProgress] = useState({}); // nodeId → { turns, preview } + // 文稿视图:run-results 投影(磁盘事实)+ SSE 叠加(progress 实时流)。 + // 独立于 ResultPanel 的 remoteResults——两边加载时机不同,避免相互干扰。 + const [docWallResults, setDocWallResults] = useState(undefined); + const [docWallLoading, setDocWallLoading] = useState(false); + const [docWallError, setDocWallError] = useState(''); const [credOpen, setCredOpen] = useState(false); const [variableCenterOpen, setVariableCenterOpen] = useState(false); const [scheduleCenterOpen, setScheduleCenterOpen] = useState(false); @@ -326,10 +353,79 @@ export default function App() { return () => clearInterval(timer); }, [hostSession.id, refreshRunList]); + // 文稿视图数据:切到 docs 或 inspectedRunId 变化时拉 run-results(磁盘投影)。 + // 运行中先快速投影(不等 ready),run-end 后由 resultsReadyToken 触发重载拿最终产物。 + const loadDocWall = useCallback(async (runId, { waitUntilReady = false } = {}) => { + if (!runId) { setDocWallResults(undefined); return; } + setDocWallLoading(true); + setDocWallError(''); + try { + const data = await loadRunResults( + apiUrl(`/run-results?id=${encodeURIComponent(runId)}`), + { waitUntilReady }, + ); + setDocWallResults(data); + } catch (error) { + setDocWallError(error?.message || String(error)); + } finally { + setDocWallLoading(false); + } + }, []); + const [docWallVisible, setDocWallVisible] = useState(false); // 只在 docs 视图挂载时拉数据 + useEffect(() => { + setDocWallVisible(view === 'docs'); + if (view === 'docs') loadDocWall(inspectedRunId); + // 切走不撤销数据:回画布再切回 docs 保留投影,避免重复拉取 + }, [view, inspectedRunId, loadDocWall]); + // 运行结束(成果落盘)→ 文稿墙全量再投影(异常恢复原则:重新投影即可,不增量修补最终态) + const docWallReadyToken = resultsReadyByRunId[inspectedRunId] || 0; + const docWallReadyTokenRef = useRef(docWallReadyToken); + // 文稿 tab 徽标:画布视图期间任意所看运行成果新落盘(ready 晚于上次进过文稿)→ 挂圆点提示 + const [docsSeenAt, setDocsSeenAt] = useState(0); + const latestReadyAt = useMemo(() => Math.max(0, ...Object.values(resultsReadyByRunId).map(Number)), [resultsReadyByRunId]); + const docsBadge = view !== 'docs' && latestReadyAt > docsSeenAt; + useEffect(() => { + if (!docWallVisible || !inspectedRunId) return; + if (docWallReadyToken && docWallReadyToken !== docWallReadyTokenRef.current) { + docWallReadyTokenRef.current = docWallReadyToken; + loadDocWall(inspectedRunId, { waitUntilReady: true }); + } + }, [docWallReadyToken, docWallVisible, inspectedRunId, loadDocWall]); + // SSE 断线重连 → 文稿墙再投影。首次 open 不刷(数据刚拉过):距上次加载 >10s 才认定是断线恢复 + const docWallLastLoadRef = useRef(0); + useEffect(() => { if (docWallLoading) docWallLastLoadRef.current = Date.now(); }, [docWallLoading]); + useEffect(() => { + sseReconnectRef.current = () => { + if (!docWallVisible || !inspectedRunId) return; + if (Date.now() - docWallLastLoadRef.current < 10000) return; + loadDocWall(inspectedRunId); + }; + return () => { sseReconnectRef.current = null; }; + }, [docWallVisible, inspectedRunId, loadDocWall]); + // 运行中增量落卡:文稿墙可见且该运行进行中时,轻量轮询 runs/detail 刷新 nodeStates, + // 已完成节点的产物卡随跑随铺(run-end 仍由 results-ready 全量再投影兜底终态) + useEffect(() => { + if (!docWallVisible || !inspectedRunId) return undefined; + if (!runStatus.running || runStatus.runId !== inspectedRunId) return undefined; + const poll = () => { + fetch(apiUrl(`/runs/detail?id=${encodeURIComponent(inspectedRunId)}`)) + .then((response) => response.ok ? response.json() : Promise.reject(new Error('detail unavailable'))) + .then((detail) => setRunDetails((current) => ({ ...current, [inspectedRunId]: detail }))) + .catch(() => {}); + }; + const timer = setInterval(poll, 8000); + return () => clearInterval(timer); + }, [docWallVisible, inspectedRunId, runStatus.running, runStatus.runId]); + // SSE:事件 → 节点状态 + 进度 + 结构化日志 + const sseReconnectRef = useRef(null); // 重连回调(文稿墙再投影等订阅方),定义在后段 useEffect(() => { if (!canvasScopeReady) return undefined; const es = new EventSource(apiUrl('/events')); + // 断线重连:EventSource 自动重连,但断口事件已丢——通知订阅方全量再投影(文稿墙刷新) + es.addEventListener('open', () => { + if (sseReconnectRef.current) sseReconnectRef.current(); + }); // 结构化运行事件:实时运行经 ref 路由,历史检查态不参与 SSE 过滤。 const pushEntry = (entry) => { const timed = { t: Date.now(), ...entry }; @@ -446,6 +542,7 @@ export default function App() { if (!appliesToActiveRun(p)) return; runningRef.current = false; setRunStatus((s) => (s.runId === p.runId ? { ...s, running: false, last: p.status } : s)); + setProgress({}); // 文稿墙流卡随运行终止退场(落卡由 results-ready 重载投影接管) pushEntry({ kind: 'run', runId: p.runId, status: p.status, text: `运行结束:${STATUS_CN[p.status] || p.status}${p.durationMs ? ` · ${(p.durationMs / 1000).toFixed(1)}s` : ''}` }); const completedScopeEpoch = workflowScopeEpochRef.current; const hydrateRunDetail = async () => { @@ -500,6 +597,7 @@ export default function App() { if (!adopted) activeRunIdRef.current = p.runId; runningRef.current = false; setRunStatus((s) => ({ ...s, running: false, runId: p.runId || s.runId, last: 'error' })); + setProgress({}); // 启动失败的流卡同样要退场 toast(`启动失败:${p.error}`, 'error'); pushEntry({ kind: 'sys', runId: p.runId, status: 'error', text: p.error }); }); @@ -1449,6 +1547,7 @@ export default function App() { Workflow One @@ -1495,6 +1594,22 @@ export default function App() {
+ ) : view === 'docs' ? ( +
+ loadDocWall(inspectedRunId)} + onRefresh={() => loadDocWall(inspectedRunId, { waitUntilReady: false })} + onRunHere={() => { setView('canvas'); }} + recentRuns={runList.filter((run) => run.runId !== inspectedRunId)} + onInspectRun={(runId) => { setInspectedRunId(runId); loadDocWall(runId); }} + /> +
) : ( <>
, + success: , + error: , + canceled: , +}; + +function formatDuration(ms) { + if (ms == null) return ''; + return ms >= 60000 ? `${(ms / 60000).toFixed(1)} 分` : `${(ms / 1000).toFixed(1)} 秒`; +} + +/* ---------- 懒挂载包装:进入视口才挂子树,挂后保留(内容静态不卸载) ---------- */ +function LazyMount({ children, placeholderHeight = 320 }) { + const ref = useRef(null); + const [visible, setVisible] = useState(false); + useEffect(() => { + if (visible || !ref.current || typeof IntersectionObserver === 'undefined') { + if (typeof IntersectionObserver === 'undefined') setVisible(true); + return undefined; + } + const observer = new IntersectionObserver((entries) => { + if (entries.some((entry) => entry.isIntersecting)) { setVisible(true); observer.disconnect(); } + }, { root: ref.current?.closest('.docwall-strip'), rootMargin: '200px' }); + observer.observe(ref.current); + return () => observer.disconnect(); + }, [visible]); + return ( +
+ {visible ? children : null} +
+ ); +} + +/* ---------- doc 卡正文:nodeStates 产物只有文件名,正文按需获取。 + 优先消费批量预取缓存(BULK context,一次请求拉全条带),缓存未命中再单卡惰性拉取; + 拉取失败(运行被清理/历史 resume 目录已删)渲染占位,不白屏不报错 ---------- */ +const BulkContext = createContext(null); +// run 级产物清单(卡内引用互链):正文行内 code 引用的文件名命中清单即变预览链接 +const FilesContext = createContext([]); + +function useDocBody(doc) { + const bulk = useContext(BulkContext); + const cached = bulk?.get(`${doc.nodeId || ''}\u0000${doc.name}`); + const [body, setBody] = useState(() => doc.content || cached?.content || ''); + const [state, setState] = useState(() => { + if (doc.content || cached?.content) return 'ready'; + if (cached?.omitted) return 'idle'; // 批量时超预算被省略,回退单卡拉取 + return doc.downloadUrl ? 'idle' : 'missing'; + }); + const startedRef = useRef(false); + useEffect(() => { + // startedRef 防重入:状态只在完成时翻转,避免 setState 触发 effect 重跑 + // 把在途 fetch 的结果用 alive 丢弃(会永远卡在 loading) + if (state !== 'idle' || startedRef.current || !doc.downloadUrl) return undefined; + startedRef.current = true; + let alive = true; + fetch(doc.downloadUrl).then(async (res) => { + if (!res.ok) throw new Error(String(res.status)); + return res.text(); + }).then((text) => { + // 与内联 content 同一红线:卡内只渲染截断稿,全文进预览弹窗 + if (alive) { setBody(clipDocContent(text)); setState('ready'); } + }).catch(() => { + if (alive) setState('missing'); + }); + return () => { alive = false; }; + }, [state, doc.downloadUrl]); + return { state, body }; +} + +/* ---------- 文档卡(md 可读卡 / 图片 / 视频占位 / data chip 由 Strip 渲染) ---------- */ +const DocCard = memo(function DocCard({ doc, onOpen, fresh }) { + const open = () => onOpen?.(doc); + const files = useContext(FilesContext); + const { state, body: bodyText } = useDocBody(doc); + let body; + if (doc.kind === 'image') { + body = ( +
+ {doc.name} +
+ ); + } else if (doc.kind === 'video') { + // 性能红线:视频卡不挂