dsh-data-cleaning-agent 0.9.2 → 0.9.3

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 CHANGED
@@ -4,6 +4,12 @@
4
4
 
5
5
  ## [Unreleased]
6
6
 
7
+ ## [0.9.3] - 2026-09-10
8
+
9
+ - 图片向导保留独立缩略图与来源元数据,识别前显示待识别而非表格零行。
10
+ - 明确发送授权后,图片高层工具沿用确认版本自动完成识别、体检、匹配、补全及新 XLSX 交付;不再要求第二次发送。歧义、超限及过期配置阻断企业查询,重复调用保持幂等。
11
+ - 文件预览优先复用当前会话的侧栏预览 Tab,下载及无侧栏浏览器回退保持独立。
12
+
7
13
  ## [0.9.2] - 2026-09-10
8
14
 
9
15
  - 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.2** (stable release)
13
+ > Current source version / 当前源码版本: **0.9.3** (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.2
12
+ dsh plugin --profile web add dsh-data-cleaning-agent@0.9.3
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.2**(正式版本)
30
+ > 当前源码版本 / Current source version: **0.9.3**(正式版本)
31
31
 
32
32
  本版上传表格后展示完整清单及空白数量,用绿色、琥珀色、红色及文字区分已通过、待确认和未匹配映射。已映射字段自动纳入补全范围,额外字段默认折叠;多个原列可经确认使用同一非主体字段,分别补空并保留非空原值。修复 Host 草稿重置为默认五字段的问题,增加前后端版本错配提示。生成说明不查询,发送才执行;升级后需完整重启 DSH,历史下载文件不自动改写。
33
33
 
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}」` : ''},已由企查查智能文档解析识别并人工核验 ${entryCount} 条主体标识`
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('请先通过企查查智能文档解析提取图片中的企业全称、18 位统一社会信用代码或注册号;模糊字符不得猜测。识别结果回传数据清洗补全工作台供我核验,然后沿用本任务已经选择的匹配规则、清洗要求和补全字段。');
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} 条${time ? '|' + time : ''}`;
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' || imageCommand?.state === 'completed') {
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
- imageAttachment ? h('div', { className: 'dcAgentPromptImagePreview' },
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
- }, imageAttachment.previewUrl ? h('img', { src: imageAttachment.previewUrl, alt: imageFileName }) : '图片'),
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 && imageAttachment?.previewUrl ? h(ImagePreview, {
2966
- src: imageAttachment.previewUrl, alt: imageFileName, sessionId, onClose: () => setImageLightbox(false),
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
- toolName === 'data_cleaning_qcc_run' ? h('div', { className: 'dcAgentArtifactList' },
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' ? '粘贴名单' : '上传表格'} · ${cachedTask.source?.fileName || ''} · ${cachedTask.source?.rowCount || 0} 条 · ${cachedTask.fieldSelection?.length || 0} 个补全字段`),
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();
@@ -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: 'Parse one already-staged local company-list image through QCC qcc-document-mcp. Call only when a visible data-cleaning prompt supplies a dci-* commandId. The Host owns the temporary image, calls parse_document once, and polls get_parse_result only while the returned task is processing.',
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: `企查查智能文档解析已识别图片企业名单:${value.entryCount} 条,已同步回数据清洗补全工作台等待核验。请在「导入与核验」查看原始清单全部行或下载全量 CSV/JSON,核对后进入「规则与体检」确认规则并生成质量体检,再到「主体匹配」估算调用量并确认账号额度,点击「生成可编辑任务说明」自动回填中央对话框。无需手工复制 dcq- 凭证或重新粘贴名单;识别名单不代表已匹配,尚未查询时不要编造匹配摘要。`,
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
@@ -17,6 +17,7 @@ import { publicWorkflowContract } from './workflow-contract.js';
17
17
  import { DataCleaningWorkflowStore, WorkflowError } from './workflow.js';
18
18
  import { ArtifactError, WorkflowArtifactStore } from './artifacts.js';
19
19
  import { createWorkflowExecution } from './workflow-execution.js';
20
+ import { createImageWorkflow } from './image-workflow.js';
20
21
  import {
21
22
  ImageIntakeError,
22
23
  ImageIntakeStore,
@@ -304,10 +305,11 @@ export function mountWebRoutes(wctx, { logger, report, TOOL_NAME, SKILL_NAME })
304
305
  const skills = wctx.skills;
305
306
  const disposers = [];
306
307
  const qccBridge = new QccHostBridge({ tools, logger });
307
- const imageIntake = new ImageIntakeStore({ tools });
308
308
  const g5Runs = new G5RunStore();
309
309
  const execution = createWorkflowExecution({ getWorkflow: () => getWorkflow(), artifacts: wctx.fs ? new WorkflowArtifactStore({ fs: wctx.fs }) : null });
310
310
  const qccCommands = new QccCommandStore({ bridge: qccBridge, runs: g5Runs, lifecycle: execution });
311
+ const imageIntake = new ImageIntakeStore({ tools,
312
+ continueWorkflow: createImageWorkflow({ getWorkflow: () => getWorkflow(), execution, commands: qccCommands }) });
311
313
  if (typeof tools?.register === 'function') {
312
314
  disposers.push(registerQccCommandTool(tools, qccCommands));
313
315
  disposers.push(registerImageIntakeTool(tools, imageIntake));
@@ -417,6 +419,26 @@ export function mountWebRoutes(wctx, { logger, report, TOOL_NAME, SKILL_NAME })
417
419
  command: imageIntake.status(decodeURIComponent(rest[0])),
418
420
  });
419
421
  }
422
+ if (rest.length === 1 && req.method === 'PATCH') {
423
+ const payload = JSON.parse((await readBody(req)).toString('utf8'));
424
+ const record = imageIntake.require(decodeURIComponent(rest[0]));
425
+ const task = await (await getWorkflow()).require(payload.taskId);
426
+ if (payload.executeOnSend !== true || record.state !== 'prepared' || task.state !== 'draft'
427
+ || task.revision !== payload.expectedRevision || task.source?.type !== 'image'
428
+ || task.source.fileName !== record.fileName || !task.fieldSelection.length) {
429
+ throw new ImageIntakeError('DC_IMAGE_CONFIG_CONFLICT', '图片或任务配置已变更,或未选择补全字段,请重新确认。', 409);
430
+ }
431
+ let artifactOrigin = '';
432
+ const requestHost = req.headers?.host;
433
+ if (typeof requestHost === 'string' && /^[a-zA-Z0-9.:[\]-]+$/.test(requestHost)) {
434
+ try {
435
+ const origin = new URL(req.headers.origin || `${req.socket?.encrypted ? 'https' : 'http'}://${requestHost}`);
436
+ if (['http:', 'https:'].includes(origin.protocol) && origin.host === requestHost) artifactOrigin = origin.origin;
437
+ } catch { /* Use relative artifact links. */ }
438
+ }
439
+ record.workflow = { taskId: task.id, revision: task.revision, artifactOrigin };
440
+ return writeJson(res, 200, { ok: true, command: imageIntake.status(record.commandId) });
441
+ }
420
442
  if (rest.length === 1 && req.method === 'DELETE') {
421
443
  await imageIntake.remove(decodeURIComponent(rest[0]));
422
444
  return writeJson(res, 200, { ok: true, marker: 'data-cleaning-image-intake-v1', removed: true });
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.2",
3
+ "version": "0.9.3",
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",