dsh-data-cleaning-agent 0.9.15 → 0.9.16

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
@@ -1,3 +1,10 @@
1
+ # 0.9.16 · 2026-09-15
2
+
3
+ - UX-49:仅清洗入口新建的独立 Session,通过 Host 原生草稿接口预填一次;空草稿、附件/引用、输入就绪、IME、草稿版本与晚到竞态二次核验,不自动发送、建业务任务或展开工作台。
4
+ - 用户编辑/主动清空、旧会话、刷新、重挂载和切回不补回、不清理草稿;保留 UX-48 接纳后开台与 settling→hero 品牌修复。
5
+ - 初始引导采用稳定模板 ID/版本和精确文本指纹;向导排除未改模板,修改后的内容不整体忽略,已有草稿提供替换/追加/取消,异步回填前重新核验。
6
+ - 未补占位符或缺名单/待补字段,在清洗业务 Skill 中优先澄清,不调 OAuth/OCR/MCP 或创建 workflow;不拦截 DSH 全局发送。
7
+
1
8
  # 0.9.15 · 2026-09-14
2
9
 
3
10
  - 保留 0.9.14 的 Host 驱动单向工作流、导航保护、导入核验和紧凑状态区功能。
package/README.en.md CHANGED
@@ -10,7 +10,9 @@ 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.15** (stable release)
13
+ > Current source version / 当前源码版本: **0.9.16** (stable release)
14
+
15
+ 0.9.16 implements UX-49: a one-time editable native guide for new cleaning-entry Sessions, with existing text, attachment, IME and asynchronous-race guards. Clearing, refreshing or returning never restores it. Initialization does not send, call tools or open the workbench. Incomplete templates request the missing list and fields first. See the [adoption record](docs/UNIFIED-HOME-1.5.4-ADOPTION.md) for isolated DSH evidence and unverified boundaries.
14
16
 
15
17
  0.9.15 releases the Host-driven forward-only workflow across import, rules, entity matching, enrichment, and download. Future stages provide explicit locked feedback, while completed stages remain viewable without business-state mutations. Imported rows stay visible for full review before the bottom confirmation advances to rules. The compact status area reports stage, source, time, scope, and explicit result counts. The 0.9.13 submission-admission, background-session isolation, and manual-collapse guarantees remain intact. Start a new cleaning Session after upgrading; legacy Session migration is outside this release's acceptance scope.
