dsh-data-cleaning-agent 0.7.0 → 0.8.1

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/lib/client.js CHANGED
@@ -347,6 +347,78 @@ window.__ModuleLoader__.load({
347
347
  font-size: 12px;
348
348
  }
349
349
  .dcAgentPromptFile input { max-width: 100%; }
350
+ .dcAgentPromptImageDrop {
351
+ display: grid;
352
+ gap: 10px;
353
+ padding: 12px;
354
+ border: 1px dashed light-dark(#b9c5d8, #43516a);
355
+ border-radius: 12px;
356
+ background: light-dark(#fbfcfe, #11171f);
357
+ outline: none;
358
+ }
359
+ .dcAgentPromptImageDrop.is-active {
360
+ border-color: #2869e6;
361
+ background: light-dark(#edf4ff, #172841);
362
+ }
363
+ .dcAgentPromptImageDrop > p {
364
+ margin: 0;
365
+ color: light-dark(#697386, #aab5c7);
366
+ font-size: 11px;
367
+ line-height: 17px;
368
+ }
369
+ .dcAgentPromptImagePreview {
370
+ display: flex;
371
+ align-items: center;
372
+ gap: 10px;
373
+ min-width: 0;
374
+ }
375
+ .dcAgentPromptImageThumb {
376
+ width: 64px;
377
+ height: 64px;
378
+ flex: 0 0 auto;
379
+ overflow: hidden;
380
+ padding: 0;
381
+ border: 1px solid light-dark(#d9dee8, #394352);
382
+ border-radius: 10px;
383
+ background: light-dark(#f3f6fa, #202733);
384
+ cursor: zoom-in;
385
+ }
386
+ .dcAgentPromptImageThumb img { width: 100%; height: 100%; object-fit: cover; display: block; }
387
+ .dcAgentPromptImageMeta { min-width: 0; flex: 1 1 auto; }
388
+ .dcAgentPromptImageMeta b,
389
+ .dcAgentPromptImageMeta small { display: block; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
390
+ .dcAgentPromptImageMeta small { margin-top: 3px; color: light-dark(#697386, #aab5c7); }
391
+ .dcAgentPromptImageRemove {
392
+ flex: 0 0 auto;
393
+ padding: 5px 9px;
394
+ border: 1px solid light-dark(#d9dee8, #394352);
395
+ border-radius: 8px;
396
+ background: transparent;
397
+ color: inherit;
398
+ font: inherit;
399
+ font-size: 11px;
400
+ cursor: pointer;
401
+ }
402
+ .dcAgentImageLightbox {
403
+ position: fixed;
404
+ inset: 0;
405
+ z-index: 1400;
406
+ display: grid;
407
+ place-items: center;
408
+ padding: 32px;
409
+ border: 0;
410
+ background: rgba(8, 12, 20, .76);
411
+ cursor: zoom-out;
412
+ pointer-events: auto;
413
+ }
414
+ .dcAgentImageLightbox img {
415
+ display: block;
416
+ max-width: min(1100px, 92vw);
417
+ max-height: 88vh;
418
+ object-fit: contain;
419
+ border-radius: 12px;
420
+ box-shadow: 0 24px 80px rgba(0, 0, 0, .4);
421
+ }
350
422
  .dcAgentPromptGroup { margin-top: 12px; }
351
423
  .dcAgentPromptGroup > b { display: block; margin-bottom: 7px; font-size: 12px; }
352
424
  .dcAgentPromptChoices { display: flex; flex-wrap: wrap; gap: 6px; }
@@ -982,7 +1054,10 @@ window.__ModuleLoader__.load({
982
1054
  const executionDraft = value.startsWith('请执行已在「数据清洗补全工作台」确认的企业数据任务。')
983
1055
  && value.includes('安全任务凭证:dcq-')
984
1056
  && value.includes('data_cleaning_qcc_run');
985
- return wizardDraft || executionDraft;
1057
+ const imageDraft = value.startsWith('请识别我刚刚在向导中安全暂存的企业名单图片')
1058
+ && value.includes('安全图片凭证:dci-')
1059
+ && value.includes('data_cleaning_extract_image_companies');
1060
+ return wizardDraft || executionDraft || imageDraft;
986
1061
  }
987
1062
 
988
1063
  function clearCleaningDraft(ctx, sessionId, onlyDefault = false) {
@@ -1119,7 +1194,9 @@ window.__ModuleLoader__.load({
1119
1194
  : [];
1120
1195
  const entryCount = Number.isFinite(config.entryCount) ? config.entryCount : entries.length;
1121
1196
  const source = mode === 'image'
1122
- ? `已附加图片${config.fileName ? `「${config.fileName}」` : ''}`
1197
+ ? config.imageState === 'completed'
1198
+ ? `图片${config.fileName ? `「${config.fileName}」` : ''},已由企查查智能文档解析识别并人工核验 ${entryCount} 条主体标识`
1199
+ : `图片${config.fileName ? `「${config.fileName}」` : ''},已安全暂存在本机 Host,等待企查查智能文档解析识别`
1123
1200
  : mode === 'excel'
1124
1201
  ? `本地表格${config.fileName ? `「${config.fileName}」` : ''},已解析 ${entryCount} 条主体标识,完整数据已载入数据清洗补全工作台`
1125
1202
  : `手工录入,共 ${entryCount} 条主体标识`;
@@ -1127,8 +1204,9 @@ window.__ModuleLoader__.load({
1127
1204
  '请执行一项企业名单数据清洗补全任务。',
1128
1205
  `输入来源:${source}。`,
1129
1206
  ];
1130
- if (mode === 'image') {
1131
- lines.push('请先使用当前已连接且可用的企查查智能文档解析 MCP 能力,从图片中提取企业全称或统一社会信用代码;若该能力未连接或不可用,请先提示我改用文本或 Excel。');
1207
+ if (mode === 'image' && config.imageCommandId && config.imageState !== 'completed') {
1208
+ lines.push('请先通过企查查智能文档解析提取图片中的企业全称、18 位统一社会信用代码或注册号;模糊字符不得猜测。识别结果回传数据清洗补全工作台供我核验,然后沿用本任务已经选择的匹配规则、清洗要求和补全字段。');
1209
+ lines.push(`安全图片凭证:${config.imageCommandId}。请只调用一次 data_cleaning_extract_image_companies,参数只传 commandId;该高层工具会在 Host 内调用 qcc-document-mcp 的 parse_document / get_parse_result,不要直接调用底层 mcp__qcc-document* 工具。`);
1132
1210
  }
1133
1211
  if (entries.length) {
1134
1212
  lines.push(`${mode === 'excel' ? '主体预览' : '待处理主体'}:\n${entries.map((entry, index) => `${index + 1}. ${entry}`).join('\n')}`);
@@ -1523,8 +1601,8 @@ window.__ModuleLoader__.load({
1523
1601
  }
1524
1602
 
1525
1603
  /**
1526
- * 图片接入兼容 Bridge。仅在运行时同时探测到 createDraftImages / input.shell.addImages
1527
- * 时使用;失败会释放浏览器临时对象,不假设 alpha 实验 API 稳定。
1604
+ * 图片接入兼容 Bridge。返回官方 ComposerAttachment,供向导复用其 previewUrl
1605
+ * 显示 64px 缩略图;失败会释放浏览器临时对象,不假设 alpha 实验 API 稳定。
1528
1606
  */
1529
1607
  function attachPromptImages(ctx, sessionId, files) {
1530
1608
  const conversation = typeof ctx.get === 'function' ? ctx.get('conversation') : ctx.conversation;
@@ -1540,7 +1618,17 @@ window.__ModuleLoader__.load({
1540
1618
  conversation.releaseDraftImages?.(images);
1541
1619
  throw new Error('图片未能加入当前对话,请检查格式或数量限制。');
1542
1620
  }
1543
- return images.length;
1621
+ return images;
1622
+ }
1623
+
1624
+ function removePromptImage(ctx, sessionId, attachment) {
1625
+ const conversation = typeof ctx.get === 'function' ? ctx.get('conversation') : ctx.conversation;
1626
+ const id = attachment?.id;
1627
+ if (!conversation || !id) return false;
1628
+ const shell = conversation.input?.shell?.(sessionId);
1629
+ shell?.removeImage?.(id);
1630
+ conversation.releaseDraftImage?.(id);
1631
+ return true;
1544
1632
  }
1545
1633
 
1546
1634
  async function sendQccAgentCommand(ctx, sessionId, prompt) {
@@ -1783,8 +1871,15 @@ window.__ModuleLoader__.load({
1783
1871
  const changedBadges = new Map();
1784
1872
  const hiddenForeignDocks = new Map();
1785
1873
  const composerStack = marker?.parentElement?.parentElement;
1874
+ // 单一会话所有权由入口的 enabled 参数(useCleaningSession(sessionId) && composerPhase==='blank')
1875
+ // 与 React effect 生命周期共同保证:非清洗会话不会挂载,会话切换即走 cleanup 让位。
1876
+ // 有界同步:每次 fix 的 DOM 变更计入校正预算,达到上限即断开观察器,杜绝与其它插件互相改写的死循环。
1877
+ const MAX_HERO_CORRECTIONS = 8;
1878
+ let corrections = 0;
1879
+ let observer = null;
1786
1880
 
1787
1881
  const fix = () => {
1882
+ let mutated = false;
1788
1883
  const spans = [...hero.querySelectorAll('span')];
1789
1884
  const title = spans.find((element) => element.dataset?.dcAgentHeroTitle === 'true')
1790
1885
  ?? spans.find((element) => ['探索未至之境', 'Into the Unknown'].includes(element.textContent?.trim()))
@@ -1794,12 +1889,12 @@ window.__ModuleLoader__.load({
1794
1889
  if (title) {
1795
1890
  if (!changedTitles.has(title)) changedTitles.set(title, title.textContent ?? '');
1796
1891
  title.dataset.dcAgentHeroTitle = 'true';
1797
- if (title.textContent !== '数据清洗补全智能体') title.textContent = '数据清洗补全智能体';
1892
+ if (title.textContent !== '数据清洗补全智能体') { title.textContent = '数据清洗补全智能体'; mutated = true; }
1798
1893
  }
1799
1894
  if (badge) {
1800
1895
  if (!changedBadges.has(badge)) changedBadges.set(badge, badge.style?.display ?? '');
1801
1896
  badge.dataset.dcAgentHeroBadge = 'true';
1802
- if (badge.style && badge.style.display !== 'none') badge.style.display = 'none';
1897
+ if (badge.style && badge.style.display !== 'none') { badge.style.display = 'none'; mutated = true; }
1803
1898
  }
1804
1899
 
1805
1900
  const foreignSurfaces = composerStack?.querySelectorAll?.('[aria-label="尽调类型"], .qccDock') ?? [];
@@ -1815,13 +1910,20 @@ window.__ModuleLoader__.load({
1815
1910
  if ((hideTarget?.parentElement === composerStack || hideTarget?.parentElement === marker.parentElement)
1816
1911
  && !hideTarget.contains?.(marker)) {
1817
1912
  if (!hiddenForeignDocks.has(hideTarget)) hiddenForeignDocks.set(hideTarget, hideTarget.style?.display ?? '');
1818
- if (hideTarget.style && hideTarget.style.display !== 'none') hideTarget.style.display = 'none';
1913
+ if (hideTarget.style && hideTarget.style.display !== 'none') { hideTarget.style.display = 'none'; mutated = true; }
1914
+ }
1915
+ }
1916
+ if (mutated) {
1917
+ corrections += 1;
1918
+ if (corrections >= MAX_HERO_CORRECTIONS && observer) {
1919
+ observer.disconnect();
1920
+ observer = null;
1819
1921
  }
1820
1922
  }
1821
1923
  };
1822
1924
 
1823
1925
  fix();
1824
- const observer = typeof MutationObserver === 'function' ? new MutationObserver(fix) : null;
1926
+ observer = typeof MutationObserver === 'function' ? new MutationObserver(fix) : null;
1825
1927
  observer?.observe?.(hero, { childList: true, subtree: true });
1826
1928
  return () => {
1827
1929
  observer?.disconnect?.();
@@ -1873,8 +1975,30 @@ window.__ModuleLoader__.load({
1873
1975
  }).filter(Boolean).slice(0, 50);
1874
1976
  }
1875
1977
 
1978
+ async function imageFileBase64(file) {
1979
+ if (!file || typeof file.arrayBuffer !== 'function') throw new Error('图片文件不可读。');
1980
+ const bytes = new Uint8Array(await file.arrayBuffer());
1981
+ let binary = '';
1982
+ for (let offset = 0; offset < bytes.length; offset += 0x8000) {
1983
+ binary += String.fromCharCode(...bytes.subarray(offset, offset + 0x8000));
1984
+ }
1985
+ return btoa(binary);
1986
+ }
1987
+
1988
+ async function stagePromptImage(file) {
1989
+ if (!['image/png', 'image/jpeg', 'image/webp'].includes(String(file?.type ?? ''))) {
1990
+ throw new Error('仅支持 PNG、JPEG 或 WebP 图片。');
1991
+ }
1992
+ if (Number(file?.size ?? 0) > 8 * 1024 * 1024) throw new Error('图片不能超过 8 MiB。');
1993
+ return api('/data-cleaning/api/images/commands', {
1994
+ fileName: file.name,
1995
+ mimeType: file.type,
1996
+ content: await imageFileBase64(file),
1997
+ });
1998
+ }
1999
+
1876
2000
  function PromptGenerator(props) {
1877
- const { sessionId, inputActions, attachImages } = props;
2001
+ const { sessionId, inputActions, attachImages, removeImage } = props;
1878
2002
  const enabled = useCleaningSession(sessionId);
1879
2003
  const [open, setOpen] = react.useState(false);
1880
2004
  const [wizardStep, setWizardStep] = react.useState(1);
@@ -1884,7 +2008,11 @@ window.__ModuleLoader__.load({
1884
2008
  const [entryCount, setEntryCount] = react.useState(0);
1885
2009
  const [spreadsheetFileName, setSpreadsheetFileName] = react.useState('');
1886
2010
  const [imageFileName, setImageFileName] = react.useState('');
1887
- const [imageAttached, setImageAttached] = react.useState(false);
2011
+ const [imageSizeBytes, setImageSizeBytes] = react.useState(0);
2012
+ const [imageAttachment, setImageAttachment] = react.useState(null);
2013
+ const [imageCommand, setImageCommand] = react.useState(null);
2014
+ const [imageDropActive, setImageDropActive] = react.useState(false);
2015
+ const [imageLightbox, setImageLightbox] = react.useState(false);
1888
2016
  const [cleaningKeys, setCleaningKeys] = react.useState(DEFAULT_CLEANING_KEYS);
1889
2017
  const [enrichmentKeys, setEnrichmentKeys] = react.useState(DEFAULT_ENRICHMENT_KEYS);
1890
2018
  const [anchorKeys, setAnchorKeys] = react.useState(['company_name', 'credit_no']);
@@ -1897,38 +2025,127 @@ window.__ModuleLoader__.load({
1897
2025
  const [busy, setBusy] = react.useState(false);
1898
2026
  const [error, setError] = react.useState(null);
1899
2027
 
1900
- if (!enabled) return null;
2028
+ react.useEffect(() => {
2029
+ const commandId = imageCommand?.commandId;
2030
+ if (!commandId || ['completed', 'failed'].includes(imageCommand?.state)) return undefined;
2031
+ let disposed = false;
2032
+ let timer = null;
2033
+ const poll = async () => {
2034
+ try {
2035
+ const response = await api(`/data-cleaning/api/images/commands/${encodeURIComponent(commandId)}`);
2036
+ if (disposed) return;
2037
+ const command = response?.command;
2038
+ if (!command) return;
2039
+ setImageCommand((current) => current?.commandId === commandId ? { ...current, ...command } : current);
2040
+ if (command.state === 'completed') {
2041
+ const recognized = Array.isArray(command.result?.entries) ? command.result.entries : [];
2042
+ setEntries(recognized);
2043
+ setEntryCount(recognized.length);
2044
+ requestWorkbenchDataset(entriesToDataset(recognized), sessionId, {
2045
+ type: 'image', fileName: imageFileName, sizeBytes: imageSizeBytes,
2046
+ });
2047
+ setError(null);
2048
+ setOpen(false);
2049
+ requestWorkbenchOpen('upload', sessionId);
2050
+ return;
2051
+ }
2052
+ if (command.state === 'failed') {
2053
+ setError(command.error?.message || '图片识别失败,请换用清晰图片或文本/Excel 名单。');
2054
+ setOpen(true);
2055
+ return;
2056
+ }
2057
+ timer = setTimeout(poll, 1000);
2058
+ } catch (pollError) {
2059
+ if (!disposed) {
2060
+ setError(pollError instanceof Error ? pollError.message : String(pollError));
2061
+ setOpen(true);
2062
+ }
2063
+ }
2064
+ };
2065
+ timer = setTimeout(poll, 800);
2066
+ return () => {
2067
+ disposed = true;
2068
+ if (timer !== null) clearTimeout(timer);
2069
+ };
2070
+ }, [imageCommand?.commandId, imageCommand?.state, imageFileName, imageSizeBytes, sessionId]);
1901
2071
 
2072
+ const discardCurrentImage = () => {
2073
+ if (imageAttachment && typeof removeImage === 'function') removeImage(sessionId, imageAttachment);
2074
+ if (imageCommand?.commandId && imageCommand.state !== 'running') {
2075
+ requestJson(`/data-cleaning/api/images/commands/${encodeURIComponent(imageCommand.commandId)}`, 'DELETE').catch(() => {});
2076
+ }
2077
+ setImageAttachment(null);
2078
+ setImageCommand(null);
2079
+ setImageFileName('');
2080
+ setImageSizeBytes(0);
2081
+ setEntries([]);
2082
+ setEntryCount(0);
2083
+ setImageLightbox(false);
2084
+ };
1902
2085
  const setSourceMode = (nextMode) => {
2086
+ if (mode === 'image' && nextMode !== 'image') {
2087
+ if (imageCommand?.state === 'running') {
2088
+ setError('图片正在识别,请等待完成后再切换数据来源。');
2089
+ return;
2090
+ }
2091
+ discardCurrentImage();
2092
+ }
1903
2093
  setMode(nextMode);
1904
2094
  setError(null);
1905
2095
  };
1906
2096
  const toggle = (setter, values, key) => setter(values.includes(key)
1907
2097
  ? values.filter((item) => item !== key)
1908
2098
  : [...values, key]);
2099
+ const handleImageFile = async (file) => {
2100
+ if (!file || busy) return;
2101
+ setBusy(true);
2102
+ setError(null);
2103
+ let attached = null;
2104
+ try {
2105
+ if (typeof attachImages !== 'function') throw new Error('当前 DSH 没有可用的图片接入 Bridge。');
2106
+ const images = await attachImages(sessionId, [file]);
2107
+ attached = Array.isArray(images) ? images[0] : null;
2108
+ if (!attached?.id) throw new Error('图片未能加入当前对话。');
2109
+ const staged = await stagePromptImage(file);
2110
+ if (imageAttachment && typeof removeImage === 'function') removeImage(sessionId, imageAttachment);
2111
+ if (imageCommand?.commandId) {
2112
+ requestJson(`/data-cleaning/api/images/commands/${encodeURIComponent(imageCommand.commandId)}`, 'DELETE').catch(() => {});
2113
+ }
2114
+ setImageAttachment(attached);
2115
+ setImageFileName(file.name);
2116
+ setImageSizeBytes(Number(file.size ?? 0));
2117
+ setImageCommand(staged.command);
2118
+ setEntries([]);
2119
+ setEntryCount(0);
2120
+ } catch (fileError) {
2121
+ if (attached && typeof removeImage === 'function') removeImage(sessionId, attached);
2122
+ throw fileError;
2123
+ } finally {
2124
+ setBusy(false);
2125
+ }
2126
+ };
1909
2127
  const handleFile = async (event) => {
1910
2128
  const file = event.target?.files?.[0];
1911
2129
  if (!file || busy) return;
2130
+ if (mode === 'image') {
2131
+ try { await handleImageFile(file); }
2132
+ catch (fileError) { setError(fileError instanceof Error ? fileError.message : String(fileError)); }
2133
+ finally { if (event.target) event.target.value = ''; }
2134
+ return;
2135
+ }
1912
2136
  setBusy(true);
1913
2137
  setError(null);
1914
2138
  try {
1915
- if (mode === 'image') {
1916
- if (typeof attachImages !== 'function') throw new Error('当前 DSH 没有可用的图片接入 Bridge。');
1917
- await attachImages(sessionId, [file]);
1918
- setImageFileName(file.name);
1919
- setImageAttached(true);
1920
- } else {
1921
- const result = await parseFile(file);
1922
- if (!result || result.ok === false) throw new Error(result?.message || result?.error || '表格解析失败');
1923
- const extracted = extractPromptEntries(result);
1924
- if (!extracted.length) throw new Error('未识别到企业名称或统一社会信用代码列,请检查表头。');
1925
- setEntries(extracted);
1926
- setEntryCount(Number.isFinite(result.rowCount) ? result.rowCount : (Array.isArray(result.rows) ? result.rows.length : extracted.length));
1927
- setSpreadsheetFileName(file.name);
1928
- requestWorkbenchDataset(result, sessionId, {
1929
- type: result.fmt || 'xlsx', fileName: file.name, sizeBytes: file.size || 0,
1930
- });
1931
- }
2139
+ const result = await parseFile(file);
2140
+ if (!result || result.ok === false) throw new Error(result?.message || result?.error || '表格解析失败');
2141
+ const extracted = extractPromptEntries(result);
2142
+ if (!extracted.length) throw new Error('未识别到企业名称或统一社会信用代码列,请检查表头。');
2143
+ setEntries(extracted);
2144
+ setEntryCount(Number.isFinite(result.rowCount) ? result.rowCount : (Array.isArray(result.rows) ? result.rows.length : extracted.length));
2145
+ setSpreadsheetFileName(file.name);
2146
+ requestWorkbenchDataset(result, sessionId, {
2147
+ type: result.fmt || 'xlsx', fileName: file.name, sizeBytes: file.size || 0,
2148
+ });
1932
2149
  } catch (fileError) {
1933
2150
  setError(fileError instanceof Error ? fileError.message : String(fileError));
1934
2151
  } finally {
@@ -1943,7 +2160,10 @@ window.__ModuleLoader__.load({
1943
2160
  const values = selectedEntries();
1944
2161
  if (mode === 'text' && !values.length) return '请先录入企业名称或统一社会信用代码,每行一条。';
1945
2162
  if (mode === 'excel' && !values.length) return '请先上传并解析 Excel / CSV / JSON 文件。';
1946
- if (mode === 'image' && !imageAttached) return '请先选择并附加一张包含企业名单的图片。';
2163
+ if (mode === 'image' && !imageCommand) return '请先选择、粘贴或拖入一张包含企业名单的图片。';
2164
+ if (mode === 'image' && imageCommand?.state === 'failed') return imageCommand.error?.message || '图片识别失败,请重新选择图片。';
2165
+ if (mode === 'image' && imageCommand?.state === 'running') return '图片正在通过企查查智能文档解析识别,请稍候。';
2166
+ if (mode === 'image' && imageCommand?.state === 'completed' && !values.length) return '图片中未提取到可核验的企业名称或统一社会信用代码。';
1947
2167
  return null;
1948
2168
  };
1949
2169
  const promptConfig = () => ({
@@ -1955,6 +2175,8 @@ window.__ModuleLoader__.load({
1955
2175
  enrichmentKeys,
1956
2176
  anchorKeys,
1957
2177
  matchRules,
2178
+ imageCommandId: imageCommand?.commandId ?? '',
2179
+ imageState: imageCommand?.state ?? null,
1958
2180
  });
1959
2181
  const generatePrompt = () => {
1960
2182
  const sourceError = validateSource();
@@ -1966,6 +2188,11 @@ window.__ModuleLoader__.load({
1966
2188
  return;
1967
2189
  }
1968
2190
  const config = promptConfig();
2191
+ if (mode === 'image' && imageCommand?.state === 'prepared' && imageAttachment && typeof removeImage === 'function') {
2192
+ // qcc-document-mcp 只读取 Host 暂存文件;发送前释放原生附件,文本模型不会收到图片。
2193
+ removeImage(sessionId, imageAttachment);
2194
+ setImageAttachment(null);
2195
+ }
1969
2196
  inputActions.setDraft(buildTaskPrompt(config));
1970
2197
  if (mode === 'text') {
1971
2198
  requestWorkbenchDataset(entriesToDataset(config.entries), sessionId, { type: 'text', fileName: '' });
@@ -1993,6 +2220,47 @@ window.__ModuleLoader__.load({
1993
2220
  generatePrompt();
1994
2221
  };
1995
2222
 
2223
+ const acceptImageFiles = async (files) => {
2224
+ const file = [...(files || [])].find((candidate) => String(candidate?.type ?? '').startsWith('image/'));
2225
+ if (!file) { setError('剪贴板或拖入内容中没有可用图片。'); return; }
2226
+ try { await handleImageFile(file); }
2227
+ catch (imageError) { setError(imageError instanceof Error ? imageError.message : String(imageError)); }
2228
+ };
2229
+ const removeCurrentImage = async () => {
2230
+ if (busy) return;
2231
+ discardCurrentImage();
2232
+ setError(null);
2233
+ };
2234
+
2235
+ // 在数据清洗会话中从 window 捕获阶段接管 Composer 图片粘贴,早于
2236
+ // document 级视觉兼容插件。向导未打开时会自动进入图片模式;只消费包含
2237
+ // 图片且目标为原生 Composer 的事件,不影响文字粘贴、右侧工作台或其他会话。
2238
+ react.useEffect(() => {
2239
+ if (!enabled) return undefined;
2240
+ const handleWindowPaste = (event) => {
2241
+ const files = event.clipboardData?.files;
2242
+ const hasImage = [...(files || [])].some((file) => String(file?.type ?? '').startsWith('image/'));
2243
+ if (!hasImage) return;
2244
+ const target = event.target;
2245
+ const inComposer = !open && Boolean(target?.closest?.('textarea, [contenteditable="true"], [role="textbox"]'));
2246
+ const inImageStep = open && wizardStep === 1 && mode === 'image';
2247
+ if (!inComposer && !inImageStep) return;
2248
+ event.preventDefault();
2249
+ event.stopImmediatePropagation?.();
2250
+ if (!open) {
2251
+ setMode('image');
2252
+ setWizardStep(1);
2253
+ setOpen(true);
2254
+ setError(null);
2255
+ }
2256
+ acceptImageFiles(files);
2257
+ };
2258
+ window.addEventListener?.('paste', handleWindowPaste, true);
2259
+ return () => window.removeEventListener?.('paste', handleWindowPaste, true);
2260
+ }, [enabled, open, wizardStep, mode, busy, sessionId]);
2261
+
2262
+ if (!enabled) return null;
2263
+
1996
2264
  const choice = (options, values, setter) => options.map(([key, label]) => h('label', {
1997
2265
  key,
1998
2266
  className: `dcAgentPromptChoice${values.includes(key) ? ' is-selected' : ''}`,
@@ -2067,6 +2335,55 @@ window.__ModuleLoader__.load({
2067
2335
  placeholder: '企查查科技股份有限公司\n9132…\n某某信息技术(上海)有限公司',
2068
2336
  onChange: (event) => setRawText(event.target.value),
2069
2337
  }),
2338
+ ) : mode === 'image' ? h('div', {
2339
+ className: `dcAgentPromptImageDrop${imageDropActive ? ' is-active' : ''}`,
2340
+ tabIndex: 0,
2341
+ role: 'group',
2342
+ 'aria-label': '粘贴、拖入或选择企业名单图片',
2343
+ onPaste: (event) => {
2344
+ const files = event.clipboardData?.files;
2345
+ if (files?.length) {
2346
+ event.preventDefault();
2347
+ acceptImageFiles(files);
2348
+ }
2349
+ },
2350
+ onDragOver: (event) => { event.preventDefault(); setImageDropActive(true); },
2351
+ onDragLeave: () => setImageDropActive(false),
2352
+ onDrop: (event) => {
2353
+ event.preventDefault();
2354
+ setImageDropActive(false);
2355
+ acceptImageFiles(event.dataTransfer?.files);
2356
+ },
2357
+ },
2358
+ h('p', null, '可直接在此粘贴图片,或拖入 / 选择 PNG、JPEG、WebP。图片会在对话框显示缩略图,点击可放大。'),
2359
+ imageAttachment ? h('div', { className: 'dcAgentPromptImagePreview' },
2360
+ h('button', {
2361
+ type: 'button', className: 'dcAgentPromptImageThumb',
2362
+ 'aria-label': `放大查看 ${imageFileName}`,
2363
+ onClick: () => setImageLightbox(true),
2364
+ }, imageAttachment.previewUrl ? h('img', { src: imageAttachment.previewUrl, alt: imageFileName }) : '图片'),
2365
+ h('div', { className: 'dcAgentPromptImageMeta' },
2366
+ h('b', null, imageFileName),
2367
+ h('small', null, imageCommand?.state === 'completed'
2368
+ ? `已识别 ${entryCount} 条,下一步逐条核验`
2369
+ : imageCommand?.state === 'running'
2370
+ ? '识别中…'
2371
+ : imageCommand?.state === 'failed'
2372
+ ? '识别失败,可移除后重试'
2373
+ : '已暂存,可继续选择匹配规则和补全字段'),
2374
+ ),
2375
+ h('button', { type: 'button', className: 'dcAgentPromptImageRemove', disabled: busy || imageCommand?.state === 'running', onClick: removeCurrentImage }, '移除'),
2376
+ ) : h('label', { className: 'dcAgentPromptFile' },
2377
+ h('span', null, '选择包含企业名单的图片'),
2378
+ h('input', {
2379
+ type: 'file', accept: 'image/png,image/jpeg,image/webp',
2380
+ disabled: busy, onChange: handleFile,
2381
+ }),
2382
+ ),
2383
+ imageCommand?.state === 'completed' && entries.length ? h('div', { className: 'dcAgentPromptChoices' },
2384
+ entries.slice(0, 12).map((entry, index) => h('span', { key: `${entry}-${index}`, className: 'dcAgentPromptChoice is-selected' }, `${index + 1}. ${entry}`)),
2385
+ entries.length > 12 ? h('span', { className: 'dcAgentPromptChoice' }, `另 ${entries.length - 12} 条`) : null,
2386
+ ) : null,
2070
2387
  ) : h('label', { className: 'dcAgentPromptFile' },
2071
2388
  h('span', null, mode === 'image' ? '选择包含企业名单的图片' : '选择 CSV / XLSX / JSON 名单'),
2072
2389
  h('input', {
@@ -2118,9 +2435,18 @@ window.__ModuleLoader__.load({
2118
2435
  error ? h('p', { className: 'dcAgentPromptError', role: 'alert' }, error) : null,
2119
2436
  h('div', { className: 'dcAgentPromptActions' },
2120
2437
  h('button', { type: 'button', className: 'dcAgentPromptAction', disabled: wizardStep === 1, onClick: () => setWizardStep(Math.max(1, wizardStep - 1)) }, '上一步'),
2121
- h('button', { type: 'button', className: 'dcAgentPromptAction is-primary', disabled: busy, onClick: nextWizard }, busy ? '处理中…' : wizardStep === 4 ? '回填到对话框' : '下一步'),
2438
+ h('button', { type: 'button', className: 'dcAgentPromptAction is-primary', disabled: busy || imageCommand?.state === 'running', onClick: nextWizard }, busy
2439
+ ? '处理中…'
2440
+ : wizardStep === 1 && mode === 'image' && imageCommand?.state === 'running'
2441
+ ? '图片识别中…'
2442
+ : wizardStep === 4 ? '回填到对话框' : '下一步'),
2122
2443
  ),
2123
2444
  ) : null,
2445
+ imageLightbox && imageAttachment?.previewUrl ? h('button', {
2446
+ type: 'button', className: 'dcAgentImageLightbox',
2447
+ 'aria-label': '关闭图片预览',
2448
+ onClick: () => setImageLightbox(false),
2449
+ }, h('img', { src: imageAttachment.previewUrl, alt: imageFileName })) : null,
2124
2450
  );
2125
2451
  }
2126
2452
 
@@ -2143,11 +2469,12 @@ window.__ModuleLoader__.load({
2143
2469
  );
2144
2470
  }
2145
2471
 
2146
- /** M3 · 三工具 tool.call.toolview 卡片元数据(wire 名 → 展示文案)。 */
2472
+ /** 模型工具 tool.call.toolview 卡片元数据(wire 名 → 展示文案)。 */
2147
2473
  const TOOL_VIEW_META = {
2148
2474
  data_clean_rows: { icon: '🧹', label: '数据清洗', hint: '去重 · 剔除非法金额 · 缺失补 0' },
2149
2475
  data_complete_rows: { icon: '🧩', label: '数据补全', hint: '名称 / 金额 / 手机号归一' },
2150
2476
  data_profile: { icon: '🩺', label: '数据体检', hint: '缺失率 · 去重值 · 金额分布' },
2477
+ data_cleaning_extract_image_companies: { icon: '🖼️', label: '图片名单识别', hint: 'OCR · 企业名称 / 信用代码提取 · 人工核验' },
2151
2478
  };
2152
2479
  const TOOL_VIEW_STATE = { running: '运行中', stopped: '已停止', error: '失败', ok: '完成' };
2153
2480
 
@@ -3419,7 +3746,10 @@ window.__ModuleLoader__.load({
3419
3746
  name: 'conversation.input.overlay',
3420
3747
  id: 'data-cleaning-agent-prompt-generator',
3421
3748
  order: 110,
3422
- inject: () => ({ attachImages: (sessionId, files) => attachPromptImages(ctx, sessionId, files) }),
3749
+ inject: () => ({
3750
+ attachImages: (sessionId, files) => attachPromptImages(ctx, sessionId, files),
3751
+ removeImage: (sessionId, attachment) => removePromptImage(ctx, sessionId, attachment),
3752
+ }),
3423
3753
  }, PromptGenerator));
3424
3754
 
3425
3755
  // 会话头部恢复入口:用户关闭右栏后可随时重新打开当前任务工作台。
@@ -3429,9 +3759,9 @@ window.__ModuleLoader__.load({
3429
3759
  order: 110,
3430
3760
  }, WorkbenchHeaderEntry));
3431
3761
 
3432
- // M3:三个工具的 tool.call.toolview 富化卡片(keyed by wire name,替代裸 JSON 摘要)。
3762
+ // 模型工具的 tool.call.toolview 富化卡片(keyed by wire name,替代裸 JSON 摘要)。
3433
3763
  // 说明:分三条独立 inject(而非 generator)——测试 shim 对 inject 回调仅执行一次并 push 其返回值。
3434
- const toolviewKeys = ['data_clean_rows', 'data_complete_rows', 'data_profile'];
3764
+ const toolviewKeys = ['data_clean_rows', 'data_complete_rows', 'data_profile', 'data_cleaning_extract_image_companies'];
3435
3765
  for (const key of toolviewKeys) {
3436
3766
  ctx.slots.inject('tool.call.toolview', () => ctx.slots.register({
3437
3767
  name: 'tool.call.toolview',