dsh-data-cleaning-agent 0.6.0 → 0.6.2

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
@@ -822,34 +822,66 @@ window.__ModuleLoader__.load({
822
822
  const DEFAULT_CLEANING_KEYS = ['clean_name', 'deduplicate', 'validate_identity', 'complete_fields'];
823
823
  const DEFAULT_ENRICHMENT_KEYS = ['credit_no', 'legal_rep', 'reg_capital', 'establish_date', 'reg_status'];
824
824
  const DEFAULT_SESSION_PROMPT = '请帮我清洗并补全企业名单。可点击输入框左上角「提示词生成」录入名单、上传 Excel 或图片,也可直接修改本段任务说明后开始。';
825
- const CLEANING_SESSION_STORAGE_KEY = 'dsh.data-cleaning-agent.sessions.v1';
826
- const CLEANING_SESSION_EVENT = 'dsh:data-cleaning-session-marked';
827
- const cleaningSessionIds = new Set();
825
+ const CLEANING_SESSION_STORAGE_KEYS = [
826
+ 'dsh.data-cleaning-agent.active-session.v2',
827
+ 'dsh.data-cleaning-agent.sessions.v1',
828
+ ];
829
+ const CLEANING_SESSION_EVENT = 'dsh:data-cleaning-session-ownership';
830
+ let activeCleaningSessionId = null;
828
831
 
829
832
  try {
830
- const stored = window.sessionStorage?.getItem(CLEANING_SESSION_STORAGE_KEY);
831
- for (const id of JSON.parse(stored || '[]')) {
832
- if (typeof id === 'string' && id) cleaningSessionIds.add(id);
833
- }
833
+ // 旧版本持久化过 sessionId;但 DSH 会复用空白新会话,无法仅凭 ID 判断入口来源。
834
+ // 子系统归属因此只在当前页面、经菜单显式激活,并在升级时清除所有旧标记。
835
+ for (const key of CLEANING_SESSION_STORAGE_KEYS) window.sessionStorage?.removeItem(key);
834
836
  } catch (_error) {
835
- // sessionStorage 可能被禁用;当前页面内的 Set 仍可工作。
837
+ // sessionStorage 可能被禁用;内存态不受影响。
836
838
  }
837
839
 
838
840
  function isCleaningSession(sessionId) {
839
- return typeof sessionId === 'string' && cleaningSessionIds.has(sessionId);
841
+ return typeof sessionId === 'string' && sessionId === activeCleaningSessionId;
840
842
  }
841
843
 
842
844
  function markCleaningSession(sessionId) {
843
845
  if (typeof sessionId !== 'string' || !sessionId) return;
844
- cleaningSessionIds.add(sessionId);
845
- try {
846
- window.sessionStorage?.setItem(CLEANING_SESSION_STORAGE_KEY, JSON.stringify([...cleaningSessionIds].slice(-20)));
847
- } catch (_error) {
848
- // 不因浏览器存储策略阻断会话。
846
+ activeCleaningSessionId = sessionId;
847
+ if (typeof window.CustomEvent === 'function' && typeof window.dispatchEvent === 'function') {
848
+ window.dispatchEvent(new window.CustomEvent(CLEANING_SESSION_EVENT, { detail: { sessionId, active: true } }));
849
+ }
850
+ }
851
+
852
+ function isKnownCleaningDraft(value) {
853
+ if (value === DEFAULT_SESSION_PROMPT) return true;
854
+ if (typeof value !== 'string') return false;
855
+ return value.startsWith('请执行一项企业名单数据清洗补全任务。')
856
+ && value.includes('企查查连接、套餐额度和费用均由当前用户自己的账号承担。')
857
+ && value.includes('提供结果和待复核清单的导出。');
858
+ }
859
+
860
+ function clearCleaningDraft(ctx, sessionId, onlyDefault = false) {
861
+ if (!sessionId) return false;
862
+ const conversation = typeof ctx.get === 'function' ? ctx.get('conversation') : ctx.conversation;
863
+ const shell = conversation?.input?.shell?.(sessionId);
864
+ if (!shell || typeof shell.setDraft !== 'function') return false;
865
+ if (onlyDefault && !isKnownCleaningDraft(shell.snapshot?.draft)) return false;
866
+ shell.setDraft('');
867
+ return true;
868
+ }
869
+
870
+ function deactivateCleaningSession(sessionId = activeCleaningSessionId) {
871
+ if (!activeCleaningSessionId || (sessionId && sessionId !== activeCleaningSessionId)) return false;
872
+ const previousSessionId = activeCleaningSessionId;
873
+ activeCleaningSessionId = null;
874
+ if (rootWorkbenchActions) {
875
+ stopJobsPolling();
876
+ rootWorkbenchActions.close?.();
877
+ rootWorkbenchActions.setActiveSession?.(null);
849
878
  }
850
879
  if (typeof window.CustomEvent === 'function' && typeof window.dispatchEvent === 'function') {
851
- window.dispatchEvent(new window.CustomEvent(CLEANING_SESSION_EVENT, { detail: { sessionId } }));
880
+ window.dispatchEvent(new window.CustomEvent(CLEANING_SESSION_EVENT, {
881
+ detail: { sessionId: previousSessionId, active: false },
882
+ }));
852
883
  }
884
+ return true;
853
885
  }
854
886
 
855
887
  function useCleaningSession(sessionId) {
@@ -858,14 +890,89 @@ window.__ModuleLoader__.load({
858
890
  );
859
891
  react.useEffect(() => {
860
892
  const handleMarked = (event) => {
861
- if (event?.detail?.sessionId === sessionId) setMarkedSessionId(sessionId);
893
+ const detail = event?.detail ?? {};
894
+ setMarkedSessionId(detail.active === true && detail.sessionId === sessionId ? sessionId : null);
862
895
  };
863
896
  window.addEventListener?.(CLEANING_SESSION_EVENT, handleMarked);
864
897
  return () => window.removeEventListener?.(CLEANING_SESSION_EVENT, handleMarked);
865
898
  }, [sessionId]);
866
899
  // DSH 可能复用同一个 slot component 切换会话;状态必须绑定具体 sessionId,
867
900
  // 避免从清洗会话切回普通会话后仍残留业务首页与提示词入口。
868
- return markedSessionId === sessionId || isCleaningSession(sessionId);
901
+ return markedSessionId === sessionId && isCleaningSession(sessionId);
902
+ }
903
+
904
+ /**
905
+ * 清洗补全是一个显式进入的业务子系统。切换会话、点击新会话或点击侧栏其它业务
906
+ * 入口时立即撤销其激活态,即使 DSH 复用了同一个空白 sessionId 也不会残留首页、
907
+ * 提示词、能力栏或工作台。点击本插件自己的顶部入口不会提前撤销。
908
+ */
909
+ function installSessionOwnershipBridge(ctx) {
910
+ let staleDraftObserver = null;
911
+ let staleDraftObserverTimer = null;
912
+ const clearStaleDefaultDraft = () => {
913
+ if (activeCleaningSessionId) return false;
914
+ const currentSessionId = ctx.sessions?.list?.getSnapshot?.()?.current;
915
+ const cleared = clearCleaningDraft(ctx, currentSessionId, true);
916
+ if (cleared && staleDraftObserver) {
917
+ staleDraftObserver.disconnect();
918
+ staleDraftObserver = null;
919
+ }
920
+ return cleared;
921
+ };
922
+ const leaveSubsystem = () => {
923
+ const sessionId = activeCleaningSessionId;
924
+ if (sessionId) clearCleaningDraft(ctx, sessionId);
925
+ deactivateCleaningSession(sessionId);
926
+ };
927
+ const handleSidebarNavigation = (event) => {
928
+ if (!activeCleaningSessionId) return;
929
+ const target = event?.target;
930
+ if (!target || typeof target.closest !== 'function') return;
931
+ if (target.closest('.dcAgentLauncher, .dcAgentExperience, .dcAgentPromptPopover, .dcAgentWorkbench, .dcAgentHeaderAction')) return;
932
+
933
+ const actionable = target.closest('button, a, [role="button"]');
934
+ if (!actionable) return;
935
+ const workspaceSlot = document.querySelector?.(SIDEBAR_WORKSPACES_SELECTOR);
936
+ const sidebarRoot = workspaceSlot?.closest?.('aside, nav, [data-slot="sidebar"], [class*="sidebar"]')
937
+ ?? workspaceSlot?.parentElement;
938
+ const label = `${actionable.getAttribute?.('aria-label') ?? ''} ${actionable.textContent ?? ''}`.trim();
939
+ const sessionOrAgentEntry = /新建?会话|新会话|new\s+session|智能体|agent|尽调/i.test(label);
940
+ if (sidebarRoot?.contains?.(actionable) || sessionOrAgentEntry) leaveSubsystem();
941
+ };
942
+
943
+ document.addEventListener?.('click', handleSidebarNavigation, true);
944
+ // 升级 / 刷新后不恢复子系统所有权;仅清理由旧版本遗留且仍未被用户编辑的默认文案。
945
+ clearStaleDefaultDraft();
946
+ // DSH 会在插件 apply() 之后异步恢复 composer draft。在首屏稳定期内
947
+ // 监听 DOM 变化并仅清理完全匹配本插件默认文案,或同时命中
948
+ // 提示词向导固定首尾与费用声明签名的草稿;不触碰用户自写内容。
949
+ if (typeof MutationObserver === 'function' && document.documentElement) {
950
+ staleDraftObserver = new MutationObserver(clearStaleDefaultDraft);
951
+ staleDraftObserver.observe(document.documentElement, {
952
+ childList: true,
953
+ characterData: true,
954
+ subtree: true,
955
+ });
956
+ staleDraftObserverTimer = setTimeout(() => {
957
+ staleDraftObserver?.disconnect();
958
+ staleDraftObserver = null;
959
+ staleDraftObserverTimer = null;
960
+ }, 10_000);
961
+ }
962
+ const unsubscribe = ctx.sessions?.list?.subscribe?.((snapshot) => {
963
+ const nextSessionId = snapshot?.current;
964
+ if (activeCleaningSessionId && nextSessionId && nextSessionId !== activeCleaningSessionId) {
965
+ leaveSubsystem();
966
+ } else if (!activeCleaningSessionId) {
967
+ clearStaleDefaultDraft();
968
+ }
969
+ });
970
+ return () => {
971
+ document.removeEventListener?.('click', handleSidebarNavigation, true);
972
+ staleDraftObserver?.disconnect();
973
+ if (staleDraftObserverTimer !== null) clearTimeout(staleDraftObserverTimer);
974
+ if (typeof unsubscribe === 'function') unsubscribe();
975
+ };
869
976
  }
870
977
 
871
978
  function optionLabels(options, keys) {
@@ -1001,6 +1108,18 @@ window.__ModuleLoader__.load({
1001
1108
  return requestJson(path, body === undefined ? 'GET' : 'POST', body);
1002
1109
  }
1003
1110
 
1111
+ async function waitForQccCommand(commandId, attempts = 180) {
1112
+ for (let attempt = 0; attempt < attempts; attempt += 1) {
1113
+ const response = await api(`/data-cleaning/api/g5/commands/${encodeURIComponent(commandId)}`);
1114
+ const command = response?.command;
1115
+ if (command?.state === 'completed' || command?.state === 'failed') return command;
1116
+ await new Promise((resolve) => setTimeout(resolve, 1000));
1117
+ }
1118
+ const error = new Error('等待智能体执行企查查任务超时;可稍后从任务历史恢复结果。');
1119
+ error.code = 'QCC_COMMAND_TIMEOUT';
1120
+ throw error;
1121
+ }
1122
+
1004
1123
  function cacheWorkflowTask(actions, sessionId, task) {
1005
1124
  if (!task) return null;
1006
1125
  if (sessionId) workflowTaskBySession.set(String(sessionId), task);
@@ -1251,6 +1370,15 @@ window.__ModuleLoader__.load({
1251
1370
  return images.length;
1252
1371
  }
1253
1372
 
1373
+ async function sendQccAgentCommand(ctx, sessionId, prompt) {
1374
+ const scoped = typeof ctx.sessions?.scope === 'function' ? ctx.sessions.scope(sessionId) : null;
1375
+ const sessionConversation = scoped?.get?.('conversation');
1376
+ if (!sessionConversation || typeof sessionConversation.send !== 'function') {
1377
+ throw new Error('当前 DSH 版本没有可用的 Session conversation.send 能力');
1378
+ }
1379
+ await sessionConversation.send(prompt);
1380
+ }
1381
+
1254
1382
  function capabilityIcon(item, size = 16) {
1255
1383
  return typeof item.icon === 'function'
1256
1384
  ? h(item.icon, { size, 'aria-hidden': 'true' })
@@ -1453,7 +1581,12 @@ window.__ModuleLoader__.load({
1453
1581
 
1454
1582
  /**
1455
1583
  * DSH 当前只公开 hero brand.mark,没有 headline slot。这里仅在本插件会话且 blank hero
1456
- * 阶段精确替换原生中英文标题,并在卸载时恢复,避免污染普通会话。
1584
+ * 阶段替换标题,并在卸载时恢复,避免污染普通会话。
1585
+ *
1586
+ * 某些第三方插件会全局改写 hero headline、向所有会话注入自己的 dock。清洗会话必须
1587
+ * 仍然保持自己的标题和单一业务入口,因此用 headline class 作为最后降级,并只隐藏与
1588
+ * 当前清洗 hero 同一 composer stack 内、带明确「尽调类型」aria 标签的已知外来 dock。
1589
+ * 所有变更均记录原值并在会话离开 / 组件卸载时恢复。
1457
1590
  */
1458
1591
  function rewriteHeroChrome(sessionId, enabled) {
1459
1592
  if (!enabled || typeof document === 'undefined' || typeof document.querySelectorAll !== 'function') return () => {};
@@ -1461,32 +1594,63 @@ window.__ModuleLoader__.load({
1461
1594
  .find((element) => element?.dataset?.sessionId === sessionId);
1462
1595
  const hero = marker?.closest?.('[data-phase="hero"]');
1463
1596
  if (!hero) return () => {};
1464
- const spans = [...hero.querySelectorAll('span')];
1465
- const title = spans.find((element) => ['探索未至之境', 'Into the Unknown'].includes(element.textContent?.trim()))
1466
- ?? spans.find((element) => element.dataset?.dcAgentHeroTitle === 'true');
1467
- const badge = spans.find((element) => ['预览版', 'Preview'].includes(element.textContent?.trim()))
1468
- ?? spans.find((element) => element.dataset?.dcAgentHeroBadge === 'true');
1469
- if (title) {
1470
- title.dataset.dcAgentHeroTitle = 'true';
1471
- title.dataset.dcAgentOriginalText ??= title.textContent ?? '';
1472
- title.textContent = '数据清洗补全智能体';
1473
- }
1474
- if (badge) {
1475
- badge.dataset.dcAgentHeroBadge = 'true';
1476
- badge.dataset.dcAgentOriginalDisplay ??= badge.style?.display ?? '';
1477
- if (badge.style) badge.style.display = 'none';
1478
- }
1597
+ const changedTitles = new Map();
1598
+ const changedBadges = new Map();
1599
+ const hiddenForeignDocks = new Map();
1600
+ const composerStack = marker?.parentElement?.parentElement;
1601
+
1602
+ const fix = () => {
1603
+ const spans = [...hero.querySelectorAll('span')];
1604
+ const title = spans.find((element) => element.dataset?.dcAgentHeroTitle === 'true')
1605
+ ?? spans.find((element) => ['探索未至之境', 'Into the Unknown'].includes(element.textContent?.trim()))
1606
+ ?? hero.querySelector?.('[class*="headlineText"]');
1607
+ const badge = spans.find((element) => element.dataset?.dcAgentHeroBadge === 'true')
1608
+ ?? spans.find((element) => ['预览版', 'Preview'].includes(element.textContent?.trim()));
1609
+ if (title) {
1610
+ if (!changedTitles.has(title)) changedTitles.set(title, title.textContent ?? '');
1611
+ title.dataset.dcAgentHeroTitle = 'true';
1612
+ if (title.textContent !== '数据清洗补全智能体') title.textContent = '数据清洗补全智能体';
1613
+ }
1614
+ if (badge) {
1615
+ if (!changedBadges.has(badge)) changedBadges.set(badge, badge.style?.display ?? '');
1616
+ badge.dataset.dcAgentHeroBadge = 'true';
1617
+ if (badge.style && badge.style.display !== 'none') badge.style.display = 'none';
1618
+ }
1619
+
1620
+ const foreignSurfaces = composerStack?.querySelectorAll?.('[aria-label="尽调类型"], .qccDock') ?? [];
1621
+ for (const foreignSurface of foreignSurfaces) {
1622
+ let hideTarget = foreignSurface;
1623
+ while (hideTarget?.parentElement
1624
+ && hideTarget.parentElement !== composerStack
1625
+ && hideTarget.parentElement !== marker.parentElement) {
1626
+ hideTarget = hideTarget.parentElement;
1627
+ }
1628
+ // DSH 的 list slot 可能把多个插件放进同一个 data-slot 容器。此时只能隐藏
1629
+ // 对方自己的根节点,不能隐藏共享 slot;独立 host 则隐藏其直接容器。
1630
+ if ((hideTarget?.parentElement === composerStack || hideTarget?.parentElement === marker.parentElement)
1631
+ && !hideTarget.contains?.(marker)) {
1632
+ if (!hiddenForeignDocks.has(hideTarget)) hiddenForeignDocks.set(hideTarget, hideTarget.style?.display ?? '');
1633
+ if (hideTarget.style && hideTarget.style.display !== 'none') hideTarget.style.display = 'none';
1634
+ }
1635
+ }
1636
+ };
1637
+
1638
+ fix();
1639
+ const observer = typeof MutationObserver === 'function' ? new MutationObserver(fix) : null;
1640
+ observer?.observe?.(hero, { childList: true, subtree: true });
1479
1641
  return () => {
1480
- if (title?.dataset?.dcAgentOriginalText !== undefined) {
1481
- title.textContent = title.dataset.dcAgentOriginalText;
1482
- delete title.dataset.dcAgentOriginalText;
1642
+ observer?.disconnect?.();
1643
+ for (const [title, originalText] of changedTitles) {
1644
+ if (title.textContent === '数据清洗补全智能体') title.textContent = originalText;
1483
1645
  delete title.dataset.dcAgentHeroTitle;
1484
1646
  }
1485
- if (badge?.dataset?.dcAgentOriginalDisplay !== undefined) {
1486
- if (badge.style) badge.style.display = badge.dataset.dcAgentOriginalDisplay;
1487
- delete badge.dataset.dcAgentOriginalDisplay;
1647
+ for (const [badge, originalDisplay] of changedBadges) {
1648
+ if (badge.style) badge.style.display = originalDisplay;
1488
1649
  delete badge.dataset.dcAgentHeroBadge;
1489
1650
  }
1651
+ for (const [foreignDock, originalDisplay] of hiddenForeignDocks) {
1652
+ if (foreignDock.style) foreignDock.style.display = originalDisplay;
1653
+ }
1490
1654
  };
1491
1655
  }
1492
1656
 
@@ -1942,6 +2106,7 @@ window.__ModuleLoader__.load({
1942
2106
  /** 右侧非模态工作台:中央区域始终保留 DSH 原生会话。 */
1943
2107
  function WorkbenchDrawer(props) {
1944
2108
  const { useStore, actions } = props;
2109
+ const sendSessionCommand = props.sendSessionCommand;
1945
2110
  const open = useStore((state) => state.open);
1946
2111
  const step = useStore((state) => state.step);
1947
2112
  const expanded = useStore((state) => state.expanded);
@@ -2088,6 +2253,7 @@ window.__ModuleLoader__.load({
2088
2253
  if (!open) return null;
2089
2254
 
2090
2255
  const hasData = dataset !== null && dataset.rowCount > 0;
2256
+ const requiresQcc = objectives.includes('validate_identity') || objectives.includes('complete_fields');
2091
2257
  const cachedTask = workflowTaskBySession.get(String(activeSessionId || 'unassigned')) ?? workflowTask;
2092
2258
  const runtimeKey = cachedTask?.id ?? `session:${activeSessionId || 'unassigned'}`;
2093
2259
  const runtime = runtimeFor(runtimeKey);
@@ -2165,7 +2331,7 @@ window.__ModuleLoader__.load({
2165
2331
  actions.setError(null);
2166
2332
  try {
2167
2333
  await performProfile(cachedTask);
2168
- actions.setStep('match');
2334
+ actions.setStep(requiresQcc ? 'match' : 'enrich');
2169
2335
  } catch (err) {
2170
2336
  actions.setError(err instanceof Error ? err.message : String(err));
2171
2337
  } finally {
@@ -2246,11 +2412,13 @@ window.__ModuleLoader__.load({
2246
2412
 
2247
2413
  const estimateQcc = async () => {
2248
2414
  const uniqueCompanies = new Set(runtime.rows.map((row) => String(row?.[nameField] ?? '').trim()).filter(Boolean)).size;
2415
+ const requiresProfile = fieldSelection.some((field) => ['company_profile', 'industry_large', 'industry_middle'].includes(field));
2416
+ const callsPerCompany = requiresProfile ? 3 : 2;
2249
2417
  actions.setQccEstimate({
2250
2418
  uniqueCompanies,
2251
- tools: ['主体查询', '工商信息'],
2252
- estimatedCalls: uniqueCompanies * 2,
2253
- maxCalls: 200,
2419
+ tools: requiresProfile ? ['主体查询', '工商信息', '企业画像'] : ['主体查询', '工商信息'],
2420
+ estimatedCalls: uniqueCompanies * callsPerCompany,
2421
+ maxCalls: 300,
2254
2422
  withinLimit: runtime.rows.length <= 100,
2255
2423
  estimateType: 'upper-bound',
2256
2424
  });
@@ -2302,24 +2470,44 @@ window.__ModuleLoader__.load({
2302
2470
  return current;
2303
2471
  };
2304
2472
 
2473
+ const executePreparedQccCommand = async (payload) => {
2474
+ if (!activeSessionId || typeof sendSessionCommand !== 'function') {
2475
+ throw new Error('当前会话无法提交数据清洗补全企查查任务');
2476
+ }
2477
+ const prepared = await api('/data-cleaning/api/g5/commands', {
2478
+ ...payload,
2479
+ confirmPaidCalls: true,
2480
+ });
2481
+ const command = prepared?.command;
2482
+ if (!command?.commandId || !command?.prompt) throw new Error('Host 未返回有效的企查查任务命令');
2483
+ await sendSessionCommand(activeSessionId, command.prompt);
2484
+ const completedCommand = await waitForQccCommand(command.commandId);
2485
+ if (completedCommand.state === 'failed') {
2486
+ const commandError = new Error(completedCommand.error?.message || '智能体企查查任务执行失败');
2487
+ commandError.code = completedCommand.error?.code;
2488
+ throw commandError;
2489
+ }
2490
+ if (!completedCommand.run) throw new Error('智能体企查查任务完成但结果已失效');
2491
+ return completedCommand.run;
2492
+ };
2493
+
2305
2494
  const runQcc = async () => {
2306
2495
  if (busy || !qccEstimate || !paidConfirmed || !qccEstimate.withinLimit) return;
2307
2496
  actions.setBusy(true);
2308
2497
  actions.setError(null);
2309
2498
  try {
2310
- await workflowAction(actions, activeSessionId, cachedTask, 'match-start');
2311
- const key = `g5-ui-${Date.now()}-${Math.random().toString(36).slice(2, 10)}`;
2312
- const r = await api('/data-cleaning/api/g5/enrich', {
2499
+ const current = await workflowAction(actions, activeSessionId, cachedTask, 'match-start');
2500
+ const r = await executePreparedQccCommand({
2501
+ kind: 'enrich',
2502
+ taskId: current.id,
2313
2503
  rows: runtime.rows,
2314
2504
  headers: runtime.headers,
2315
2505
  nameField,
2506
+ fieldSelection,
2316
2507
  includeRisk: false,
2317
2508
  concurrency: 2,
2318
- confirmPaidCalls: true,
2319
- idempotencyKey: key,
2320
2509
  });
2321
- if (r && r.ok !== false) await applyQccRun(r);
2322
- else actions.setError((r && (r.message || r.error)) || '企业匹配补全失败');
2510
+ await applyQccRun(r);
2323
2511
  } catch (err) {
2324
2512
  if (['QCC_NOT_CONNECTED', 'QCC_TOOL_UNAVAILABLE', 'QCC_AUTH_REQUIRED'].includes(err?.code)) {
2325
2513
  try { await workflowAction(actions, activeSessionId, cachedTask, 'authorization-required'); } catch (_workflowError) {}
@@ -2335,15 +2523,15 @@ window.__ModuleLoader__.load({
2335
2523
  actions.setBusy(true);
2336
2524
  actions.setError(null);
2337
2525
  try {
2338
- const r = await api('/data-cleaning/api/g5/resolve', {
2526
+ const task = workflowTaskBySession.get(String(activeSessionId || 'unassigned')) ?? cachedTask;
2527
+ const r = await executePreparedQccCommand({
2528
+ kind: 'resolve',
2529
+ taskId: task.id,
2339
2530
  runId: qccRun.runId,
2340
2531
  companyName: item.companyName,
2341
2532
  selectedCreditNo: candidate.creditNo,
2342
- confirmPaidCalls: true,
2343
- idempotencyKey: `g5-resolve-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
2344
2533
  });
2345
- if (r && r.ok !== false) await applyQccRun(r);
2346
- else actions.setError((r && (r.message || r.error)) || '候选确认失败');
2534
+ await applyQccRun(r);
2347
2535
  } catch (err) {
2348
2536
  actions.setError(err instanceof Error ? err.message : String(err));
2349
2537
  } finally {
@@ -2358,14 +2546,14 @@ window.__ModuleLoader__.load({
2358
2546
  actions.setBusy(true);
2359
2547
  actions.setError(null);
2360
2548
  try {
2361
- const r = await api('/data-cleaning/api/g5/retry', {
2549
+ const task = workflowTaskBySession.get(String(activeSessionId || 'unassigned')) ?? cachedTask;
2550
+ const r = await executePreparedQccCommand({
2551
+ kind: 'retry',
2552
+ taskId: task.id,
2362
2553
  runId: qccRun.runId,
2363
2554
  companyNames: names,
2364
- confirmPaidCalls: true,
2365
- idempotencyKey: `g5-retry-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
2366
2555
  });
2367
- if (r && r.ok !== false) await applyQccRun(r);
2368
- else actions.setError((r && (r.message || r.error)) || '失败项重试失败');
2556
+ await applyQccRun(r);
2369
2557
  } catch (err) {
2370
2558
  actions.setError(err instanceof Error ? err.message : String(err));
2371
2559
  } finally {
@@ -2526,9 +2714,23 @@ window.__ModuleLoader__.load({
2526
2714
  }
2527
2715
  };
2528
2716
 
2717
+ const displayStatValue = (value) => {
2718
+ if (typeof value === 'string' || typeof value === 'number') return value;
2719
+ if (typeof value === 'bigint' || typeof value === 'boolean') return String(value);
2720
+ return '—';
2721
+ };
2722
+ // 浏览器运行时只保留当前会话的原始明细;进程重启后,已完成任务应从
2723
+ // Host 持久化摘要恢复计数,不能因 qccRun 仅存在于内存而显示“—”。
2724
+ const qccEnrichedCount = qccRun
2725
+ ? qccRun.summary?.enriched ?? 0
2726
+ : cachedTask?.enrichmentSummary?.completed;
2727
+ const qccReviewCount = qccRun
2728
+ ? qccRun.summary?.ambiguous ?? 0
2729
+ : cachedTask?.enrichmentSummary?.reviewRequired
2730
+ ?? cachedTask?.matchSummary?.reviewRequired;
2529
2731
  const stat = (label, value, tone) => h('div', { className: 'dcAgentCard' },
2530
2732
  h('span', null, label),
2531
- h('b', { className: tone ? `is-${tone}` : null }, value)
2733
+ h('b', { className: tone ? `is-${tone}` : null }, displayStatValue(value))
2532
2734
  );
2533
2735
  const uiFieldGroups = Array.isArray(workflowContract?.fieldCatalog)
2534
2736
  ? workflowContract.fieldCatalog.map((group) => [group.id, group.label, (group.fields || []).map((field) => [field.id, field.label])])
@@ -2645,7 +2847,12 @@ window.__ModuleLoader__.load({
2645
2847
  ))),
2646
2848
  ) : null,
2647
2849
  h('div', { className: 'dcAgentRow' },
2648
- h('button', { type: 'button', className: 'dcAgentButton is-primary', disabled: busy, onClick: () => actions.setStep('match') }, '下一步:匹配核验'),
2850
+ h('button', {
2851
+ type: 'button',
2852
+ className: 'dcAgentButton is-primary',
2853
+ disabled: busy,
2854
+ onClick: () => actions.setStep(requiresQcc ? 'match' : 'enrich'),
2855
+ }, requiresQcc ? '下一步:匹配核验' : '下一步:本地清洗补全'),
2649
2856
  ),
2650
2857
  ) : h('div', { className: 'dcAgentRow' },
2651
2858
  h('button', { type: 'button', className: 'dcAgentButton is-primary', disabled: busy || !hasData, 'aria-label': '生成体检报告', onClick: runProfile }, busy ? '体检中…' : '生成体检报告'),
@@ -2737,8 +2944,8 @@ window.__ModuleLoader__.load({
2737
2944
  stat('输入行数', dataset ? dataset.rowCount : '—'),
2738
2945
  stat('清洗保留', clean ? clean.kept : '—', clean && clean.kept > 0 ? 'good' : null),
2739
2946
  stat('本地补全', complete ? complete.completed : '—', complete && complete.completed > 0 ? 'good' : null),
2740
- stat('QCC 已补全', qccRun ? qccRun.summary?.enriched ?? 0 : '—', qccRun && qccRun.summary?.enriched > 0 ? 'good' : null),
2741
- stat('待核验', qccRun ? qccRun.summary?.ambiguous ?? 0 : '—', qccRun && qccRun.summary?.ambiguous > 0 ? 'warn' : null),
2947
+ stat('QCC 已补全', qccEnrichedCount, qccEnrichedCount > 0 ? 'good' : null),
2948
+ stat('待核验', qccReviewCount, qccReviewCount > 0 ? 'warn' : null),
2742
2949
  ),
2743
2950
  h('div', { className: 'dcAgentRow' }, h('button', { type: 'button', className: 'dcAgentButton is-primary', onClick: () => actions.setStep('download') }, '进入下载数据')),
2744
2951
  );
@@ -2749,8 +2956,8 @@ window.__ModuleLoader__.load({
2749
2956
  h('p', { className: 'dcAgentHint' }, '结果与异常清单由 Host 生成 CSV/XLSX 四件套并耐久保存。任务或插件重启后,可从任务历史继续下载;无需再次调用企查查。'),
2750
2957
  h('div', { className: 'dcAgentGrid' },
2751
2958
  stat('输入行数', dataset ? dataset.rowCount : workflowTask?.source?.rowCount ?? '—'),
2752
- stat('匹配补全', qccRun ? qccRun.summary?.enriched ?? 0 : '—', qccRun && qccRun.summary?.enriched > 0 ? 'good' : null),
2753
- stat('待核验', qccRun ? qccRun.summary?.ambiguous ?? 0 : '—', qccRun && qccRun.summary?.ambiguous > 0 ? 'warn' : null),
2959
+ stat('匹配补全', qccEnrichedCount, qccEnrichedCount > 0 ? 'good' : null),
2960
+ stat('待核验', qccReviewCount, qccReviewCount > 0 ? 'warn' : null),
2754
2961
  stat('任务状态', WORKFLOW_STATE_LABELS[cachedTask?.state] || cachedTask?.state || '进行中'),
2755
2962
  ),
2756
2963
  availableArtifacts.length ? h('div', { className: 'dcAgentArtifactList' },
@@ -2897,6 +3104,7 @@ window.__ModuleLoader__.load({
2897
3104
  try {
2898
3105
  const workbenchStore = createWorkbenchStore();
2899
3106
  ctx.effect(() => installUiStyles(), 'data-cleaning-agent: UI styles');
3107
+ ctx.effect(() => installSessionOwnershipBridge(ctx), 'data-cleaning-agent: session ownership');
2900
3108
 
2901
3109
  // 右侧工作台:additive overlay,不替换 DSH 单占位 details 面板。
2902
3110
  ctx.slots.inject('shell.overlay', () => ctx.slots.register({
@@ -2904,6 +3112,9 @@ window.__ModuleLoader__.load({
2904
3112
  id: 'data-cleaning-agent',
2905
3113
  order: 200,
2906
3114
  store: workbenchStore,
3115
+ inject: () => ({
3116
+ sendSessionCommand: (sessionId, prompt) => sendQccAgentCommand(ctx, sessionId, prompt),
3117
+ }),
2907
3118
  }, WorkbenchDrawer));
2908
3119
 
2909
3120
  // 左栏:footer 只托管生命周期和 Portal 降级;实际入口显示在工作区列表前。
@@ -2961,11 +3172,17 @@ window.__ModuleLoader__.load({
2961
3172
  // 测试用纯函数,不构成 Host / DSH 稳定 API。
2962
3173
  exports.__testing = {
2963
3174
  buildTaskPrompt,
3175
+ clearCleaningDraft,
2964
3176
  entriesToDataset,
2965
3177
  extractPromptEntries,
2966
3178
  guessMappings,
3179
+ deactivateCleaningSession,
3180
+ installSessionOwnershipBridge,
3181
+ isCleaningSession,
3182
+ isKnownCleaningDraft,
2967
3183
  markCleaningSession,
2968
3184
  qualitySummaryFor,
3185
+ rewriteHeroChrome,
2969
3186
  ensureWorkflowTask,
2970
3187
  queueWorkflowOperation,
2971
3188
  };
package/lib/engine.js CHANGED
@@ -221,7 +221,7 @@ export function completeRows(rows, opts = {}) {
221
221
 
222
222
  return {
223
223
  total: input.length,
224
- completed: completed.length,
224
+ completedCount: completed.length,
225
225
  incompleteCount: incomplete.length,
226
226
  fillStats,
227
227
  incomplete,
package/lib/index.js CHANGED
@@ -60,11 +60,16 @@ export function apply(ctx, config) {
60
60
  // 3. web 半区(仅 web 组合存在;headless 组合无 webServer/webRuntime,inject 会失败)
61
61
  try {
62
62
  ctx.inject(['webServer', 'webRuntime', 'tools', 'skills', 'jobs', 'storageDomain', 'fs'], (wctx) => {
63
- const dispose = mountWebRoutes(wctx, { logger: ctx.logger, report, TOOL_NAME: TOOL_CLEAN, SKILL_NAME });
64
- if (typeof dispose === 'function' && typeof ctx.effect === 'function') {
65
- ctx.effect(() => () => dispose(), 'data-cleaning-agent: web routes');
63
+ try {
64
+ const dispose = mountWebRoutes(wctx, { logger: ctx.logger, report, TOOL_NAME: TOOL_CLEAN, SKILL_NAME });
65
+ if (typeof dispose === 'function' && typeof ctx.effect === 'function') {
66
+ ctx.effect(() => () => dispose(), 'data-cleaning-agent: web routes');
67
+ }
68
+ report.webMounted = true;
69
+ } catch (error) {
70
+ report.webSkipped = true;
71
+ console.warn(`[dc-agent] web half failed during deferred mount: ${error instanceof Error ? error.stack : String(error)}`);
66
72
  }
67
- report.webMounted = true;
68
73
  });
69
74
  } catch (error) {
70
75
  report.webSkipped = true;
package/lib/jobs.js CHANGED
@@ -35,7 +35,7 @@ export function runSync(kind, rows, opts = {}) {
35
35
  }
36
36
  case 'complete': {
37
37
  const r = completeRows(rows, opts);
38
- return { kind, summary: { total: r.total, completed: r.completed, incompleteCount: r.incompleteCount, name: r.fillStats.name, amount: r.fillStats.amount, phoneNormalized: r.fillStats.phoneNormalized }, rows: r.completed, headers: opts.headers ?? [] };
38
+ return { kind, summary: { total: r.total, completed: r.completedCount, incompleteCount: r.incompleteCount, name: r.fillStats.name, amount: r.fillStats.amount, phoneNormalized: r.fillStats.phoneNormalized }, rows: r.completed, headers: opts.headers ?? [] };
39
39
  }
40
40
  case 'profile': {
41
41
  const r = profileRows(rows, opts);