16
18
 
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.15
12
+ dsh plugin --profile web add dsh-data-cleaning-agent@0.9.16
13
13
  ```
14
14
 
15
15
  请先满足下文的 DSH、连接器及侧边栏依赖要求;安装后完整停止并重启对应 Profile。
@@ -27,7 +27,9 @@ dsh plugin --profile web add dsh-data-cleaning-agent@0.9.15
27
27
 
28
28
  > 在 DeepSeek Harness 中清洗、补全、画像企业名单数据的智能体插件:本地 CSV/XLSX/JSON 引擎 + 可选企查查(Qichacha/QCC)MCP 企业数据补全,由企查查(Qichacha/QCC)团队发起并维护。
29
29
  >
30
- > 当前源码版本 / Current source version: **0.9.15**(正式版本)
30
+ > 当前源码版本 / Current source version: **0.9.16**(正式版本)
31
+
32
+ 0.9.16 实施 UX-49:首页仅为清洗入口新建的会话预填一次原生可编辑引导,保护已有文字、附件、中文组合输入与异步竞态。主动清空、刷新或切回不会补回;不自动发送、调用工具或展开工作台。未补全模板先澄清名单和字段。真实 DSH 隔离验收及未验证边界见[规范采用记录](docs/UNIFIED-HOME-1.5.4-ADOPTION.md)。
31
33
 
32
34
  0.9.15 发布 Host 驱动的单向工作流:统一“导入与核验 → 规则与体检 → 主体匹配 → 字段补全 → 结果下载”五步导航。未来节点给出明确锁定反馈,历史节点保留查看但不挂载业务写操作;导入后先核验完整名单,再从表格底部确认进入规则页。紧凑状态区展示节点、来源、时间、任务范围和明确结果计数。0.9.13 的真实提交接纳、后台会话隔离与手动收起保护保持不变。升级后请新建清洗会话验证,旧 Session 迁移不在本版验收范围。
33
35
 
@@ -206,3 +206,10 @@ Host 制品下载按钮。alpha.2 继续只检查 Host/路由/制品 Bridge,
206
206
  XLSX 因此以 Base64 文本落盘。未来切换二进制 seam 必须保留旧 `v1` 制品读取兼容。
207
207
  - v2 已生成制品可跨 Host 重启恢复,但浏览器 runtime 中尚未导出的原始行不会持久化;恢复中途任务
208
208
  仍需用户重新提供输入。制品只在当前工作区,不承诺跨设备同步。
209
+ # UX-49 原生初始草稿兼容边界(0.9.16,2026-09-15)
210
+
211
+ - 采用记录见 `UNIFIED-HOME-1.5.4-ADOPTION.md` 的 v1.5.6 小节。仅新建清洗 Session 初始化,旧 Session/刷新/重挂载只恢复归属,不迁移或补回草稿。
212
+ - 本机核对 DSH 0.1.2-rc.1 的公开 `conversation.input.shell(sessionId)`:`snapshot.draft / draftRev / phase / imageIds / occurrences` 与 `setDraft`。缺失任一必要快照能力时保守跳过,不使用 DOM 空文本推断可写,不读取私有 editor。
213
+ - Host InputState 没有 IME 字段:使用 compositionstart/end 与入口异步期间的 beforeinput/input/paste/drop 事件,仅用于取消本插件初始化,不阻断默认事件、不修改其他产品。公开 shell 可在新 Session 的输入 DOM 挂载前使用;不把 hero/settling 或 session.blank 当草稿就绪证据。
214
+ - 未填写的模板发送由本业务 Skill 澄清;不改 Host submit/beginSubmission 全局语义。UX-48 原有 observed 接纳桥保持独立,草稿写入不产生业务接纳事件。
215
+ - 本轮不升级 DSH、不修改正式 Profile、不请求 QCC/OCR/模型凭据。无法验证的 Host 版本或真实模型决策明确保留为外部边界。
@@ -1,4 +1,25 @@
1
- # DSH-UX-001 v1.5.4 采用记录
1
+ # DSH-UX-001 首页规范采用记录
2
+
3
+ ## v1.5.6 / UX49-CLEAN(2026-09-15,0.9.16)
4
+
5
+ 发布授权补充:用户于实施与 PR 验证后明确要求 commit、push、tag、npm,按仓库既有 CI、合并及 OIDC 发布流程推进 0.9.16;原实施阶段仅授权 PR 的边界作为历史记录保留。
6
+
7
+ 依据共享 `DSH智能体开发交互规范方案.md` 的 UX-49、§11.1、§14,并核对 UX-48;协同清单 `DSH-UX-049-四智能体首页初始引导协同.md` 及总账同日交接。这里只记录采用与差异,不复制规范全文。
8
+
9
+ - 审计基线:remote main `fc7d57240b843e1a57998cf0ebbe4f39c086befb`,npm latest 0.9.15 / gitHead `6d8b37a7a0efae1aa2fddc72d61fc5e732f3231f`。原仓 main 的未提交 client/test 修改保持原样,另建独立分支。
10
+ - 已有正确项:独立清洗 Session namespace、宿主原生输入接口、实际接纳后开台、标题 settling→hero 修复、历史来源只读与主结果/报告分层。
11
+ - 真实缺陷:入口无条件 setDraft;普通会话异步恢复时按宽泛签名清理草稿;已有业务入口再次点击会开台;向导缺少已有内容确认与晚到写入检查。以上已收紧。
12
+ - 初始模板 `dsh-initial-draft/data-cleaning/1` / version 1,UTF-8 SHA-256 `2b84b86f669e64e970381e4c7ab8547c77d5e10ee7d5441543c893b9f1272e2b`。仅精确相等才排除;不 trim、不按前后缀忽略用户修改。
13
+ - 初始化仅由本入口成功创建的全新 ID 触发,在打开会话前操作未挂载的原生 shell,避免 Host setDraft 的选区写入抢占已挂载输入焦点。snapshot 缺失或非 plain、draft 非空、draftRev 非零、imageIds/occurrences 非空均跳过。建立一次性记录、订阅后重读、微任务后再次快照;期间用户输入/附件/组合事件、会话切换或卸载使写入失效,无轮询补回。
14
+ - 清洗业务 Skill 将信息充分性检查置于任何工具之前:占位符/缺名单或待补字段先澄清;没有注册全局发送拦截。安全凭证所指暂存来源仍可提供名单和范围,不重复要求上传。
15
+ - 向导按 UX-10 提供替换/追加/取消,默认保留用户内容;同一 Session 未改的向导草稿更新而非重复叠加。异步回填重读草稿版本、附件和当前业务归属。
16
+ - 本地门禁:`npm run check` 通过(296 项测试,语法/文档/营销/69 文件 pack 白名单);Chromium 浅深色 × 5 尺寸 10/10;XLSX 预览 390/800/1440px 3/3。包含附件、IME 事件、晚到修改/清空、卸载、A/B、普通 Session、Tab X/折叠恢复、草稿回填替换/追加/取消与零开台断言。
17
+ - 真实 DSH:`scripts/ux49-native-smoke.mjs`,独立 Profile、无模型/MCP 凭据。DSH 0.1.2-rc.1 + Sidebar 0.18.1 + 清洗候选/招投标 0.5.11/访前尽调 0.1.35/填表 0.2.30 共装通过。原生模板、正确 Hero 标题、不移动焦点、主动清空不补回、刷新保留修改、A/B/普通会话隔离、零业务 API mutation、零可见工作台、零浏览器异常均通过。
18
+ - 实机客户端 SHA-256 `bacfd1faa7eff584ddec3e948ee3f2e19f1d710224de9068d9a62415345a28a1`,测试包 SHA-256 `e93fac1541934a43bc54789283d57c378fbd721fe0d8fc86b922b2a69c5747c5`。原始报告/截图在 `/var/folders/ws/tmsn44b140lf8b89ml5qqjf00000gn/T/cleaning-ux49-native-HtQGy0/`;本地日志 `/private/tmp/ux49-{check,ui,artifact,native}-release-candidate.log`,UI 截图 `_scratch/ui-layout/`。测试包不是 npm 发布物;随后文档和验收脚本路径清理不改变已验收客户端字节。
19
+ - 未验证边界:真实模型执行占位符澄清(仅验证 Skill 优先规则,不冒充实际模型决策)、系统级真实中文输入法(已覆盖组合事件)、收费 QCC/OCR、多账号/其他 Host 版本,以及其余三产品各自 UX49 候选版本。正式 3080/Profile 未修改。
20
+ - 本次仅 commit/push/PR/CI;不合并、不升版本、不 tag/Release/npm publish。
21
+
22
+ ## v1.5.4 历史采用记录
2
23
 
3
24
  日期:2026-09-14。基线:0.9.11 / a0ec971db0d25d42f67f99e4328cca6f49a2bc2f。
4
25
  本轮仅本地代码与验收,不发布,不修改正式 Profile 或全局 DSH。
package/lib/client.js CHANGED
@@ -1605,19 +1605,104 @@ body:has(.dcAgentPromptBackdrop) { overflow: hidden; }
1605
1605
  return () => { style.remove(); };
1606
1606
  }
1607
1607
 
1608
- // 原始数据不进入 Host storageDomain 或模型上下文;只在当前页面按 taskId 隔离。
1608
+ // 原始数据不进入 Host storageDomain 或模型上下文;仅在当前浏览器页签内按 taskId 隔离。
1609
+ // sessionStorage 只用于刷新恢复,关闭页签后不承诺保留;写入失败时降级为同源重新载入。
1610
+ const RUNTIME_SESSION_CACHE_KEY = 'dsh.data-cleaning-agent.runtime-tasks.v1';
1611
+ const RUNTIME_SESSION_CACHE_LIMIT = 20;
1612
+ const RUNTIME_SESSION_CACHE_MAX_ROWS = 5_000;
1613
+ const RUNTIME_SESSION_CACHE_MAX_CHARS = 2 * 1024 * 1024;
1609
1614
  const runtimeTasks = new Map();
1610
1615
  const workflowTaskBySession = new Map();
1611
1616
  const workflowTaskCreationBySession = new Map();
1612
1617
  const workflowOperationBySession = new Map();
1613
1618
 
1614
- function runtimeFor(taskId, create = true) {
1619
+ function readRuntimeSessionCache() {
1620
+ try {
1621
+ const value = JSON.parse(window.sessionStorage?.getItem(RUNTIME_SESSION_CACHE_KEY) || '{}');
1622
+ return value && typeof value === 'object' && !Array.isArray(value) ? value : {};
1623
+ } catch { return {}; }
1624
+ }
1625
+
1626
+ function runtimeFromSession(taskId, expectedChecksum = '') {
1615
1627
  const key = String(taskId || 'unassigned');
1628
+ const value = readRuntimeSessionCache()[key];
1629
+ const rows = Array.isArray(value?.rows)
1630
+ ? value.rows.filter((row) => row && typeof row === 'object' && !Array.isArray(row))
1631
+ : [];
1632
+ const checksum = String(value?.source?.checksum || '');
1633
+ if (!rows.length || !expectedChecksum || checksum !== expectedChecksum) return null;
1634
+ return {
1635
+ rows,
1636
+ sourceRows: rows,
1637
+ headers: Array.isArray(value.headers) ? value.headers.map(String) : [],
1638
+ source: value.source && typeof value.source === 'object' ? value.source : null,
1639
+ restoredFromSession: true,
1640
+ lastCsv: { clean: null, complete: null, qcc: null, review: null },
1641
+ resultRows: { clean: null, complete: null, qcc: null },
1642
+ };
1643
+ }
1644
+
1645
+ function persistRuntimeSession(taskId, runtime, removeTaskId = null) {
1646
+ const rows = Array.isArray(runtime?.sourceRows) && runtime.sourceRows.length
1647
+ ? runtime.sourceRows
1648
+ : Array.isArray(runtime?.rows) ? runtime.rows : [];
1649
+ if (!rows.length || rows.length > RUNTIME_SESSION_CACHE_MAX_ROWS
1650
+ || typeof window.sessionStorage?.setItem !== 'function') return false;
1651
+ try {
1652
+ const key = String(taskId || 'unassigned');
1653
+ const cache = readRuntimeSessionCache();
1654
+ if (removeTaskId !== null) delete cache[String(removeTaskId || 'unassigned')];
1655
+ delete cache[key];
1656
+ cache[key] = {
1657
+ rows,
1658
+ headers: Array.isArray(runtime.headers) ? runtime.headers : [],
1659
+ source: runtime.source && typeof runtime.source === 'object' ? runtime.source : null,
1660
+ };
1661
+ const entries = Object.entries(cache).slice(-RUNTIME_SESSION_CACHE_LIMIT);
1662
+ while (entries.length) {
1663
+ const serialized = JSON.stringify(Object.fromEntries(entries));
1664
+ if (serialized.length <= RUNTIME_SESSION_CACHE_MAX_CHARS) {
1665
+ window.sessionStorage?.setItem(RUNTIME_SESSION_CACHE_KEY, serialized);
1666
+ return true;
1667
+ }
1668
+ entries.shift();
1669
+ }
1670
+ return false;
1671
+ } catch { return false; }
1672
+ }
1673
+
1674
+ function removeRuntimeSession(taskId) {
1675
+ try {
1676
+ const key = String(taskId || 'unassigned');
1677
+ const cache = readRuntimeSessionCache();
1678
+ if (!(key in cache)) return;
1679
+ delete cache[key];
1680
+ window.sessionStorage?.setItem(RUNTIME_SESSION_CACHE_KEY, JSON.stringify(cache));
1681
+ } catch {}
1682
+ }
1683
+
1684
+ function removeRuntime(taskId) {
1685
+ runtimeTasks.delete(String(taskId || 'unassigned'));
1686
+ removeRuntimeSession(taskId);
1687
+ }
1688
+
1689
+ function runtimeFor(taskId, create = true, expectedChecksum = '') {
1690
+ const key = String(taskId || 'unassigned');
1691
+ const current = runtimeTasks.get(key);
1692
+ if (current && expectedChecksum && current.source?.checksum !== expectedChecksum) {
1693
+ runtimeTasks.delete(key);
1694
+ removeRuntimeSession(key);
1695
+ }
1696
+ if (!runtimeTasks.has(key)) {
1697
+ const restored = runtimeFromSession(key, expectedChecksum);
1698
+ if (restored) runtimeTasks.set(key, restored);
1699
+ }
1616
1700
  if (!runtimeTasks.has(key) && create) {
1617
1701
  runtimeTasks.set(key, {
1618
1702
  rows: [],
1619
1703
  headers: [],
1620
1704
  source: null,
1705
+ restoredFromSession: false,
1621
1706
  lastCsv: { clean: null, complete: null, qcc: null, review: null },
1622
1707
  resultRows: { clean: null, complete: null, qcc: null },
1623
1708
  });
@@ -1633,6 +1718,7 @@ body:has(.dcAgentPromptBackdrop) { overflow: hidden; }
1633
1718
  target.sourceRows = from.sourceRows;
1634
1719
  target.headers = from.headers;
1635
1720
  target.source = from.source;
1721
+ target.restoredFromSession = from.restoredFromSession;
1636
1722
  target.lastCsv = from.lastCsv;
1637
1723
  target.resultRows = from.resultRows;
1638
1724
  runtimeTasks.delete(String(fromId || 'unassigned'));
@@ -1781,7 +1867,12 @@ body:has(.dcAgentPromptBackdrop) { overflow: hidden; }
1781
1867
  ];
1782
1868
  const DEFAULT_CLEANING_KEYS = ['clean_name', 'deduplicate', 'validate_identity', 'complete_fields'];
1783
1869
  const DEFAULT_ENRICHMENT_KEYS = ['credit_no', 'legal_rep', 'reg_capital', 'establish_date', 'reg_status'];
1784
- const DEFAULT_SESSION_PROMPT = '请帮我清洗并补全企业名单。可点击输入框左上角「提示词生成」录入名单、上传 Excel 或图片,也可直接修改本段任务说明后开始。';
1870
+ const DEFAULT_SESSION_PROMPT = '请帮我清洗并补全企业名单。请粘贴企业名称,或上传 Excel / 名单图片,并说明需要补全的字段;也可点击左上角「提示词生成」设置匹配与输出。例如:补全【企业名单】的统一社会信用代码、法定代表人和联系方式,结果导出为 Excel。';
1871
+ const INITIAL_DRAFT_TEMPLATE = Object.freeze({ id: 'dsh-initial-draft/data-cleaning/1', version: 1,
1872
+ fingerprint: 'sha256:2b84b86f669e64e970381e4c7ab8547c77d5e10ee7d5441543c893b9f1272e2b', text: DEFAULT_SESSION_PROMPT });
1873
+ const INITIAL_DRAFT_LEDGER = Symbol.for('dsh.data-cleaning.initial-draft.v1');
1874
+ const compositionState = document[Symbol.for('dsh.data-cleaning.composition.v1')] ||= { active: false };
1875
+ let initialDraftEpoch = 0;
1785
1876
  const CLEANING_SESSION_STORAGE_KEYS = [
1786
1877
  'dsh.data-cleaning-agent.active-session.v2',
1787
1878
  'dsh.data-cleaning-agent.sessions.v1',
@@ -1827,28 +1918,17 @@ body:has(.dcAgentPromptBackdrop) { overflow: hidden; }
1827
1918
  }
1828
1919
 
1829
1920
  function isKnownCleaningDraft(value) {
1830
- if (value === DEFAULT_SESSION_PROMPT) return true;
1831
- if (typeof value !== 'string') return false;
1832
- const wizardDraft = value.startsWith('请执行一项企业名单数据清洗补全任务。')
1833
- && value.includes('企查查连接、套餐额度和费用均由当前用户自己的账号承担。')
1834
- && value.includes('提供结果和待复核清单的导出。');
1835
- const executionDraft = value.startsWith('请执行已在「数据清洗补全工作台」确认的企业数据任务。')
1836
- && value.includes('安全任务凭证:dcq-')
1837
- && value.includes('data_cleaning_qcc_run');
1838
- const imageDraft = value.startsWith('请识别我刚刚在向导中安全暂存的企业名单图片')
1839
- && value.includes('安全图片凭证:dci-')
1840
- && value.includes('data_cleaning_extract_image_companies');
1841
- return wizardDraft || executionDraft || imageDraft;
1921
+ // Exact text is the fingerprint: no trimming, substring or fuzzy recognition.
1922
+ return value === INITIAL_DRAFT_TEMPLATE.text;
1842
1923
  }
1843
1924
 
1844
- function clearCleaningDraft(ctx, sessionId, onlyDefault = false) {
1845
- if (!sessionId) return false;
1846
- const conversation = typeof ctx.get === 'function' ? ctx.get('conversation') : ctx.conversation;
1847
- const shell = conversation?.input?.shell?.(sessionId);
1848
- if (!shell || typeof shell.setDraft !== 'function') return false;
1849
- if (onlyDefault && !isKnownCleaningDraft(shell.snapshot?.draft)) return false;
1850
- shell.setDraft('');
1851
- return true;
1925
+ function wizardListEntries(value) {
1926
+ if (isKnownCleaningDraft(value)) return [];
1927
+ return String(value || '').split(/\r?\n/).map((line) => line.trim()).filter(Boolean);
1928
+ }
1929
+
1930
+ function invalidListInstruction(values) {
1931
+ return values.some((value) => /【[^】]*】|^(?:请|例如[::]|输入来源[::]|结果导出|需要补全)/u.test(value));
1852
1932
  }
1853
1933
 
1854
1934
  function deactivateCleaningSession(sessionId = activeCleaningSessionId) {
@@ -1889,43 +1969,19 @@ body:has(.dcAgentPromptBackdrop) { overflow: hidden; }
1889
1969
  */
1890
1970
  function installSessionOwnershipBridge(ctx) {
1891
1971
  const releaseNewSessionBridge = installNewSessionBridge(ctx);
1892
- let staleDraftObserver = null;
1893
- let staleDraftObserverTimer = null;
1894
- const clearStaleDefaultDraft = () => {
1895
- if (activeCleaningSessionId) return false;
1896
- const currentSessionId = ctx.sessions?.list?.getSnapshot?.()?.current;
1897
- const cleared = clearCleaningDraft(ctx, currentSessionId, true);
1898
- if (cleared && staleDraftObserver) {
1899
- staleDraftObserver.disconnect();
1900
- staleDraftObserver = null;
1901
- }
1902
- return cleared;
1903
- };
1972
+ // Observe composition only; never read/write another product's draft.
1973
+ const compositionStart = () => { compositionState.active = true; };
1974
+ const compositionEnd = () => { compositionState.active = false; };
1975
+ document.addEventListener?.('compositionstart', compositionStart, true);
1976
+ document.addEventListener?.('compositionend', compositionEnd, true);
1904
1977
  const leaveSubsystem = () => {
1905
1978
  const sessionId = activeCleaningSessionId;
1906
- if (sessionId && !ownedSessionId(sessionId)) clearCleaningDraft(ctx, sessionId, true);
1907
1979
  deactivateCleaningSession(sessionId);
1908
1980
  };
1909
1981
  // Only our independently-created native session namespace may regain ownership.
1910
1982
  const initialSession = ctx.sessions?.list?.getSnapshot?.()?.current;
1911
1983
  if (ownedSessionId(initialSession)) markCleaningSession(initialSession);
1912
- clearStaleDefaultDraft();
1913
- // DSH 会在插件 apply() 之后异步恢复 composer draft。在首屏稳定期内
1914
- // 监听 DOM 变化并仅清理完全匹配本插件默认文案,或同时命中
1915
- // 提示词向导固定首尾与费用声明签名的草稿;不触碰用户自写内容。
1916
- if (typeof MutationObserver === 'function' && document.documentElement) {
1917
- staleDraftObserver = new MutationObserver(clearStaleDefaultDraft);
1918
- staleDraftObserver.observe(document.documentElement, {
1919
- childList: true,
1920
- characterData: true,
1921
- subtree: true,
1922
- });
1923
- staleDraftObserverTimer = setTimeout(() => {
1924
- staleDraftObserver?.disconnect();
1925
- staleDraftObserver = null;
1926
- staleDraftObserverTimer = null;
1927
- }, 10_000);
1928
- }
1984
+ // Restore ownership only. Refresh/remount/navigation must never seed or clear drafts.
1929
1985
  const unsubscribe = ctx.sessions?.list?.subscribe?.((snapshot) => {
1930
1986
  // Store subscriptions may signal without a payload; read authoritative state.
1931
1987
  const selected = ctx.sessions?.list?.getSnapshot?.() ?? snapshot;
@@ -1936,14 +1992,13 @@ body:has(.dcAgentPromptBackdrop) { overflow: hidden; }
1936
1992
  }
1937
1993
  if (!activeCleaningSessionId && ownedSessionId(nextSessionId)) {
1938
1994
  markCleaningSession(nextSessionId);
1939
- } else if (!activeCleaningSessionId) {
1940
- clearStaleDefaultDraft();
1941
1995
  }
1942
1996
  });
1943
1997
  return () => {
1998
+ initialDraftEpoch++;
1944
1999
  releaseNewSessionBridge();
1945
- staleDraftObserver?.disconnect();
1946
- if (staleDraftObserverTimer !== null) clearTimeout(staleDraftObserverTimer);
2000
+ document.removeEventListener?.('compositionstart', compositionStart, true);
2001
+ document.removeEventListener?.('compositionend', compositionEnd, true);
1947
2002
  if (typeof unsubscribe === 'function') unsubscribe();
1948
2003
  };
1949
2004
  }
@@ -2406,6 +2461,32 @@ body:has(.dcAgentPromptBackdrop) { overflow: hidden; }
2406
2461
  return cacheWorkflowTask(actions, sessionId, response.task, reason);
2407
2462
  }
2408
2463
 
2464
+ async function verifyRestoredRuntime(actions, sessionId, task, runtime) {
2465
+ if (!runtime?.restoredFromSession) return runtime;
2466
+ if (!task?.id || !task.source?.checksum || !runtime.sourceRows?.length) {
2467
+ if (task?.id) removeRuntime(task.id);
2468
+ actions.setDataset?.(null);
2469
+ throw new Error('刷新缓存缺少同源校验信息,请重新载入原文件或原文本。');
2470
+ }
2471
+ const response = await requestJson(
2472
+ `/data-cleaning/api/workflow/tasks/${encodeURIComponent(task.id)}/verify-source`,
2473
+ 'POST',
2474
+ {
2475
+ originSessionId: sessionId,
2476
+ originWorkspaceId: task.originWorkspaceId,
2477
+ headers: runtime.headers,
2478
+ rows: runtime.sourceRows,
2479
+ },
2480
+ );
2481
+ if (response.verified !== true) {
2482
+ removeRuntime(task.id);
2483
+ actions.setDataset?.(null);
2484
+ throw new Error('刷新缓存与原任务不一致,已停止本次操作。请重新载入原文件或新建任务。');
2485
+ }
2486
+ runtime.restoredFromSession = false;
2487
+ return runtime;
2488
+ }
2489
+
2409
2490
  async function stageConfirmedWorkflow(actions, sessionId, expected = null) {
2410
2491
  const host = await requestJson('/data-cleaning/api/g5/capabilities');
2411
2492
  if (host?.workflowExecutionVersion !== 1) throw new Error('页面与 Host 版本不一致。请保存任务后升级并完整重启 DSH;当前 Host 不支持自动生成结果文件。');
@@ -2427,9 +2508,10 @@ body:has(.dcAgentPromptBackdrop) { overflow: hidden; }
2427
2508
  throw new Error('工作台保存的范围与向导不一致,请重试确认;未生成执行凭证。');
2428
2509
  }
2429
2510
  }
2430
- const runtime = runtimeFor(task.id, false);
2511
+ const runtime = runtimeFor(task.id, false, task.source?.checksum || '');
2431
2512
  if (!task.fieldSelection?.length) throw new Error('当前未选择企查查补全字段。请在规则页选择补全字段,或使用工作台本地清洗与结果导出;不会发起企查查调用。');
2432
2513
  if (!runtime?.rows?.length) throw new Error('Host 名单尚未载入,请先解析数据。');
2514
+ await verifyRestoredRuntime(actions, sessionId, task, runtime);
2433
2515
  if (task.state === 'uploaded') task = await workflowAction(actions, sessionId, task, 'rules', {
2434
2516
  objectives: task.objectives, fieldSelection: task.fieldSelection, mappings: task.mappings, matchRules: task.matchRules,
2435
2517
  });
@@ -2725,13 +2807,45 @@ body:has(.dcAgentPromptBackdrop) { overflow: hidden; }
2725
2807
  return `${CLEANING_SESSION_ID_PREFIX}${globalThis.crypto.randomUUID()}`;
2726
2808
  }
2727
2809
 
2810
+ function emptyInitialInput(snapshot) {
2811
+ // Missing Host capabilities fail closed. draftRev also detects type-then-clear.
2812
+ return snapshot?.phase === 'plain' && snapshot.draft === ''
2813
+ && snapshot.draftRev === 0 && Array.isArray(snapshot.imageIds) && snapshot.imageIds.length === 0
2814
+ && Array.isArray(snapshot.occurrences) && snapshot.occurrences.length === 0;
2815
+ }
2816
+
2817
+ async function initializeCleaningDraft(ctx, sessionId, entry) {
2818
+ const ledger = document[INITIAL_DRAFT_LEDGER] ||= new Map();
2819
+ if (!ownedSessionId(sessionId) || entry?.createdId !== sessionId || ledger.has(sessionId)) return false;
2820
+ // Reserve before yielding: no duplicate initialization, even on plugin remount.
2821
+ ledger.set(sessionId, { templateId: INITIAL_DRAFT_TEMPLATE.id, state: 'skipped' });
2822
+ const conversation = typeof ctx.get === 'function' ? ctx.get('conversation') : ctx.conversation;
2823
+ const shell = conversation?.input?.shell?.(sessionId);
2824
+ const eligible = () => !compositionState.active && !entry.interrupted()
2825
+ && ctx.sessions.list.getSnapshot().current === entry.previousSession
2826
+ && typeof shell?.setDraft === 'function' && typeof shell.state?.subscribe === 'function'
2827
+ && emptyInitialInput(shell.snapshot);
2828
+ if (!eligible()) return false;
2829
+ const revision = shell.snapshot.draftRev;
2830
+ let changed = false;
2831
+ const release = shell.state?.subscribe?.(() => { if (!emptyInitialInput(shell.snapshot)) changed = true; });
2832
+ try {
2833
+ await Promise.resolve();
2834
+ // Authoritative second snapshot, not captured render props. No await between CAS and write.
2835
+ if (changed || !eligible() || shell.snapshot.draftRev !== revision) return false;
2836
+ shell.setDraft(INITIAL_DRAFT_TEMPLATE.text);
2837
+ const accepted = shell.snapshot?.draft === INITIAL_DRAFT_TEMPLATE.text;
2838
+ ledger.set(sessionId, { templateId: INITIAL_DRAFT_TEMPLATE.id, state: accepted ? 'initialized' : 'skipped' });
2839
+ return accepted;
2840
+ } finally { if (typeof release === 'function') release(); }
2841
+ }
2842
+
2728
2843
  /** 使用 DSH 原生工作区、会话和输入机打开中央对话,不构造第二套聊天界面。 */
2729
2844
  async function startCleaningSession(ctx) {
2730
2845
  const workspace = ctx.workspaces.list.getSnapshot();
2731
2846
  const current = ctx.sessions.list.getSnapshot().current;
2732
2847
  if (ownedSessionId(current)) {
2733
2848
  markCleaningSession(current);
2734
- requestWorkbenchOpen(workflowTaskBySession.get(String(current))?.stage || 'upload', current);
2735
2849
  return current;
2736
2850
  }
2737
2851
  const items = Array.isArray(workspace.items) ? workspace.items : [];
@@ -2752,16 +2866,34 @@ body:has(.dcAgentPromptBackdrop) { overflow: hidden; }
2752
2866
  throw new Error('当前 DSH 版本没有可用的独立会话创建能力');
2753
2867
  }
2754
2868
  const requestedId = createCleaningSessionId();
2755
- const sessionId = await ctx.sessions.create({ workspaceId: targetWorkspace.workspaceId, sessionId: requestedId });
2756
- if (sessionId !== requestedId) {
2757
- throw new Error('当前 DSH 版本不支持独立会话创建,请升级 DSH 后再打开数据清洗补全智能体');
2869
+ const entryEpoch = initialDraftEpoch;
2870
+ let interrupted = compositionState.active;
2871
+ const interrupt = () => { interrupted = true; };
2872
+ const events = ['beforeinput', 'input', 'paste', 'drop', 'compositionstart'];
2873
+ for (const event of events) document.addEventListener?.(event, interrupt, true);
2874
+ const stopNavigation = ctx.sessions.list.subscribe?.(() => {
2875
+ if (ctx.sessions.list.getSnapshot().current !== current) interrupt();
2876
+ });
2877
+ let sessionId;
2878
+ try {
2879
+ sessionId = await ctx.sessions.create({ workspaceId: targetWorkspace.workspaceId, sessionId: requestedId });
2880
+ if (sessionId !== requestedId) {
2881
+ throw new Error('当前 DSH 版本不支持独立会话创建,请升级 DSH 后再打开数据清洗补全智能体');
2882
+ }
2883
+ await initializeCleaningDraft(ctx, sessionId, {
2884
+ createdId: requestedId, previousSession: current,
2885
+ interrupted: () => interrupted || entryEpoch !== initialDraftEpoch,
2886
+ });
2887
+ // A late create must not bring the user back from another Session/product.
2888
+ if (!interrupted && entryEpoch === initialDraftEpoch && ctx.sessions.list.getSnapshot().current === current) {
2889
+ ctx.sessions.open(sessionId);
2890
+ markCleaningSession(sessionId);
2891
+ }
2892
+ return sessionId;
2893
+ } finally {
2894
+ if (typeof stopNavigation === 'function') stopNavigation();
2895
+ for (const event of events) document.removeEventListener?.(event, interrupt, true);
2758
2896
  }
2759
- const conversation = typeof ctx.get === 'function' ? ctx.get('conversation') : ctx.conversation;
2760
- if (!conversation) throw new Error('DSH 对话服务尚未就绪,请稍后重试');
2761
- conversation.input.shell(sessionId).setDraft(DEFAULT_SESSION_PROMPT);
2762
- ctx.sessions.open(sessionId);
2763
- markCleaningSession(sessionId);
2764
- return sessionId;
2765
2897
  }
2766
2898
 
2767
2899
  /**
@@ -3385,6 +3517,10 @@ body:has(.dcAgentPromptBackdrop) { overflow: hidden; }
3385
3517
  return rows.map((row) => {
3386
3518
  const name = nameField ? String(row?.[nameField] ?? '').trim() : '';
3387
3519
  const credit = creditField ? String(row?.[creditField] ?? '').trim() : '';
3520
+ if (isKnownCleaningDraft(name)) return '';
3521
+ if (invalidListInstruction([name, credit])) {
3522
+ throw new Error('名单包含占位符、示例或任务说明,请提供实际企业名称或信用代码后再继续。');
3523
+ }
3388
3524
  return [name, credit].filter(Boolean).join(' | ');
3389
3525
  }).filter(Boolean).slice(0, 50);
3390
3526
  }
@@ -3465,6 +3601,10 @@ body:has(.dcAgentPromptBackdrop) { overflow: hidden; }
3465
3601
  });
3466
3602
  const [busy, setBusy] = react.useState(false);
3467
3603
  const [error, setError] = react.useState(null);
3604
+ const [draftPolicy, setDraftPolicy] = react.useState('cancel');
3605
+ const lastWizardDraft = react.useRef(null);
3606
+
3607
+ react.useEffect(() => { setDraftPolicy('cancel'); lastWizardDraft.current = null; }, [sessionId]);
3468
3608
 
3469
3609
  react.useEffect(() => {
3470
3610
  const commandId = imageCommand?.commandId;
@@ -3625,11 +3765,12 @@ body:has(.dcAgentPromptBackdrop) { overflow: hidden; }
3625
3765
  }
3626
3766
  };
3627
3767
  const selectedEntries = () => mode === 'text'
3628
- ? rawText.split(/\r?\n/).map((line) => line.trim()).filter(Boolean)
3768
+ ? wizardListEntries(rawText)
3629
3769
  : entries;
3630
3770
  const validateSource = () => {
3631
3771
  const values = selectedEntries();
3632
3772
  if (mode === 'text' && !values.length) return '请先录入企业名称或统一社会信用代码,每行一条。';
3773
+ if (mode === 'text' && invalidListInstruction(values)) return '请提供实际企业名单,每行一个名称或信用代码;请勿将【企业名单】、示例或任务说明作为企业。';
3633
3774
  if (mode === 'text' && values.some(isImagePathEntry)) return '检测到图片文件路径,不是企业名单。请切换「上传图片识别」,重新选择或粘贴图片。';
3634
3775
  if (mode === 'excel' && !values.length) return '请先上传并解析 Excel / CSV / JSON 文件。';
3635
3776
  if (mode === 'image' && !imageCommand) return '请先选择、粘贴或拖入一张包含企业名单的图片。';
@@ -3661,16 +3802,26 @@ body:has(.dcAgentPromptBackdrop) { overflow: hidden; }
3661
3802
  return;
3662
3803
  }
3663
3804
  const inputSnapshot = getInputSnapshot?.(sessionId);
3664
- if (['adjudicating', 'submitting'].includes(inputSnapshot?.phase)) {
3805
+ if (!inputSnapshot || inputSnapshot.phase !== 'plain' || compositionState.active) {
3665
3806
  setError('当前对话正在提交,请等待完成后再回填。图片与字段选择已保留。');
3666
3807
  return;
3667
3808
  }
3809
+ const unchangedWizard = lastWizardDraft.current?.sessionId === sessionId
3810
+ && lastWizardDraft.current.draft === inputSnapshot.draft;
3811
+ const userDraft = inputSnapshot.draft && !isKnownCleaningDraft(inputSnapshot.draft) && !unchangedWizard;
3812
+ if (userDraft && draftPolicy === 'cancel') {
3813
+ setError('已保留当前草稿。请选择“替换”或“追加”后回填,或关闭向导取消。'); return;
3814
+ }
3815
+ const prefix = unchangedWizard ? lastWizardDraft.current.prefix
3816
+ : userDraft && draftPolicy === 'append' ? inputSnapshot.draft + '\n\n' : '';
3668
3817
  const config = promptConfig();
3669
3818
  if (mode === 'image' && imageCommand?.state === 'prepared' && imageAttachment && typeof removeImage === 'function') {
3670
3819
  // qcc-document-mcp 只读取 Host 暂存文件;发送前释放原生附件,文本模型不会收到图片。
3671
3820
  removeImage(sessionId, imageAttachment);
3672
3821
  setImageAttachment(null);
3673
3822
  }
3823
+ const attachmentSnapshot = JSON.stringify({imageIds:getInputSnapshot?.(sessionId)?.imageIds,
3824
+ occurrences:getInputSnapshot?.(sessionId)?.occurrences});
3674
3825
  setBusy(true);
3675
3826
  try {
3676
3827
  if (mode === 'text') {
@@ -3697,9 +3848,18 @@ body:has(.dcAgentPromptBackdrop) { overflow: hidden; }
3697
3848
  prompt = mode === 'image' && imageCommand?.state === 'prepared' ? command.prompt : command.prompt + '\n\n' + buildTaskPrompt(config);
3698
3849
  if (mode === 'image' && imageCommand?.state === 'prepared') setImageCommand(command);
3699
3850
  }
3851
+ const latest = getInputSnapshot?.(sessionId);
3852
+ if (!isCleaningSession(sessionId) || compositionState.active || latest?.phase !== 'plain'
3853
+ || latest.draft !== inputSnapshot.draft || latest.draftRev !== inputSnapshot.draftRev
3854
+ || JSON.stringify({imageIds:latest.imageIds,occurrences:latest.occurrences}) !== attachmentSnapshot) {
3855
+ throw new Error('草稿或会话已变化,未覆盖您的内容;请确认后再次回填。');
3856
+ }
3857
+ prompt = prefix + prompt;
3700
3858
  inputActions.setDraft(prompt);
3701
3859
  const updatedInput = getInputSnapshot?.(sessionId);
3702
3860
  if (updatedInput && updatedInput.draft !== prompt) throw new Error('当前对话尚未接收任务说明,请稍后重试回填。');
3861
+ lastWizardDraft.current = {sessionId,draft:prompt,prefix};
3862
+ setDraftPolicy('cancel');
3703
3863
  } catch (draftError) {
3704
3864
  setError(draftError instanceof Error ? draftError.message : String(draftError));
3705
3865
  return;
@@ -3990,6 +4150,11 @@ body:has(.dcAgentPromptBackdrop) { overflow: hidden; }
3990
4150
  wizardStep === 4 ? h('div', { className: 'dcAgentWizardPane' },
3991
4151
  h('h4', null, '确认任务描述'),
3992
4152
  h('p', null, '回填后仍可在对话框继续编辑;回填本身不会调用企查查 MCP。'),
4153
+ h('label', null, '已有草稿时的回填方式 ',
4154
+ h('select', { 'aria-label':'已有草稿时的回填方式', value:draftPolicy, onChange:event=>setDraftPolicy(event.target.value) },
4155
+ h('option', {value:'cancel'}, '保留草稿(取消回填)'),
4156
+ h('option', {value:'replace'}, '替换已有草稿'),
4157
+ h('option', {value:'append'}, '追加到已有草稿'))),
3993
4158
  h('pre', { className: 'dcAgentPromptPreview' }, buildTaskPrompt(promptConfig())),
3994
4159
  ) : null,
3995
4160
  h('p', { className: 'dcAgentPromptNote' }, '向导只生成任务描述并保存任务设置。只有你在任务中明确确认后,才会使用当前客户自己的企查查 MCP 连接、账号和额度。'),
@@ -4185,6 +4350,7 @@ body:has(.dcAgentPromptBackdrop) { overflow: hidden; }
4185
4350
  sizeBytes: source.sizeBytes || 0,
4186
4351
  checksum: result.checksum || source.checksum || '',
4187
4352
  };
4353
+ runtime.restoredFromSession = false;
4188
4354
  const mappings = guessMappings(runtime.headers);
4189
4355
  const guessedNameField = mappings.find((mapping) => mapping.targetField === 'company_name')?.sourceField
4190
4356
  ?? mappings.find((mapping) => mapping.targetField === 'credit_no')?.sourceField
@@ -4214,6 +4380,7 @@ body:has(.dcAgentPromptBackdrop) { overflow: hidden; }
4214
4380
  sizeBytes: source.sizeBytes || task.source?.sizeBytes || 0,
4215
4381
  checksum: result.checksum || '',
4216
4382
  };
4383
+ runtime.restoredFromSession = false;
4217
4384
  actions.setDataset({
4218
4385
  fmt: result.fmt ?? task.source?.type ?? 'csv',
4219
4386
  headers: runtime.headers,
@@ -4222,6 +4389,7 @@ body:has(.dcAgentPromptBackdrop) { overflow: hidden; }
4222
4389
  });
4223
4390
  const primary = (task.mappings || []).find((mapping) => ['company_name', 'credit_no', 'reg_no'].includes(mapping.targetField));
4224
4391
  if (primary) actions.setNameField(primary.sourceField);
4392
+ persistRuntimeSession(task.id, runtime);
4225
4393
  return runtime;
4226
4394
  }
4227
4395
 
@@ -4244,6 +4412,7 @@ body:has(.dcAgentPromptBackdrop) { overflow: hidden; }
4244
4412
  sizeBytes: source.sizeBytes || 0,
4245
4413
  checksum: result.checksum || source.checksum || '',
4246
4414
  };
4415
+ target.restoredFromSession = false;
4247
4416
  if (['draft', 'uploaded', 'parse_failed'].includes(current.state)) {
4248
4417
  current = await workflowAction(actions, sessionId, current, 'upload', {
4249
4418
  source: {
@@ -4258,6 +4427,7 @@ body:has(.dcAgentPromptBackdrop) { overflow: hidden; }
4258
4427
  }, 'import');
4259
4428
  }
4260
4429
  if (current.state === 'uploaded') current = await updateWorkflowTask(actions, sessionId, current, importedDraft);
4430
+ persistRuntimeSession(current.id, target, stagingKey);
4261
4431
  return current;
4262
4432
  }
4263
4433
 
@@ -4443,7 +4613,7 @@ body:has(.dcAgentPromptBackdrop) { overflow: hidden; }
4443
4613
  const objectives = useStore((state) => state.objectives);
4444
4614
  const fieldSelection = useStore((state) => state.fieldSelection);
4445
4615
  const matchRules = useStore((state) => state.matchRules);
4446
- const dataset = useStore((state) => state.dataset);
4616
+ const storedDataset = useStore((state) => state.dataset);
4447
4617
  const profile = useStore((state) => state.profile);
4448
4618
  const clean = useStore((state) => state.clean);
4449
4619
  const complete = useStore((state) => state.complete);
@@ -4455,11 +4625,11 @@ body:has(.dcAgentPromptBackdrop) { overflow: hidden; }
4455
4625
  const paidConfirmed = useStore((state) => state.paidConfirmed);
4456
4626
  const activeSessionId = useStore((state) => state.activeSessionId);
4457
4627
  const [fieldQuery, setFieldQuery] = react.useState('');
4458
- const [intakeEditorOpen, setIntakeEditorOpen] = react.useState(() => dataset == null);
4628
+ const [intakeEditorOpen, setIntakeEditorOpen] = react.useState(() => storedDataset == null);
4459
4629
 
4460
4630
  react.useEffect(() => {
4461
- setIntakeEditorOpen(dataset == null);
4462
- }, [dataset]);
4631
+ setIntakeEditorOpen(storedDataset == null);
4632
+ }, [storedDataset]);
4463
4633
 
4464
4634
  react.useEffect(() => {
4465
4635
  if (!open || !visible) return;
@@ -4514,13 +4684,19 @@ body:has(.dcAgentPromptBackdrop) { overflow: hidden; }
4514
4684
 
4515
4685
  // Host 决定 Tab 挂载/可见性;内容不再按私有 open flag 隐藏。
4516
4686
 
4517
- const hasData = dataset !== null && dataset.rowCount > 0;
4518
- const requiresQcc = objectives.includes('validate_identity') || objectives.includes('complete_fields');
4519
4687
  const cachedTask = workflowTaskBySession.get(String(activeSessionId || 'unassigned')) ?? workflowTask;
4520
4688
  const runtimeKey = cachedTask?.id ?? `session:${activeSessionId || 'unassigned'}`;
4521
- const runtime = runtimeFor(runtimeKey);
4689
+ const runtime = runtimeFor(runtimeKey, true, cachedTask?.source?.checksum || '');
4522
4690
  const stagedSource = runtimeFor(`session:${activeSessionId || 'unassigned'}`, false);
4523
4691
  const sourceRows = stagedSource?.sourceRows ?? runtime.sourceRows ?? runtime.rows;
4692
+ const dataset = storedDataset ?? (sourceRows.length ? {
4693
+ fmt: runtime.source?.type || cachedTask?.source?.type || 'csv',
4694
+ headers: runtime.headers.length ? runtime.headers : cachedTask?.source?.headers || [],
4695
+ rowCount: cachedTask?.source?.rowCount ?? sourceRows.length,
4696
+ preview: sourceRows.slice(0, 5),
4697
+ } : null);
4698
+ const hasData = dataset !== null && dataset.rowCount > 0;
4699
+ const requiresQcc = objectives.includes('validate_identity') || objectives.includes('complete_fields');
4524
4700
  const intake = intakeState(dataset, sourceRows, input, runtime.source || stagedSource?.source || cachedTask?.source);
4525
4701
  const normalizedStage = normalizeViewStage(step);
4526
4702
  const flowCompatible = cachedTask?.flow?.flowVersion === SUPPORTED_FLOW_VERSION;
@@ -4542,8 +4718,8 @@ body:has(.dcAgentPromptBackdrop) { overflow: hidden; }
4542
4718
  const showIntakeEditor = canAction('import-data') && (!dataset || intakeEditorOpen);
4543
4719
  const runtimeRecoveryNeeded = Boolean(cachedTask?.source?.rowCount) && runtime.rows.length === 0;
4544
4720
  const runtimeRecoveryAllowed = runtimeRecoveryNeeded
4545
- && isCurrentView
4546
- && ['uploaded', 'rules_confirmed', 'diagnosed'].includes(cachedTask?.state)
4721
+ && ((isCurrentView && ['uploaded', 'rules_confirmed', 'diagnosed'].includes(cachedTask?.state))
4722
+ || (normalizedStage === 'upload' && viewAccess === 'read'))
4547
4723
  && cachedTask?.source?.type !== 'image';
4548
4724
  const lastCsv = runtime.lastCsv;
4549
4725
  const fieldByPattern = (pattern) => runtime.headers.find((field) => pattern.test(field)) ?? null;
@@ -4665,6 +4841,7 @@ body:has(.dcAgentPromptBackdrop) { overflow: hidden; }
4665
4841
  };
4666
4842
 
4667
4843
  const performProfile = async (task) => {
4844
+ await verifyRestoredRuntime(actions, activeSessionId, task, runtime);
4668
4845
  const r = await api('/data-cleaning/api/mvp/profile', {
4669
4846
  rows: runtime.rows,
4670
4847
  headers: runtime.headers,
@@ -4700,6 +4877,7 @@ body:has(.dcAgentPromptBackdrop) { overflow: hidden; }
4700
4877
  actions.setBusy(true);
4701
4878
  actions.setError(null);
4702
4879
  try {
4880
+ await verifyRestoredRuntime(actions, activeSessionId, cachedTask, runtime);
4703
4881
  const r = await api('/data-cleaning/api/mvp/clean', {
4704
4882
  rows: runtime.rows,
4705
4883
  headers: runtime.headers,
@@ -4728,6 +4906,7 @@ body:has(.dcAgentPromptBackdrop) { overflow: hidden; }
4728
4906
  actions.setBusy(true);
4729
4907
  actions.setError(null);
4730
4908
  try {
4909
+ await verifyRestoredRuntime(actions, activeSessionId, cachedTask, runtime);
4731
4910
  const r = await api('/data-cleaning/api/mvp/complete', {
4732
4911
  rows: runtime.rows,
4733
4912
  headers: runtime.headers,
@@ -4900,6 +5079,7 @@ body:has(.dcAgentPromptBackdrop) { overflow: hidden; }
4900
5079
  actions.setBusy(true);
4901
5080
  actions.setError(null);
4902
5081
  try {
5082
+ await verifyRestoredRuntime(actions, activeSessionId, cachedTask, runtime);
4903
5083
  let current = await ensureWorkflowTask(actions, activeSessionId, {
4904
5084
  title: taskTitle, objectives, fieldSelection, mappings, matchRules,
4905
5085
  });
@@ -4957,9 +5137,9 @@ body:has(.dcAgentPromptBackdrop) { overflow: hidden; }
4957
5137
  actions.setError('当前会话的任务向导尚未就绪或正在解析图片,请稍后重试。');
4958
5138
  return;
4959
5139
  }
4960
- if (cachedTask?.id) runtimeTasks.delete(String(cachedTask.id));
4961
- runtimeTasks.delete(`session:${activeSessionId || 'unassigned'}`);
4962
- runtimeTasks.delete('unassigned');
5140
+ if (cachedTask?.id) removeRuntime(cachedTask.id);
5141
+ removeRuntime(`session:${activeSessionId || 'unassigned'}`);
5142
+ removeRuntime('unassigned');
4963
5143
  workflowTaskBySession.delete(String(activeSessionId || 'unassigned'));
4964
5144
  actions.resetWorkflow();
4965
5145
  actions.close();
@@ -4977,6 +5157,7 @@ body:has(.dcAgentPromptBackdrop) { overflow: hidden; }
4977
5157
  if (Array.isArray(latest.artifacts) && latest.artifacts.length) return latest;
4978
5158
  const rows = exportRows();
4979
5159
  if (!rows?.length) throw new Error('当前页面没有可导出的明细。若任务来自历史记录,请重新上传源文件并继续处理。');
5160
+ await verifyRestoredRuntime(actions, activeSessionId, latest, runtime);
4980
5161
  const response = await requestJson(`/data-cleaning/api/workflow/tasks/${encodeURIComponent(latest.id)}/artifacts`, 'POST', {
4981
5162
  originSessionId: activeSessionId, originWorkspaceId: latest.originWorkspaceId,
4982
5163
  expectedRevision: latest.revision,
@@ -5287,22 +5468,30 @@ body:has(.dcAgentPromptBackdrop) { overflow: hidden; }
5287
5468
  ),
5288
5469
  );
5289
5470
  } else if (step === 'profile') {
5471
+ const persistedQuality = cachedTask?.qualitySummary;
5290
5472
  pane = h('div', { className: 'dcAgentPane dcAgentProfilePane' },
5291
5473
  h('section', { className: 'dcAgentSection dcAgentProfileSurface', 'aria-label': '质量体检报告' },
5292
5474
  h('header', { className: 'dcAgentProfileHeader' },
5293
5475
  h('h3', null, '质量体检报告'),
5294
5476
  h('p', { className: 'dcAgentHint' }, '基于已解析数据生成本地质量画像(缺失率、去重值、金额分布)。本步骤不发起企查查调用。'),
5295
5477
  ),
5296
- profile ? h('div', { className: 'dcAgentProfileReport' },
5478
+ profile || persistedQuality ? h('div', { className: 'dcAgentProfileReport' },
5479
+ !profile ? h('p', { className: 'dcAgentHint', role: 'status' }, '以下为 Host 保存的体检摘要;字段级明细未持久化。') : null,
5297
5480
  h('div', { className: 'dcAgentGrid dcAgentProfileMetrics', 'aria-label': '体检摘要' },
5298
- stat('行数', profile.rowCount ?? '—'),
5299
- stat('列数', profile.columnCount ?? '—'),
5300
- profile.amountStats ? stat('金额 最小', profile.amountStats.min) : null,
5301
- profile.amountStats ? stat('金额 最大', profile.amountStats.max) : null,
5302
- profile.amountStats ? stat('金额 总和', profile.amountStats.sum) : null,
5303
- profile.amountStats ? stat('金额 均值', typeof profile.amountStats.mean === 'number' ? profile.amountStats.mean.toFixed(2) : profile.amountStats.mean) : null,
5481
+ stat('行数', profile?.rowCount ?? persistedQuality?.total ?? cachedTask?.source?.rowCount ?? '—'),
5482
+ stat('列数', profile?.columnCount ?? cachedTask?.source?.columnCount ?? '—'),
5483
+ profile?.amountStats ? stat('金额 最小', profile.amountStats.min) : null,
5484
+ profile?.amountStats ? stat('金额 最大', profile.amountStats.max) : null,
5485
+ profile?.amountStats ? stat('金额 总和', profile.amountStats.sum) : null,
5486
+ profile?.amountStats ? stat('金额 均值', typeof profile.amountStats.mean === 'number' ? profile.amountStats.mean.toFixed(2) : profile.amountStats.mean) : null,
5487
+ !profile && persistedQuality ? stat('有效主体', persistedQuality.valid, persistedQuality.valid > 0 ? 'good' : null) : null,
5488
+ !profile && persistedQuality ? stat('缺少主体标识', persistedQuality.missingAnchor, persistedQuality.missingAnchor > 0 ? 'bad' : null) : null,
5489
+ !profile && persistedQuality ? stat('重复主体', persistedQuality.duplicates, persistedQuality.duplicates > 0 ? 'warn' : null) : null,
5490
+ !profile && persistedQuality ? stat('信用代码异常', persistedQuality.invalidCreditNo, persistedQuality.invalidCreditNo > 0 ? 'warn' : null) : null,
5491
+ !profile && persistedQuality ? stat('联系电话异常', persistedQuality.invalidPhone, persistedQuality.invalidPhone > 0 ? 'warn' : null) : null,
5492
+ !profile && persistedQuality ? stat('空白字段', persistedQuality.emptyFields, persistedQuality.emptyFields > 0 ? 'warn' : null) : null,
5304
5493
  ),
5305
- Array.isArray(profile.columns) && profile.columns.length ? h('section', { className: 'dcAgentProfileTableSection', 'aria-label': '字段质量明细' },
5494
+ Array.isArray(profile?.columns) && profile.columns.length ? h('section', { className: 'dcAgentProfileTableSection', 'aria-label': '字段质量明细' },
5306
5495
  h('div', { className: 'dcAgentProfileTableHead' },
5307
5496
  h('h4', null, '字段质量明细'),
5308
5497
  h('span', { className: 'dcAgentHint' }, `共 ${profile.columns.length} 列`),
@@ -5393,9 +5582,22 @@ body:has(.dcAgentPromptBackdrop) { overflow: hidden; }
5393
5582
  canAction('resolve-candidate') ? h('button', { type: 'button', className: 'dcAgentButton', disabled: busy || !paidConfirmed, onClick: () => resolveCandidate(item, candidate) }, '确认此主体') : null,
5394
5583
  )),
5395
5584
  )),
5585
+ ) : cachedTask?.matchSummary ? h('section', { className: 'dcAgentSection', 'aria-label': '主体匹配结果摘要' },
5586
+ h('h3', null, '主体匹配结果'),
5587
+ h('p', { className: 'dcAgentHint', role: 'status' }, '以下为 Host 保存的任务摘要;逐行结果和候选详情未持久化。'),
5588
+ h('div', { className: 'dcAgentGrid' },
5589
+ stat('总数', cachedTask.matchSummary.total),
5590
+ stat('精确匹配', (cachedTask.matchSummary.exact ?? 0) + (cachedTask.matchSummary.confirmed ?? 0), 'good'),
5591
+ stat('候选记录', cachedTask.matchSummary.candidate, cachedTask.matchSummary.candidate > 0 ? 'warn' : null),
5592
+ stat('待核验', cachedTask.matchSummary.reviewRequired, cachedTask.matchSummary.reviewRequired > 0 ? 'warn' : null),
5593
+ stat('未匹配', cachedTask.matchSummary.unresolved, cachedTask.matchSummary.unresolved > 0 ? 'warn' : null),
5594
+ stat('失败', cachedTask.matchSummary.failed, cachedTask.matchSummary.failed > 0 ? 'bad' : null),
5595
+ ),
5396
5596
  ) : null,
5397
5597
  );
5398
5598
  } else if (step === 'enrich') {
5599
+ const persistedEnrichment = cachedTask?.enrichmentSummary;
5600
+ const hasRuntimeEnrichment = Boolean(clean || complete || qccRun);
5399
5601
  pane = h('div', { className: 'dcAgentPane' },
5400
5602
  h('p', { className: 'dcAgentHint' }, '执行本地确定性清洗并核对本任务所选补全字段。外部字段以当前用户企查查 MCP 的真实返回为准,缺失值不会被编造。'),
5401
5603
  h('section', { className: 'dcAgentSection' },
@@ -5414,13 +5616,23 @@ body:has(.dcAgentPromptBackdrop) { overflow: hidden; }
5414
5616
  stat('重复', clean.badDuplicate, clean.badDuplicate > 0 ? 'warn' : null),
5415
5617
  ) : null,
5416
5618
  ),
5417
- h('div', { className: 'dcAgentGrid' },
5418
- stat('输入行数', dataset ? dataset.rowCount : '—'),
5419
- stat('清洗保留', clean ? clean.kept : '—', clean && clean.kept > 0 ? 'good' : null),
5420
- stat('本地补全', complete ? complete.completed : '—', complete && complete.completed > 0 ? 'good' : null),
5421
- stat('QCC 已补全', qccEnrichedCount, qccEnrichedCount > 0 ? 'good' : null),
5422
- stat('待核验', qccReviewCount, qccReviewCount > 0 ? 'warn' : null),
5423
- ),
5619
+ persistedEnrichment && !hasRuntimeEnrichment ? h('section', { className: 'dcAgentSection', 'aria-label': '字段补全结果摘要' },
5620
+ h('h3', null, '字段补全结果'),
5621
+ h('p', { className: 'dcAgentHint', role: 'status' }, '以下为 Host 保存的任务摘要;逐行补全结果请从结果下载查看。'),
5622
+ h('div', { className: 'dcAgentGrid' },
5623
+ stat('总数', persistedEnrichment.total),
5624
+ stat('已补全', persistedEnrichment.completed, persistedEnrichment.completed > 0 ? 'good' : null),
5625
+ stat('原值保留', persistedEnrichment.unchanged),
5626
+ stat('待核验', persistedEnrichment.reviewRequired, persistedEnrichment.reviewRequired > 0 ? 'warn' : null),
5627
+ stat('失败', persistedEnrichment.failed, persistedEnrichment.failed > 0 ? 'bad' : null),
5628
+ ),
5629
+ ) : h('div', { className: 'dcAgentGrid' },
5630
+ stat('输入行数', dataset ? dataset.rowCount : '—'),
5631
+ stat('清洗保留', clean ? clean.kept : '—', clean && clean.kept > 0 ? 'good' : null),
5632
+ stat('本地补全', complete ? complete.completed : '—', complete && complete.completed > 0 ? 'good' : null),
5633
+ stat('QCC 已补全', qccEnrichedCount, qccEnrichedCount > 0 ? 'good' : null),
5634
+ stat('待核验', qccReviewCount, qccReviewCount > 0 ? 'warn' : null),
5635
+ ),
5424
5636
  canAction('create-local-artifacts') ? h('div', { className: 'dcAgentRow' }, h('button', {
5425
5637
  type: 'button', className: 'dcAgentButton is-primary', disabled: busy || !exportRows()?.length,
5426
5638
  onClick: () => createAndDownload('complete', 'xlsx'),
@@ -6114,7 +6326,6 @@ body:has(.dcAgentPromptBackdrop) { overflow: hidden; }
6114
6326
  DatasetReview,
6115
6327
  reviewColumns,
6116
6328
  reviewCsv,
6117
- clearCleaningDraft,
6118
6329
  entriesToDataset,
6119
6330
  extractPromptEntries,
6120
6331
  imageFilesFromTransfer,
@@ -6123,6 +6334,8 @@ body:has(.dcAgentPromptBackdrop) { overflow: hidden; }
6123
6334
  guessMappings,
6124
6335
  intakeState,
6125
6336
  restoreParsedRuntime,
6337
+ runtimeFor,
6338
+ verifyRestoredRuntime,
6126
6339
  projectCompletionResult,
6127
6340
  mappingRecommendations,
6128
6341
  mappedOutputFields,
@@ -6135,6 +6348,12 @@ body:has(.dcAgentPromptBackdrop) { overflow: hidden; }
6135
6348
  installNewSessionBridge,
6136
6349
  isCleaningSession,
6137
6350
  isKnownCleaningDraft,
6351
+ wizardListEntries,
6352
+ invalidListInstruction,
6353
+ INITIAL_DRAFT_TEMPLATE,
6354
+ initializeCleaningDraft,
6355
+ emptyInitialInput,
6356
+ startCleaningSession,
6138
6357
  markCleaningSession,
6139
6358
  qualitySummaryFor,
6140
6359
  plainEntityListDataset,
@@ -52,6 +52,7 @@ export function registerEnrichSkill(skills) {
52
52
  'When the user gives company names and asks to enrich / complete them with Qichacha (企查查), including registration fields, company panorama, ownership penetration, governance, historical changes, risk tags, intellectual property, or operational / bidding dimensions.',
53
53
  source: 'dsh-data-cleaning-agent',
54
54
  content: [
55
+ 'UX-49 最高优先级:先检查本业务请求的信息是否充分,再考虑以下任何工具或执行规则。若仍含未补全的【】占位符,或没有实际企业名单/Excel/名单图片与待补字段,先用中文要求提供名单或文件及待补字段;零工具调用,包括 OAuth 检查、体检、OCR、MCP、创建 workflow。例子、说明、【企业名单】均不是企业。用户改过的初始模板按普通内容理解,不得整体忽略。有真实暂存来源和已选字段的安全任务凭证可提供这些信息,不要求重复提供。此规则只作用于数据清洗补全业务,不得全局拦截 DSH 发送或其它产品。',
55
56
  'You are an enterprise-list enrichment assistant. You fill company lists with Qichacha (QCC) data by calling QCC MCP tools. Never invent, pad, or fabricate any field.',
56
57
  '',
57
58
  'Workbench execution request (highest priority):',
package/lib/skill.js CHANGED
@@ -15,6 +15,7 @@ export function registerSkill(skills) {
15
15
  'When the user asks to clean, complete, validate, de-duplicate, or summarize a batch of raw data rows, CSV records, or table-like data.',
16
16
  source: 'dsh-data-cleaning-agent',
17
17
  content: [
18
+ 'UX-49 — Highest priority, before ANY tool: within this data-cleaning business request, if the message still contains an unfilled 【】 placeholder, or lacks actual rows/list/file and requested completion fields, ask the user to provide the actual enterprise list or Excel/list image and the fields to complete. Make ZERO tool calls (including profile, OAuth checks, OCR, MCP and workflow creation). Do not treat examples, instructions or 【企业名单】 as enterprises. Do not intercept other DSH products or the global send action. A valid staged-source task credential can supply the list/field scope; do not demand the same data twice. An edited initial template is ordinary user content, never discard it wholesale.',
18
19
  'You are a data cleaning and completion assistant. Work only on the rows the user actually provided; never invent, pad, or fabricate extra rows.',
19
20
  '',
20
21
  'Workflow:',
package/lib/web.js CHANGED
@@ -653,6 +653,22 @@ export function mountWebRoutes(wctx, { logger, report, TOOL_NAME, SKILL_NAME })
653
653
  return writeJson(res, 405, { ok: false, code: 'DC_METHOD', message: 'Artifact route requires GET or POST.' });
654
654
  }
655
655
 
656
+ if (rest.length === 2 && rest[1] === 'verify-source') {
657
+ if (req.method !== 'POST') {
658
+ return writeJson(res, 405, { ok: false, code: 'DC_METHOD', message: 'Source verification requires POST.' });
659
+ }
660
+ const body = await readBody(req);
661
+ const payload = body.length ? JSON.parse(body.toString('utf8')) : {};
662
+ const current = await workflow.assertRequestOrigin(taskId, payload);
663
+ const verified = Boolean(current.source?.checksum)
664
+ && sourceVerifier(payload.headers, payload.rows, current.source.checksum) === current.source.checksum;
665
+ return writeJson(res, 200, {
666
+ ok: true,
667
+ marker: 'data-cleaning-source-verifier-v1',
668
+ verified,
669
+ });
670
+ }
671
+
656
672
  if (rest.length !== 3 || rest[1] !== 'actions' || req.method !== 'POST') {
657
673
  return writeJson(res, 404, { ok: false, code: 'DC_WORKFLOW_ROUTE', message: 'Workflow route not found.' });
658
674
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-data-cleaning-agent",
3
- "version": "0.9.15",
3
+ "version": "0.9.16",
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",