dsh-data-cleaning-agent 0.9.2 → 0.9.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +10 -0
- package/README.en.md +1 -1
- package/README.md +2 -2
- package/lib/artifact-preview.js +17 -0
- package/lib/client.js +92 -15
- package/lib/image-intake.js +17 -2
- package/lib/image-workflow.js +31 -0
- package/lib/web.js +25 -3
- package/lib/workflow.js +3 -2
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -4,6 +4,16 @@
|
|
|
4
4
|
|
|
5
5
|
## [Unreleased]
|
|
6
6
|
|
|
7
|
+
## [0.9.4] - 2026-09-10
|
|
8
|
+
|
|
9
|
+
- 文件预览采用 CSV 式单行表格,长文本不再撑高整行;独立双向滚动区域固定表头与序号列,下载保持完整原值。
|
|
10
|
+
|
|
11
|
+
## [0.9.3] - 2026-09-10
|
|
12
|
+
|
|
13
|
+
- 图片向导保留独立缩略图与来源元数据,识别前显示待识别而非表格零行。
|
|
14
|
+
- 明确发送授权后,图片高层工具沿用确认版本自动完成识别、体检、匹配、补全及新 XLSX 交付;不再要求第二次发送。歧义、超限及过期配置阻断企业查询,重复调用保持幂等。
|
|
15
|
+
- 文件预览优先复用当前会话的侧栏预览 Tab,下载及无侧栏浏览器回退保持独立。
|
|
16
|
+
|
|
7
17
|
## [0.9.2] - 2026-09-10
|
|
8
18
|
|
|
9
19
|
- Better Sidebar 改为可选 peer;缺失侧栏不阻断基础安装、原生会话和本地清洗/补全/画像工具。
|
package/README.en.md
CHANGED
|
@@ -10,7 +10,7 @@ Related agents: [数据清洗补全](https://github.com/duhu2000/dsh-data-cleani
|
|
|
10
10
|
|
|
11
11
|
> A data cleaning & completion agent plugin for DeepSeek Harness: local CSV/XLSX/JSON engine plus optional Qichacha (QCC) MCP enterprise-data enrichment. Initiated and maintained by the Qichacha (QCC) team.
|
|
12
12
|
>
|
|
13
|
-
> Current source version / 当前源码版本: **0.9.
|
|
13
|
+
> Current source version / 当前源码版本: **0.9.4** (stable release)
|
|
14
14
|
|
|
15
15
|
This release unifies spreadsheet previews, column mapping, and enrichment scope across the prompt wizard and workbench. Mapping statuses use color and text; mapped fields automatically enter the scope, with optional extra fields collapsed. Multiple confirmed output columns may share a non-identity field while preserving non-empty values. It fixes saved drafts resetting the scope to five defaults and detects stale Host versions. Preparing a draft does not query QCC; sending starts execution. Fully restart DSH after upgrading. Existing download artifacts are not rewritten.
|
|
16
16
|
|
package/README.md
CHANGED
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
数据清洗补全智能体:面向 Excel/CSV/JSON 企业名单,提供数据清洗、表格清洗、清洗补全、去重、企业数据补全与字段补全,支持企查查 MCP 和结果导出。
|
|
10
10
|
|
|
11
11
|
```sh
|
|
12
|
-
dsh plugin --profile web add dsh-data-cleaning-agent@0.9.
|
|
12
|
+
dsh plugin --profile web add dsh-data-cleaning-agent@0.9.4
|
|
13
13
|
```
|
|
14
14
|
|
|
15
15
|
请先满足下文的 DSH、连接器及侧边栏依赖要求;安装后完整停止并重启对应 Profile。
|
|
@@ -27,7 +27,7 @@ dsh plugin --profile web add dsh-data-cleaning-agent@0.9.2
|
|
|
27
27
|
|
|
28
28
|
> 在 DeepSeek Harness 中清洗、补全、画像企业名单数据的智能体插件:本地 CSV/XLSX/JSON 引擎 + 可选企查查(Qichacha/QCC)MCP 企业数据补全,由企查查(Qichacha/QCC)团队发起并维护。
|
|
29
29
|
>
|
|
30
|
-
> 当前源码版本 / Current source version: **0.9.
|
|
30
|
+
> 当前源码版本 / Current source version: **0.9.4**(正式版本)
|
|
31
31
|
|
|
32
32
|
本版上传表格后展示完整清单及空白数量,用绿色、琥珀色、红色及文字区分已通过、待确认和未匹配映射。已映射字段自动纳入补全范围,额外字段默认折叠;多个原列可经确认使用同一非主体字段,分别补空并保留非空原值。修复 Host 草稿重置为默认五字段的问题,增加前后端版本错配提示。生成说明不查询,发送才执行;升级后需完整重启 DSH,历史下载文件不自动改写。
|
|
33
33
|
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
// Read-only CSV-style preview: preserve values, but never wrap long cells.
|
|
2
|
+
export function renderArtifactPreview({ fileName, taskId, rowCount, columns, rows, download }) {
|
|
3
|
+
const escape = value => String(value ?? '').replace(/[&<>"]/g, c => ({ '&': '&', '<': '<', '>': '>', '"': '"' }[c]));
|
|
4
|
+
return `<!doctype html><html lang="zh-CN"><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>${escape(fileName)}</title><style>
|
|
5
|
+
*{box-sizing:border-box}
|
|
6
|
+
html,body{height:100%;margin:0;overflow:hidden}
|
|
7
|
+
body{font:14px system-ui;color:#243044;display:flex;flex-direction:column;padding:16px;gap:12px}
|
|
8
|
+
header{flex:none;min-width:0}h1{font-size:18px;margin:0 0 8px;overflow-wrap:anywhere}p{margin:0 0 8px;color:#66758b;font-size:12px;overflow-wrap:anywhere}a{color:#0875ce}
|
|
9
|
+
.grid{flex:1;min-height:0;min-width:0;overflow:auto;scrollbar-gutter:stable;border:1px solid #dde5ef;border-radius:6px}
|
|
10
|
+
table{border-collapse:separate;border-spacing:0;table-layout:auto;width:max-content;min-width:100%}
|
|
11
|
+
td,th{white-space:nowrap;word-break:normal;overflow-wrap:normal;vertical-align:middle;min-width:100px;height:34px;padding:7px 12px;border-right:1px solid #dde5ef;border-bottom:1px solid #dde5ef;text-align:left;line-height:20px}
|
|
12
|
+
thead th{position:sticky;top:0;z-index:2;background:#e5f3ff;box-shadow:0 1px 0 #c7d8e8}
|
|
13
|
+
tbody tr:nth-child(even){background:#f8fafc}tbody tr:hover{background:#edf6ff}
|
|
14
|
+
.row-number{min-width:48px;text-align:right;color:#66758b;background:#f3f7fb;position:sticky;left:0;z-index:1}thead .row-number{z-index:3;background:#e5f3ff}
|
|
15
|
+
.grid:focus-visible{outline:2px solid #0875ce;outline-offset:1px}
|
|
16
|
+
</style><header><h1>${escape(fileName)}</h1><p>任务 ${escape(taskId)} · 共 ${escape(rowCount)} 行 / ${columns.length} 列,当前预览 ${rows.length} 行(最多 50 行);源文件未修改。左右滚动查看全部字段,表头固定。</p><a href="${escape(download)}">下载完整 Excel</a></header><div class="grid" role="region" aria-label="结果数据表格,可横向和纵向滚动" tabindex="0"><table><thead><tr><th class="row-number" scope="col">序号</th>${columns.map(c => `<th scope="col">${escape(c)}</th>`).join('')}</tr></thead><tbody>${rows.map((row, i) => `<tr><th class="row-number" scope="row">${i + 1}</th>${columns.map(c => `<td>${escape(row[c])}</td>`).join('')}</tr>`).join('')}</tbody></table></div></html>`;
|
|
17
|
+
}
|
package/lib/client.js
CHANGED
|
@@ -1190,7 +1190,7 @@ body:has(.dcAgentPromptBackdrop) { overflow: hidden; }
|
|
|
1190
1190
|
const entryCount = Number.isFinite(config.entryCount) ? config.entryCount : entries.length;
|
|
1191
1191
|
const source = mode === 'image'
|
|
1192
1192
|
? config.imageState === 'completed'
|
|
1193
|
-
? `图片${config.fileName ? `「${config.fileName}」` : ''}
|
|
1193
|
+
? `图片${config.fileName ? `「${config.fileName}」` : ''},已由企查查智能文档解析识别 ${entryCount} 条主体标识`
|
|
1194
1194
|
: `图片${config.fileName ? `「${config.fileName}」` : ''},已安全暂存在本机 Host,等待企查查智能文档解析识别`
|
|
1195
1195
|
: mode === 'excel'
|
|
1196
1196
|
? `本地表格${config.fileName ? `「${config.fileName}」` : ''},已解析 ${entryCount} 条主体标识,完整数据已载入数据清洗补全工作台`
|
|
@@ -1200,7 +1200,7 @@ body:has(.dcAgentPromptBackdrop) { overflow: hidden; }
|
|
|
1200
1200
|
`输入来源:${source}。`,
|
|
1201
1201
|
];
|
|
1202
1202
|
if (mode === 'image' && config.imageCommandId && config.imageState !== 'completed') {
|
|
1203
|
-
lines.push('
|
|
1203
|
+
lines.push('本次发送即授权使用本人企查查账号额度,按向导已确认范围执行图片识别、质量体检、主体匹配和字段补全,生成新 Excel。识别后自动沿用已选规则与字段,不重复确认或要求二次发送;仅识别歧义、多候选或权限异常时暂停,模糊字符不得猜测。');
|
|
1204
1204
|
lines.push(`安全图片凭证:${config.imageCommandId}。请只调用一次 data_cleaning_extract_image_companies,参数只传 commandId;该高层工具会在 Host 内调用 qcc-document-mcp 的 parse_document / get_parse_result,不要直接调用底层 mcp__qcc-document* 工具。`);
|
|
1205
1205
|
}
|
|
1206
1206
|
if (entries.length) {
|
|
@@ -1486,7 +1486,7 @@ body:has(.dcAgentPromptBackdrop) { overflow: hidden; }
|
|
|
1486
1486
|
const base = task.source?.fileName?.replace(/\.[^.]+$/, '') || (task.source?.type === 'image' ? '图片名单' : '粘贴名单');
|
|
1487
1487
|
const date = task.createdAt ? new Date(task.createdAt) : null;
|
|
1488
1488
|
const time = date && Number.isFinite(date.getTime()) ? date.toLocaleString() : '';
|
|
1489
|
-
return `${base}|${task.source?.rowCount ?? 0}
|
|
1489
|
+
return `${base}|${task.source?.type === 'image' && task.state === 'draft' ? '待识别' : `${task.source?.rowCount ?? 0} 条`}${time ? '|' + time : ''}`;
|
|
1490
1490
|
}
|
|
1491
1491
|
function cacheWorkflowTask(actions, sessionId, task) {
|
|
1492
1492
|
if (!task) return null;
|
|
@@ -1511,6 +1511,7 @@ body:has(.dcAgentPromptBackdrop) { overflow: hidden; }
|
|
|
1511
1511
|
fieldSelection: draft.fieldSelection || DEFAULT_ENRICHMENT_KEYS,
|
|
1512
1512
|
mappings: draft.mappings || [],
|
|
1513
1513
|
matchRules: draft.matchRules,
|
|
1514
|
+
...(draft.source?.type === 'image' ? { source: draft.source } : {}),
|
|
1514
1515
|
});
|
|
1515
1516
|
workflowTaskCreationBySession.set(key, pending);
|
|
1516
1517
|
const clearPending = () => {
|
|
@@ -1590,6 +1591,13 @@ body:has(.dcAgentPromptBackdrop) { overflow: hidden; }
|
|
|
1590
1591
|
const host = await requestJson('/data-cleaning/api/g5/capabilities');
|
|
1591
1592
|
if (host?.workflowExecutionVersion !== 1) throw new Error('页面与 Host 版本不一致。请保存任务后升级并完整重启 DSH;当前 Host 不支持自动生成结果文件。');
|
|
1592
1593
|
let task = await ensureWorkflowTask(actions, sessionId);
|
|
1594
|
+
if (expected?.mode === 'image' && expected.imageState === 'prepared') {
|
|
1595
|
+
if (JSON.stringify([...(task.fieldSelection || [])].sort()) !== JSON.stringify([...(expected.enrichmentKeys || [])].sort())) throw new Error('图片任务字段配置尚未保存,请重试。');
|
|
1596
|
+
const bound = await requestJson(`/data-cleaning/api/images/commands/${encodeURIComponent(expected.imageCommandId)}`, 'PATCH', {
|
|
1597
|
+
taskId: task.id, expectedRevision: task.revision, executeOnSend: true,
|
|
1598
|
+
});
|
|
1599
|
+
return { ...bound.command, prompt: buildTaskPrompt(expected) };
|
|
1600
|
+
}
|
|
1593
1601
|
if (expected) {
|
|
1594
1602
|
const sameSet = (a, b) => JSON.stringify([...(a || [])].sort()) === JSON.stringify([...(b || [])].sort());
|
|
1595
1603
|
if (!sameSet(task.fieldSelection, expected.enrichmentKeys)
|
|
@@ -2438,6 +2446,8 @@ body:has(.dcAgentPromptBackdrop) { overflow: hidden; }
|
|
|
2438
2446
|
const [imageFileName, setImageFileName] = react.useState('');
|
|
2439
2447
|
const [imageSizeBytes, setImageSizeBytes] = react.useState(0);
|
|
2440
2448
|
const [imageAttachment, setImageAttachment] = react.useState(null);
|
|
2449
|
+
// 独立预览不依赖原生附件的 blob URL;发送前移除附件可能撤销该 URL。
|
|
2450
|
+
const [imagePreviewUrl, setImagePreviewUrl] = react.useState('');
|
|
2441
2451
|
const [imageCommand, setImageCommand] = react.useState(null);
|
|
2442
2452
|
const [imageDropActive, setImageDropActive] = react.useState(false);
|
|
2443
2453
|
const [imageLightbox, setImageLightbox] = react.useState(false);
|
|
@@ -2466,6 +2476,28 @@ body:has(.dcAgentPromptBackdrop) { overflow: hidden; }
|
|
|
2466
2476
|
const command = response?.command;
|
|
2467
2477
|
if (!command) return;
|
|
2468
2478
|
setImageCommand((current) => current?.commandId === commandId ? { ...current, ...command } : current);
|
|
2479
|
+
if (command.taskId) {
|
|
2480
|
+
const actions = sessionWorkbenchController?.actionsFor(sessionId);
|
|
2481
|
+
if (actions) {
|
|
2482
|
+
await refreshHostedTask(actions, sessionId, command.taskId, null, true);
|
|
2483
|
+
if (command.state === 'running') requestWorkbenchOpen(command.qccCommand ? 'match' : 'upload', sessionId);
|
|
2484
|
+
if (command.qccCommand) {
|
|
2485
|
+
const status = await requestJson(`/data-cleaning/api/g5/commands/${encodeURIComponent(command.qccCommand.commandId)}`);
|
|
2486
|
+
actions.setCommandProgress?.({ taskId: command.taskId, ...(status.command?.progress || {}) });
|
|
2487
|
+
}
|
|
2488
|
+
}
|
|
2489
|
+
if (command.state === 'completed') {
|
|
2490
|
+
const recognized = command.result?.entries || [];
|
|
2491
|
+
setEntries(recognized); setEntryCount(recognized.length);
|
|
2492
|
+
const result = entriesToDataset(recognized);
|
|
2493
|
+
const runtime = runtimeFor(command.taskId);
|
|
2494
|
+
runtime.rows = result.rows; runtime.sourceRows = result.rows; runtime.headers = result.headers;
|
|
2495
|
+
runtime.source = { type: 'image', fileName: imageFileName, sizeBytes: imageSizeBytes };
|
|
2496
|
+
if (actions) actions.setDataset(result);
|
|
2497
|
+
setOpen(false);
|
|
2498
|
+
return;
|
|
2499
|
+
}
|
|
2500
|
+
}
|
|
2469
2501
|
if (command.state === 'completed') {
|
|
2470
2502
|
const recognized = Array.isArray(command.result?.entries) ? command.result.entries : [];
|
|
2471
2503
|
setEntries(recognized);
|
|
@@ -2504,6 +2536,7 @@ body:has(.dcAgentPromptBackdrop) { overflow: hidden; }
|
|
|
2504
2536
|
requestJson(`/data-cleaning/api/images/commands/${encodeURIComponent(imageCommand.commandId)}`, 'DELETE').catch(() => {});
|
|
2505
2537
|
}
|
|
2506
2538
|
setImageAttachment(null);
|
|
2539
|
+
setImagePreviewUrl('');
|
|
2507
2540
|
setImageCommand(null);
|
|
2508
2541
|
setImageFileName('');
|
|
2509
2542
|
setImageSizeBytes(0);
|
|
@@ -2536,11 +2569,13 @@ body:has(.dcAgentPromptBackdrop) { overflow: hidden; }
|
|
|
2536
2569
|
attached = Array.isArray(images) ? images[0] : null;
|
|
2537
2570
|
if (!attached?.id) throw new Error('图片未能加入当前对话。');
|
|
2538
2571
|
const staged = await stagePromptImage(file);
|
|
2572
|
+
const previewUrl = `data:${file.type};base64,${await imageFileBase64(file)}`;
|
|
2539
2573
|
if (imageAttachment && typeof removeImage === 'function') removeImage(sessionId, imageAttachment);
|
|
2540
2574
|
if (imageCommand?.commandId) {
|
|
2541
2575
|
requestJson(`/data-cleaning/api/images/commands/${encodeURIComponent(imageCommand.commandId)}`, 'DELETE').catch(() => {});
|
|
2542
2576
|
}
|
|
2543
2577
|
setImageAttachment(attached);
|
|
2578
|
+
setImagePreviewUrl(previewUrl);
|
|
2544
2579
|
setImageFileName(file.name);
|
|
2545
2580
|
setImageSizeBytes(Number(file.size ?? 0));
|
|
2546
2581
|
setImageCommand(staged.command);
|
|
@@ -2650,9 +2685,10 @@ body:has(.dcAgentPromptBackdrop) { overflow: hidden; }
|
|
|
2650
2685
|
setBusy(true);
|
|
2651
2686
|
try {
|
|
2652
2687
|
let prompt = buildTaskPrompt(config);
|
|
2653
|
-
if (mode !== 'image' ||
|
|
2688
|
+
if (mode !== 'image' || ['prepared', 'completed'].includes(imageCommand?.state)) {
|
|
2654
2689
|
const command = await requestPreparedWorkflow(sessionId, config);
|
|
2655
|
-
prompt = command.prompt + '\n\n' + buildTaskPrompt(config);
|
|
2690
|
+
prompt = mode === 'image' && imageCommand?.state === 'prepared' ? command.prompt : command.prompt + '\n\n' + buildTaskPrompt(config);
|
|
2691
|
+
if (mode === 'image' && imageCommand?.state === 'prepared') setImageCommand(command);
|
|
2656
2692
|
}
|
|
2657
2693
|
inputActions.setDraft(prompt);
|
|
2658
2694
|
const updatedInput = getInputSnapshot?.(sessionId);
|
|
@@ -2840,16 +2876,16 @@ body:has(.dcAgentPromptBackdrop) { overflow: hidden; }
|
|
|
2840
2876
|
},
|
|
2841
2877
|
},
|
|
2842
2878
|
h('p', null, '可直接在此粘贴图片,或拖入 / 选择 PNG、JPEG、WebP。图片会在对话框显示缩略图,点击可放大。'),
|
|
2843
|
-
|
|
2879
|
+
imagePreviewUrl ? h('div', { className: 'dcAgentPromptImagePreview' },
|
|
2844
2880
|
h('button', {
|
|
2845
2881
|
type: 'button', className: 'dcAgentPromptImageThumb',
|
|
2846
2882
|
'aria-label': `放大查看 ${imageFileName}`,
|
|
2847
2883
|
onClick: () => setImageLightbox(true),
|
|
2848
|
-
},
|
|
2884
|
+
}, h('img', { src: imagePreviewUrl, alt: imageFileName })),
|
|
2849
2885
|
h('div', { className: 'dcAgentPromptImageMeta' },
|
|
2850
2886
|
h('b', null, imageFileName),
|
|
2851
2887
|
h('small', null, imageCommand?.state === 'completed'
|
|
2852
|
-
? `已识别 ${entryCount} 条,下一步逐条核验`
|
|
2888
|
+
? imageCommand.result?.workflowResult ? `已识别 ${entryCount} 条,任务结果见右侧工作台` : `已识别 ${entryCount} 条,下一步逐条核验`
|
|
2853
2889
|
: imageCommand?.state === 'running'
|
|
2854
2890
|
? '识别中…'
|
|
2855
2891
|
: imageCommand?.state === 'failed'
|
|
@@ -2962,8 +2998,8 @@ body:has(.dcAgentPromptBackdrop) { overflow: hidden; }
|
|
|
2962
2998
|
: wizardStep === 4 ? '回填到对话框' : '下一步'),
|
|
2963
2999
|
),
|
|
2964
3000
|
) : null,
|
|
2965
|
-
imageLightbox &&
|
|
2966
|
-
src:
|
|
3001
|
+
imageLightbox && imagePreviewUrl ? h(ImagePreview, {
|
|
3002
|
+
src: imagePreviewUrl, alt: imageFileName, sessionId, onClose: () => setImageLightbox(false),
|
|
2967
3003
|
}) : null,
|
|
2968
3004
|
);
|
|
2969
3005
|
}
|
|
@@ -3026,7 +3062,7 @@ body:has(.dcAgentPromptBackdrop) { overflow: hidden; }
|
|
|
3026
3062
|
),
|
|
3027
3063
|
meta.hint ? h('div', { className: 'dcAgentToolCardHint' }, meta.hint) : null,
|
|
3028
3064
|
summary ? h('div', { className: 'dcAgentToolCardBody' }, summary) : null,
|
|
3029
|
-
|
|
3065
|
+
['data_cleaning_qcc_run', 'data_cleaning_extract_image_companies'].includes(toolName) ? h('div', { className: 'dcAgentArtifactList' },
|
|
3030
3066
|
[...summary.matchAll(/\[([^\]]+)\]\(((?:https?:\/\/[a-zA-Z0-9.:[\]-]+)?\/data-cleaning\/api\/workflow\/tasks\/[a-zA-Z0-9%_-]+\/artifacts\/[a-zA-Z0-9%_-]+(?:\?preview=1)?)\)/g)].filter(match => match[2].startsWith('/') || (typeof location !== 'undefined' && new URL(match[2]).origin === location.origin)).map((match, index) =>
|
|
3031
3067
|
h('a', { key: index, className: 'dcAgentButton', href: match[2], target: '_blank', rel: 'noopener noreferrer' }, match[1]))) : null,
|
|
3032
3068
|
);
|
|
@@ -4174,17 +4210,17 @@ body:has(.dcAgentPromptBackdrop) { overflow: hidden; }
|
|
|
4174
4210
|
) : null,
|
|
4175
4211
|
h('div', { className: 'dcAgentWbBody' },
|
|
4176
4212
|
cachedTask && step !== 'history' ? h('section', { className: 'dcAgentSection', 'aria-label': '当前任务进展', 'aria-live': 'polite' },
|
|
4177
|
-
h('b', null, WORKFLOW_STATE_LABELS[cachedTask.state] || cachedTask.state),
|
|
4213
|
+
h('b', null, cachedTask.source?.type === 'image' && cachedTask.state === 'draft' ? '图片已暂存,待识别' : WORKFLOW_STATE_LABELS[cachedTask.state] || cachedTask.state),
|
|
4178
4214
|
h('h3', null, workflowDisplayName(cachedTask)),
|
|
4179
4215
|
h('details', null, h('summary', null, '任务详情'), h('p', { className: 'dcAgentHint' }, `任务编号:${cachedTask.id}`)),
|
|
4180
|
-
h('p', null, `${cachedTask.source?.type === 'image' ? '图片识别' : cachedTask.source?.type === 'text' ? '粘贴名单' : '上传表格'
|
|
4216
|
+
h('p', null, `${cachedTask.source?.type === 'image' ? '图片识别' : cachedTask.source?.type === 'text' ? '粘贴名单' : cachedTask.source ? '上传表格' : '待选择数据来源'}${cachedTask.source?.fileName ? ' · ' + cachedTask.source.fileName : ''} · ${cachedTask.source?.type === 'image' && cachedTask.state === 'draft' ? '条目数待识别' : `${cachedTask.source?.rowCount || 0} 条`} · ${cachedTask.fieldSelection?.length || 0} 个补全字段`),
|
|
4181
4217
|
commandProgress?.taskId === cachedTask.id && Number.isFinite(commandProgress.totalUnique) ? h('p', null, `已处理主体 ${commandProgress.completedUnique}/${commandProgress.totalUnique}(去重后,含未匹配或失败,不代表补全成功)`) : null,
|
|
4182
4218
|
commandProgress?.taskId === cachedTask.id && Number.isFinite(commandProgress.totalUnique) && commandProgress.totalUnique > 0 ? h('progress', {
|
|
4183
4219
|
max: commandProgress.totalUnique, value: Math.min(commandProgress.totalUnique, Math.max(0, commandProgress.completedUnique || 0)),
|
|
4184
4220
|
'aria-label': '主体处理进度', style: { width: '100%', accentColor: cachedTask.state === 'completed' ? '#16845b' : cachedTask.state === 'failed' ? '#c62828' : cachedTask.state === 'partial' ? '#a66a00' : '#087dcc' },
|
|
4185
4221
|
}) : null,
|
|
4186
4222
|
h('p', { style: { color: qccFailedCount > 0 ? '#c62828' : Number(qccReviewCount) > 0 ? '#a66a00' : qccEnrichedCount > 0 ? '#16845b' : 'inherit' } },
|
|
4187
|
-
Number.isFinite(qccEnrichedCount) ? `已处理并补全 ${qccEnrichedCount} 条 · 待核验 ${Number.isFinite(qccReviewCount) ? qccReviewCount : '统计中'} 条 · 失败 ${qccFailedCount} 条(按输入行统计;未披露字段保留空白)` : '结果统计中,处理结束后展示补全与待核验数量'),
|
|
4223
|
+
cachedTask.source?.type === 'image' && cachedTask.state === 'draft' ? '请发送对话中的任务说明开始图片识别,识别后核验企业名单。' : Number.isFinite(qccEnrichedCount) ? `已处理并补全 ${qccEnrichedCount} 条 · 待核验 ${Number.isFinite(qccReviewCount) ? qccReviewCount : '统计中'} 条 · 失败 ${qccFailedCount} 条(按输入行统计;未披露字段保留空白)` : '结果统计中,处理结束后展示补全与待核验数量'),
|
|
4188
4224
|
) : null,
|
|
4189
4225
|
error ? h('div', { className: 'dcAgentError', role: 'alert' }, error) : null,
|
|
4190
4226
|
step !== 'history' ? h('details', { className: 'dcAgentSection' },
|
|
@@ -4320,6 +4356,38 @@ body:has(.dcAgentPromptBackdrop) { overflow: hidden; }
|
|
|
4320
4356
|
}
|
|
4321
4357
|
};
|
|
4322
4358
|
let adapter;
|
|
4359
|
+
let previewService = null;
|
|
4360
|
+
const previewTabId = 'dsh-data-cleaning-agent:artifact-preview';
|
|
4361
|
+
const previewUrls = new Map();
|
|
4362
|
+
const previewListeners = new Set();
|
|
4363
|
+
function ArtifactPreviewTab(props) {
|
|
4364
|
+
const url = react.useSyncExternalStore(listener => {
|
|
4365
|
+
previewListeners.add(listener);
|
|
4366
|
+
return () => previewListeners.delete(listener);
|
|
4367
|
+
}, () => previewUrls.get(props.scope.sessionId) || '', () => '');
|
|
4368
|
+
return url ? h('iframe', { src: url, title: '清洗结果 Excel 预览',
|
|
4369
|
+
sandbox: 'allow-same-origin allow-downloads',
|
|
4370
|
+
style: { display: 'block', width: '100%', height: '100%', minHeight: '320px', border: 0 } })
|
|
4371
|
+
: h('p', null, '请点击清洗结果文件的「预览 / 打开」。');
|
|
4372
|
+
}
|
|
4373
|
+
const previewClick = event => {
|
|
4374
|
+
if (disposed || event.defaultPrevented || event.button !== 0 || event.ctrlKey || event.metaKey || event.shiftKey || event.altKey) return;
|
|
4375
|
+
const anchor = event.target?.closest?.('a[href]');
|
|
4376
|
+
if (!anchor || !previewService) return;
|
|
4377
|
+
let url;
|
|
4378
|
+
try { url = new URL(anchor.href, location.href); } catch { return; }
|
|
4379
|
+
if (url.origin !== location.origin || url.search !== '?preview=1'
|
|
4380
|
+
|| !/^\/data-cleaning\/api\/workflow\/tasks\/dcw-[a-zA-Z0-9-]+\/artifacts\/dca-[a-zA-Z0-9-]+$/.test(url.pathname)) return;
|
|
4381
|
+
const sessionId = previewService.getSnapshot().sessionId;
|
|
4382
|
+
if (!sessionId || !previewService.isTabEnabled(previewTabId)) return;
|
|
4383
|
+
previewUrls.set(sessionId, url.pathname + url.search);
|
|
4384
|
+
try { previewService.openTab({ type: previewTabId }, { sessionId }); }
|
|
4385
|
+
catch { return; } // Native browser link remains usable if the provider cannot open.
|
|
4386
|
+
event.preventDefault();
|
|
4387
|
+
event.stopPropagation();
|
|
4388
|
+
for (const listener of previewListeners) listener();
|
|
4389
|
+
};
|
|
4390
|
+
host.addEventListener?.('click', previewClick, true);
|
|
4323
4391
|
function SessionWorkbenchTab(props) {
|
|
4324
4392
|
const instance = instanceFor(props.scope.sessionId);
|
|
4325
4393
|
const useStore = pick => react.useSyncExternalStore(instance.subscribe,
|
|
@@ -4340,7 +4408,14 @@ body:has(.dcAgentPromptBackdrop) { overflow: hidden; }
|
|
|
4340
4408
|
const next = createCleaningSidebarAdapter(service, adapterOptions);
|
|
4341
4409
|
adapter.dispose();
|
|
4342
4410
|
adapter = next;
|
|
4411
|
+
const unregisterPreview = next.compatible ? service.registerTab({
|
|
4412
|
+
id: previewTabId, title: '清洗结果预览', order: 31, single: true,
|
|
4413
|
+
icon: size => h(DatabaseLogo, { size }), component: ArtifactPreviewTab,
|
|
4414
|
+
}) : () => {};
|
|
4415
|
+
previewService = next.compatible ? service : null;
|
|
4343
4416
|
return () => {
|
|
4417
|
+
unregisterPreview();
|
|
4418
|
+
if (previewService === service) previewService = null;
|
|
4344
4419
|
next.dispose();
|
|
4345
4420
|
if (!disposed && adapter === next) adapter = createCleaningSidebarAdapter(null, adapterOptions);
|
|
4346
4421
|
};
|
|
@@ -4401,7 +4476,7 @@ body:has(.dcAgentPromptBackdrop) { overflow: hidden; }
|
|
|
4401
4476
|
const { sessionId, config, resolve, reject } = event.detail;
|
|
4402
4477
|
const actions = controller.actionsFor(sessionId);
|
|
4403
4478
|
queueWorkflowOperation(sessionId, () => stageConfirmedWorkflow(actions, sessionId, config))
|
|
4404
|
-
.then(command => { if (!disposed) watchHostedCommand(actions, sessionId, command); resolve(command); }, reject);
|
|
4479
|
+
.then(command => { if (!disposed && command.commandId?.startsWith('dcq-')) watchHostedCommand(actions, sessionId, command); resolve(command); }, reject);
|
|
4405
4480
|
},
|
|
4406
4481
|
};
|
|
4407
4482
|
const bindings = Object.entries(listeners).map(([name, handler]) => {
|
|
@@ -4418,6 +4493,8 @@ body:has(.dcAgentPromptBackdrop) { overflow: hidden; }
|
|
|
4418
4493
|
disposed = true;
|
|
4419
4494
|
commandWatchGeneration += 1;
|
|
4420
4495
|
for (const remove of bindings) remove();
|
|
4496
|
+
host.removeEventListener?.('click', previewClick, true);
|
|
4497
|
+
previewUrls.clear(); previewListeners.clear();
|
|
4421
4498
|
adapter.dispose();
|
|
4422
4499
|
disconnectFixture();
|
|
4423
4500
|
void providerFiber?.dispose();
|
package/lib/image-intake.js
CHANGED
|
@@ -260,11 +260,16 @@ function publicRecord(record, now = Date.now()) {
|
|
|
260
260
|
updatedAt: record.updatedAt,
|
|
261
261
|
expiresInMs: Math.max(0, record.expiresAt - now),
|
|
262
262
|
result: record.result,
|
|
263
|
+
taskId: record.workflow?.taskId,
|
|
264
|
+
qccCommand: record.qccCommand,
|
|
263
265
|
error: record.error,
|
|
264
266
|
});
|
|
265
267
|
}
|
|
266
268
|
|
|
267
269
|
function safeFailure(error) {
|
|
270
|
+
if (['WorkflowError', 'QccBridgeError'].includes(error?.name)) {
|
|
271
|
+
return { code: error.code, message: error.message };
|
|
272
|
+
}
|
|
268
273
|
if (error instanceof ImageIntakeError && error.code !== 'DC_IMAGE_PROVIDER_FAILED') {
|
|
269
274
|
return { code: error.code, message: error.message };
|
|
270
275
|
}
|
|
@@ -281,6 +286,7 @@ export class ImageIntakeStore {
|
|
|
281
286
|
ttlMs = IMAGE_LIMITS.ttlMs,
|
|
282
287
|
pollMs = 500,
|
|
283
288
|
maxPolls = 60,
|
|
289
|
+
continueWorkflow = null,
|
|
284
290
|
} = {}) {
|
|
285
291
|
if (!tools || typeof tools.get !== 'function' || typeof tools.execute !== 'function') {
|
|
286
292
|
throw new TypeError('ImageIntakeStore requires ctx.tools get/execute');
|
|
@@ -291,6 +297,7 @@ export class ImageIntakeStore {
|
|
|
291
297
|
this.pollMs = pollMs;
|
|
292
298
|
this.maxPolls = maxPolls;
|
|
293
299
|
this.records = new Map();
|
|
300
|
+
this.continueWorkflow = continueWorkflow;
|
|
294
301
|
}
|
|
295
302
|
|
|
296
303
|
toolDefinition(name) {
|
|
@@ -506,7 +513,11 @@ export class ImageIntakeStore {
|
|
|
506
513
|
entries,
|
|
507
514
|
entryCount: entries.length,
|
|
508
515
|
truncated: entries.length >= IMAGE_LIMITS.maxEntries,
|
|
516
|
+
needsReview: /[�??]|无法辨认|识别不清|待确认/.test(text),
|
|
509
517
|
};
|
|
518
|
+
if (record.workflow && this.continueWorkflow) {
|
|
519
|
+
record.result.workflowResult = await this.continueWorkflow(record, record.result, exec);
|
|
520
|
+
}
|
|
510
521
|
record.state = 'completed';
|
|
511
522
|
record.error = null;
|
|
512
523
|
record.updatedAt = new Date(this.clock()).toISOString();
|
|
@@ -559,7 +570,7 @@ export function serializeImageExtractionPrompt(command) {
|
|
|
559
570
|
export function registerImageIntakeTool(tools, store) {
|
|
560
571
|
return tools.register({
|
|
561
572
|
name: TOOL_IMAGE_EXTRACT,
|
|
562
|
-
description: '
|
|
573
|
+
description: 'Execute one staged company-list image task using a visible dci-* commandId. The Host parses the image once. If the wizard bound an explicitly confirmed execute-on-send workflow, the same Agent-owned call continues quality checks, entity matching, selected-field enrichment and XLSX delivery. Ambiguous recognition or entity candidates require review; never call underlying MCP tools separately or repeat the task.',
|
|
563
574
|
parameters: {
|
|
564
575
|
type: 'object',
|
|
565
576
|
additionalProperties: false,
|
|
@@ -575,12 +586,16 @@ export function registerImageIntakeTool(tools, store) {
|
|
|
575
586
|
entries: { type: 'array', items: { type: 'string' } },
|
|
576
587
|
entryCount: { type: 'integer' },
|
|
577
588
|
truncated: { type: 'boolean' },
|
|
589
|
+
needsReview: { type: 'boolean' },
|
|
590
|
+
workflowResult: { type: 'object', additionalProperties: true },
|
|
578
591
|
},
|
|
579
592
|
required: ['commandId', 'entries', 'entryCount', 'truncated'],
|
|
580
593
|
},
|
|
581
594
|
render: (_args, value) => [{
|
|
582
595
|
type: 'text',
|
|
583
|
-
text:
|
|
596
|
+
text: value.workflowResult
|
|
597
|
+
? `图片识别及已确认任务已执行,识别 ${value.entryCount} 条。真实执行统计:${JSON.stringify(value.workflowResult)}。${value.workflowResult.reviewReason || '无需重复确认规则或再次发送;仅按真实异常提示处理。'}\n${(value.workflowResult.artifacts || []).map(a => `${String(a.fileName).replace(/[^a-zA-Z0-9\u4e00-\u9fff._ -]/g, '_')}:[预览 / 打开](${a.url}?preview=1) · [下载 Excel](${a.url})`).join('\n')}`
|
|
598
|
+
: `图片已识别 ${value.entryCount} 条,等待核验。此图片未绑定已确认的补全任务,请在「导入与核验」核验后配置后续处理;识别名单不代表已匹配。`,
|
|
584
599
|
}],
|
|
585
600
|
},
|
|
586
601
|
async execute(args, exec) {
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import { WorkflowError } from './workflow.js';
|
|
2
|
+
|
|
3
|
+
// The wizard binds its confirmed revision. Execution reuses the original Agent token.
|
|
4
|
+
export function createImageWorkflow({ getWorkflow, execution, commands }) {
|
|
5
|
+
return async (record, result, exec) => {
|
|
6
|
+
const store = await getWorkflow();
|
|
7
|
+
let task = await store.require(record.workflow.taskId);
|
|
8
|
+
if (task.revision !== record.workflow.revision || task.state !== 'draft') {
|
|
9
|
+
throw new WorkflowError('DC_WORKFLOW_REVISION_CONFLICT', '图片任务配置已变更,请重新生成说明;未执行企业查询。', 409);
|
|
10
|
+
}
|
|
11
|
+
const rows = result.entries.map(value => ({ 主体标识: value }));
|
|
12
|
+
const mappings = [{ sourceField: '主体标识', targetField: 'company_name' }];
|
|
13
|
+
task = await store.recordUpload(task.id, { expectedRevision: task.revision,
|
|
14
|
+
source: { type: 'image', fileName: record.fileName, sizeBytes: record.sizeBytes,
|
|
15
|
+
rowCount: rows.length, columnCount: 1, headers: ['主体标识'] } });
|
|
16
|
+
task = await store.updateDraft(task.id, { expectedRevision: task.revision, mappings });
|
|
17
|
+
if (result.truncated || result.needsReview || rows.length > 100) {
|
|
18
|
+
return { taskId: task.id, deliveryState: 'review_required', artifacts: [],
|
|
19
|
+
reviewReason: rows.length > 100 || result.truncated ? '识别名单超出单批范围,请核验并拆分。' : '识别文本含模糊字符或待确认提示,请核验名单。' };
|
|
20
|
+
}
|
|
21
|
+
task = await store.confirmRules(task.id, { expectedRevision: task.revision, mappings });
|
|
22
|
+
task = await store.recordQuality(task.id, { expectedRevision: task.revision,
|
|
23
|
+
summary: { total: rows.length, valid: rows.length, missingAnchor: 0,
|
|
24
|
+
duplicates: rows.length - new Set(result.entries).size } });
|
|
25
|
+
const payload = await execution.prepare({ taskId: task.id, expectedRevision: task.revision,
|
|
26
|
+
kind: 'enrich', rows, headers: ['主体标识'], nameField: '主体标识' });
|
|
27
|
+
const command = commands.prepare(payload, { artifactOrigin: record.workflow.artifactOrigin });
|
|
28
|
+
record.qccCommand = { commandId: command.commandId, taskId: task.id };
|
|
29
|
+
return commands.run(command.commandId, exec);
|
|
30
|
+
};
|
|
31
|
+
}
|
package/lib/web.js
CHANGED
|
@@ -16,7 +16,9 @@ import { PHASE3_BATCH_LIMITS, Phase3BatchService, Phase3RunStore } from './qcc-p
|
|
|
16
16
|
import { publicWorkflowContract } from './workflow-contract.js';
|
|
17
17
|
import { DataCleaningWorkflowStore, WorkflowError } from './workflow.js';
|
|
18
18
|
import { ArtifactError, WorkflowArtifactStore } from './artifacts.js';
|
|
19
|
+
import { renderArtifactPreview } from './artifact-preview.js';
|
|
19
20
|
import { createWorkflowExecution } from './workflow-execution.js';
|
|
21
|
+
import { createImageWorkflow } from './image-workflow.js';
|
|
20
22
|
import {
|
|
21
23
|
ImageIntakeError,
|
|
22
24
|
ImageIntakeStore,
|
|
@@ -304,10 +306,11 @@ export function mountWebRoutes(wctx, { logger, report, TOOL_NAME, SKILL_NAME })
|
|
|
304
306
|
const skills = wctx.skills;
|
|
305
307
|
const disposers = [];
|
|
306
308
|
const qccBridge = new QccHostBridge({ tools, logger });
|
|
307
|
-
const imageIntake = new ImageIntakeStore({ tools });
|
|
308
309
|
const g5Runs = new G5RunStore();
|
|
309
310
|
const execution = createWorkflowExecution({ getWorkflow: () => getWorkflow(), artifacts: wctx.fs ? new WorkflowArtifactStore({ fs: wctx.fs }) : null });
|
|
310
311
|
const qccCommands = new QccCommandStore({ bridge: qccBridge, runs: g5Runs, lifecycle: execution });
|
|
312
|
+
const imageIntake = new ImageIntakeStore({ tools,
|
|
313
|
+
continueWorkflow: createImageWorkflow({ getWorkflow: () => getWorkflow(), execution, commands: qccCommands }) });
|
|
311
314
|
if (typeof tools?.register === 'function') {
|
|
312
315
|
disposers.push(registerQccCommandTool(tools, qccCommands));
|
|
313
316
|
disposers.push(registerImageIntakeTool(tools, imageIntake));
|
|
@@ -417,6 +420,26 @@ export function mountWebRoutes(wctx, { logger, report, TOOL_NAME, SKILL_NAME })
|
|
|
417
420
|
command: imageIntake.status(decodeURIComponent(rest[0])),
|
|
418
421
|
});
|
|
419
422
|
}
|
|
423
|
+
if (rest.length === 1 && req.method === 'PATCH') {
|
|
424
|
+
const payload = JSON.parse((await readBody(req)).toString('utf8'));
|
|
425
|
+
const record = imageIntake.require(decodeURIComponent(rest[0]));
|
|
426
|
+
const task = await (await getWorkflow()).require(payload.taskId);
|
|
427
|
+
if (payload.executeOnSend !== true || record.state !== 'prepared' || task.state !== 'draft'
|
|
428
|
+
|| task.revision !== payload.expectedRevision || task.source?.type !== 'image'
|
|
429
|
+
|| task.source.fileName !== record.fileName || !task.fieldSelection.length) {
|
|
430
|
+
throw new ImageIntakeError('DC_IMAGE_CONFIG_CONFLICT', '图片或任务配置已变更,或未选择补全字段,请重新确认。', 409);
|
|
431
|
+
}
|
|
432
|
+
let artifactOrigin = '';
|
|
433
|
+
const requestHost = req.headers?.host;
|
|
434
|
+
if (typeof requestHost === 'string' && /^[a-zA-Z0-9.:[\]-]+$/.test(requestHost)) {
|
|
435
|
+
try {
|
|
436
|
+
const origin = new URL(req.headers.origin || `${req.socket?.encrypted ? 'https' : 'http'}://${requestHost}`);
|
|
437
|
+
if (['http:', 'https:'].includes(origin.protocol) && origin.host === requestHost) artifactOrigin = origin.origin;
|
|
438
|
+
} catch { /* Use relative artifact links. */ }
|
|
439
|
+
}
|
|
440
|
+
record.workflow = { taskId: task.id, revision: task.revision, artifactOrigin };
|
|
441
|
+
return writeJson(res, 200, { ok: true, command: imageIntake.status(record.commandId) });
|
|
442
|
+
}
|
|
420
443
|
if (rest.length === 1 && req.method === 'DELETE') {
|
|
421
444
|
await imageIntake.remove(decodeURIComponent(rest[0]));
|
|
422
445
|
return writeJson(res, 200, { ok: true, marker: 'data-cleaning-image-intake-v1', removed: true });
|
|
@@ -561,11 +584,10 @@ export function mountWebRoutes(wctx, { logger, report, TOOL_NAME, SKILL_NAME })
|
|
|
561
584
|
const bytes = await artifactStore.read(taskId, artifact);
|
|
562
585
|
if (artifact.format === 'xlsx' && new URL(req.url, 'http://127.0.0.1').searchParams.get('preview') === '1') {
|
|
563
586
|
const parsed = await parseXlsx(bytes);
|
|
564
|
-
const escape = value => String(value ?? '').replace(/[&<>\"]/g, c => ({ '&': '&', '<': '<', '>': '>', '\"': '"' }[c]));
|
|
565
587
|
const columns = parsed.headers || [];
|
|
566
588
|
const rows = (parsed.rows || []).slice(0, 50);
|
|
567
589
|
const download = `/data-cleaning/api/workflow/tasks/${encodeURIComponent(taskId)}/artifacts/${encodeURIComponent(artifactId)}`;
|
|
568
|
-
const html =
|
|
590
|
+
const html = renderArtifactPreview({ fileName: artifact.fileName, taskId, rowCount: artifact.rowCount, columns, rows, download });
|
|
569
591
|
res.writeHead(200, { 'content-type': 'text/html; charset=utf-8', 'cache-control': 'no-store', 'content-security-policy': "default-src 'none'; style-src 'unsafe-inline'; frame-ancestors 'self'", 'x-content-type-options': 'nosniff' });
|
|
570
592
|
return res.end(html);
|
|
571
593
|
}
|
package/lib/workflow.js
CHANGED
|
@@ -149,7 +149,7 @@ export class DataCleaningWorkflowStore {
|
|
|
149
149
|
fieldSelection: draft.fieldSelection,
|
|
150
150
|
mappings: draft.mappings,
|
|
151
151
|
matchRules: draft.matchRules,
|
|
152
|
-
source: null,
|
|
152
|
+
source: input.source?.type === 'image' ? sanitizeSource({ ...input.source, rowCount: 0 }) : null,
|
|
153
153
|
qualitySummary: null,
|
|
154
154
|
matchSummary: null,
|
|
155
155
|
enrichmentSummary: null,
|
|
@@ -218,7 +218,8 @@ export class DataCleaningWorkflowStore {
|
|
|
218
218
|
mappings: input.mappings ?? record.mappings,
|
|
219
219
|
matchRules: input.matchRules ?? record.matchRules,
|
|
220
220
|
});
|
|
221
|
-
return draft
|
|
221
|
+
return { ...draft, ...(record.state === 'draft' && input.source?.type === 'image'
|
|
222
|
+
? { source: sanitizeSource({ ...input.source, rowCount: 0 }) } : {}) };
|
|
222
223
|
});
|
|
223
224
|
}
|
|
224
225
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-data-cleaning-agent",
|
|
3
|
-
"version": "0.9.
|
|
3
|
+
"version": "0.9.4",
|
|
4
4
|
"description": "Data cleaning and data enrichment for CSV/XLSX/JSON enterprise lists in DeepSeek Harness, including spreadsheet cleaning, deduplication, profiling, optional Qichacha MCP and exports.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "lib/index.js",
|