Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 快照)、重启恢复(触发器落盘 + 链式定时等待,含触发/跳过/停机错过统计;错过补跑可按任务开启)
Expand Down
47 changes: 47 additions & 0 deletions docs/doc-wall.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
# 文稿视图(Doc Wall)

一次工作流运行会产生几十份过程文稿——底稿、核查单、修订记录、审核意见……散落在成果面板的文件清单里根本没法读。**文稿视图**把整块画布主区切换成「文档墙」:左侧按执行顺序列出全部节点,右侧以大卡片横向铺开该节点产出的所有文稿,Markdown 直接渲染成可读排版。

![文稿视图总览](../images/docwall-overview.jpeg)

## 入口

顶部导航 **画布 | 文稿 | 工作流** 三态切换,点「文稿」即进入当前所看运行的文档墙;左侧「成果 / 过程节点」列表可随时跳转。视图状态写入 URL(`#docs/<runId>`),刷新保持、链接可分享。

## 核心能力

### 过程与成果一墙看全

- **成果带**: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` 祖先链回退
3 changes: 2 additions & 1 deletion dsh-plugins/dsh-ccpg-one/screenshots.json
Original file line number Diff line number Diff line change
@@ -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"
]
151 changes: 138 additions & 13 deletions dsh-plugins/dsh-ccpg-orchestrator/lib/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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 };
Expand All @@ -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),
Expand Down Expand Up @@ -1233,19 +1262,61 @@ 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,
});
if (maxRounds && turns > maxRounds) {
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);
};
Expand Down Expand Up @@ -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: '产物不存在' });
}
Expand All @@ -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: { "<node>\u0000<file>": { 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 {
Expand Down
Loading
Loading