dsh-data-cleaning-agent 0.5.2 → 0.6.0

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
@@ -8,11 +8,14 @@
8
8
  * - `defineStore` 首选 `@deepseek-ai/dsh-client-store`,回退 `@deepseek-ai/dsh-client-runtime/client`。
9
9
  * - `sidebar.footer.action` 仅托管入口生命周期和降级渲染;实际入口 Portal 到
10
10
  * `sidebar.workspaces` 前,位于「新会话」与「工作区」之间。
11
- * - DSH 原生会话保持在中间,能力入口使用 `conversation.input.left`,右侧非模态
12
- * 工作台使用 `shell.overlay`,会话头使用 `conversation.session.header.actions`。
11
+ * - DSH 原生会话保持在中间,能力入口使用 `conversation.input.dock` 并通过稳定
12
+ * `data-slot` 排到输入框下方;右侧非模态工作台使用 `shell.overlay`。
13
+ * - 提示词生成器使用 `conversation.input.overlay`,只对本插件创建的会话生效。
14
+ * Hero 标题没有公开替换槽位,故以可恢复、精确文本匹配的 DOM Bridge 对齐业务首页。
13
15
  *
14
- * 0.5.0 工作台复用本地 `/mvp/*` 与三域 `/phase3/*` 后端,完整覆盖上传映射、
15
- * 数据体检、匹配核验、补全导出;计费调用仍由 Host Bridge 的确认/幂等/上限门约束。
16
+ * v2 工作台把上传、规则、匹配、补全、下载正式绑定到 Host taskId 工作流;原始行只保留
17
+ * 在浏览器内按 taskId 隔离的 runtime 中,Host 仅持久化任务元数据、摘要与制品引用。
18
+ * 当前业务闭环使用基础企业 G5 Bridge;历史、人员、招投标域仍按产品决策延期。
16
19
  */
17
20
  window.__ModuleLoader__.load({
18
21
  id: 'dsh-data-cleaning-agent',
@@ -124,9 +127,14 @@ window.__ModuleLoader__.load({
124
127
 
125
128
  .dcAgentCapabilities {
126
129
  display: flex;
130
+ justify-content: center;
127
131
  align-items: center;
128
- gap: 4px;
129
- max-width: min(56vw, 560px);
132
+ gap: 8px;
133
+ width: 100%;
134
+ max-width: var(--dsh-composer-card-max-width, 780px);
135
+ box-sizing: border-box;
136
+ margin: 0 auto;
137
+ padding: 2px 16px 0;
130
138
  overflow-x: auto;
131
139
  scrollbar-width: none;
132
140
  transform: translateX(var(--dc-agent-workbench-shift, 0px));
@@ -138,11 +146,14 @@ window.__ModuleLoader__.load({
138
146
  align-items: center;
139
147
  gap: 5px;
140
148
  flex: 0 0 auto;
141
- min-height: 30px;
142
- padding: 4px 8px;
143
- border: 1px solid transparent;
144
- border-radius: 8px;
145
- background: transparent;
149
+ min-width: 108px;
150
+ min-height: 54px;
151
+ padding: 7px 12px;
152
+ flex-direction: column;
153
+ justify-content: center;
154
+ border: 1px solid light-dark(#e4e8f0, #303947);
155
+ border-radius: 12px;
156
+ background: light-dark(rgba(255,255,255,.86), rgba(22,27,35,.86));
146
157
  color: light-dark(#5d687a, #aab5c7);
147
158
  font: inherit;
148
159
  font-size: 12px;
@@ -157,6 +168,212 @@ window.__ModuleLoader__.load({
157
168
  }
158
169
  .dcAgentCapability svg { flex: 0 0 auto; }
159
170
 
171
+ /* DSH 官方 input.dock 默认在 composer 上方;只把本插件自己的 cell 排到下方。 */
172
+ [data-slot="conversation.input.dock"]:has(.dcAgentExperience) {
173
+ order: 20;
174
+ }
175
+
176
+ .dcAgentExperience {
177
+ width: 100%;
178
+ box-sizing: border-box;
179
+ }
180
+
181
+ .dcAgentProductHome {
182
+ width: 100%;
183
+ max-width: 980px;
184
+ box-sizing: border-box;
185
+ margin: 10px auto 0;
186
+ padding: 16px 18px;
187
+ border: 1px solid light-dark(#e4e8f0, #303947);
188
+ border-radius: 16px;
189
+ background: light-dark(rgba(255,255,255,.82), rgba(20,25,33,.86));
190
+ box-shadow: light-dark(0 10px 30px rgba(42,75,120,.06), 0 14px 34px rgba(0,0,0,.14));
191
+ }
192
+ .dcAgentProductHome h2 {
193
+ margin: 0;
194
+ color: light-dark(#172033, #edf2fa);
195
+ font-size: 16px;
196
+ line-height: 24px;
197
+ }
198
+ .dcAgentProductHome > p {
199
+ margin: 4px 0 12px;
200
+ color: light-dark(#697386, #aab5c7);
201
+ font-size: 12px;
202
+ line-height: 18px;
203
+ }
204
+ .dcAgentHomeFlow {
205
+ display: grid;
206
+ grid-template-columns: repeat(7, minmax(0, 1fr));
207
+ gap: 8px;
208
+ }
209
+ .dcAgentHomeStep {
210
+ min-width: 0;
211
+ padding: 9px 8px;
212
+ border-radius: 10px;
213
+ background: light-dark(#f4f7fc, #1c2330);
214
+ color: light-dark(#33415a, #c9d4e5);
215
+ font-size: 12px;
216
+ line-height: 18px;
217
+ }
218
+ .dcAgentHomeStep b {
219
+ display: block;
220
+ margin-bottom: 2px;
221
+ color: light-dark(#1556cf, #8cb3ff);
222
+ font-size: 11px;
223
+ }
224
+ .dcAgentHomeTrust {
225
+ display: flex;
226
+ flex-wrap: wrap;
227
+ gap: 6px;
228
+ margin-top: 10px;
229
+ }
230
+ .dcAgentHomeTrust span {
231
+ padding: 3px 8px;
232
+ border: 1px solid light-dark(#dce4f1, #344052);
233
+ border-radius: 999px;
234
+ color: light-dark(#59677c, #b1bdd0);
235
+ font-size: 11px;
236
+ }
237
+
238
+ /* input.overlay 是 DSH 官方浮层锚点;给触发器让出输入卡片顶部空间。 */
239
+ [data-composer-card]:has(.dcAgentPromptTrigger) {
240
+ padding-top: 48px;
241
+ }
242
+ .dcAgentPromptLayer {
243
+ position: absolute;
244
+ inset: 0;
245
+ z-index: 40;
246
+ pointer-events: none;
247
+ }
248
+ .dcAgentPromptTrigger {
249
+ position: absolute;
250
+ top: 10px;
251
+ left: 16px;
252
+ min-height: 28px;
253
+ padding: 3px 10px;
254
+ border: 1px solid light-dark(#c9d9f8, #365681);
255
+ border-radius: 999px;
256
+ background: light-dark(#edf4ff, #172841);
257
+ color: light-dark(#1556cf, #8cb3ff);
258
+ font: inherit;
259
+ font-size: 12px;
260
+ cursor: pointer;
261
+ pointer-events: auto;
262
+ }
263
+ .dcAgentPromptPanel {
264
+ position: absolute;
265
+ top: 44px;
266
+ left: 16px;
267
+ width: min(660px, calc(100vw - 120px));
268
+ max-height: min(68vh, 660px);
269
+ box-sizing: border-box;
270
+ overflow: auto;
271
+ padding: 16px;
272
+ border: 1px solid light-dark(#d5dae4, #303947);
273
+ border-radius: 16px;
274
+ background: light-dark(#ffffff, #161b23);
275
+ color: light-dark(#172033, #edf2fa);
276
+ box-shadow: light-dark(0 18px 48px rgba(33,55,88,.20), 0 20px 54px rgba(0,0,0,.40));
277
+ pointer-events: auto;
278
+ }
279
+ .dcAgentPromptHead {
280
+ display: flex;
281
+ align-items: flex-start;
282
+ justify-content: space-between;
283
+ gap: 12px;
284
+ margin-bottom: 12px;
285
+ }
286
+ .dcAgentPromptHead h3 { margin: 0; font-size: 16px; line-height: 22px; }
287
+ .dcAgentPromptHead p { margin: 3px 0 0; color: light-dark(#697386, #aab5c7); font-size: 11px; line-height: 17px; }
288
+ .dcAgentPromptClose {
289
+ width: 28px;
290
+ height: 28px;
291
+ flex: none;
292
+ border: 0;
293
+ border-radius: 50%;
294
+ background: transparent;
295
+ color: inherit;
296
+ cursor: pointer;
297
+ }
298
+ .dcAgentPromptClose:hover { background: light-dark(#f1f4f8, #222a36); }
299
+ .dcAgentPromptTabs { display: flex; flex-wrap: wrap; gap: 6px; margin-bottom: 10px; }
300
+ .dcAgentPromptTab,
301
+ .dcAgentPromptChoice {
302
+ display: inline-flex;
303
+ align-items: center;
304
+ gap: 5px;
305
+ min-height: 32px;
306
+ padding: 5px 10px;
307
+ border: 1px solid light-dark(#d9dee8, #394352);
308
+ border-radius: 9px;
309
+ background: transparent;
310
+ color: inherit;
311
+ font: inherit;
312
+ font-size: 12px;
313
+ cursor: pointer;
314
+ }
315
+ .dcAgentPromptTab.is-active,
316
+ .dcAgentPromptChoice.is-selected {
317
+ border-color: light-dark(#2869e6, #6d9eff);
318
+ background: light-dark(#edf4ff, #172841);
319
+ color: light-dark(#1556cf, #8cb3ff);
320
+ }
321
+ .dcAgentPromptField { display: grid; gap: 6px; margin: 10px 0; }
322
+ .dcAgentPromptField > span { font-size: 12px; font-weight: 600; }
323
+ .dcAgentPromptText {
324
+ box-sizing: border-box;
325
+ width: 100%;
326
+ min-height: 104px;
327
+ resize: vertical;
328
+ padding: 10px 12px;
329
+ border: 1px solid light-dark(#d9dee8, #394352);
330
+ border-radius: 10px;
331
+ background: light-dark(#fbfcfe, #11171f);
332
+ color: inherit;
333
+ font: inherit;
334
+ font-size: 12px;
335
+ line-height: 20px;
336
+ }
337
+ .dcAgentPromptFile {
338
+ display: flex;
339
+ align-items: center;
340
+ gap: 10px;
341
+ padding: 12px;
342
+ border: 1px dashed light-dark(#b9c5d8, #43516a);
343
+ border-radius: 10px;
344
+ color: light-dark(#59677c, #b1bdd0);
345
+ font-size: 12px;
346
+ }
347
+ .dcAgentPromptFile input { max-width: 100%; }
348
+ .dcAgentPromptGroup { margin-top: 12px; }
349
+ .dcAgentPromptGroup > b { display: block; margin-bottom: 7px; font-size: 12px; }
350
+ .dcAgentPromptChoices { display: flex; flex-wrap: wrap; gap: 6px; }
351
+ .dcAgentPromptChoice input { margin: 0; }
352
+ .dcAgentPromptNote {
353
+ margin: 10px 0 0;
354
+ padding: 8px 10px;
355
+ border-radius: 8px;
356
+ background: light-dark(#f6f8fb, #1c2330);
357
+ color: light-dark(#697386, #aab5c7);
358
+ font-size: 11px;
359
+ line-height: 17px;
360
+ }
361
+ .dcAgentPromptError { margin: 8px 0 0; color: light-dark(#b42318, #ff8d86); font-size: 11px; }
362
+ .dcAgentPromptActions { display: flex; justify-content: flex-end; gap: 8px; margin-top: 14px; }
363
+ .dcAgentPromptAction {
364
+ min-height: 34px;
365
+ padding: 6px 13px;
366
+ border: 1px solid light-dark(#d9dee8, #394352);
367
+ border-radius: 9px;
368
+ background: transparent;
369
+ color: inherit;
370
+ font: inherit;
371
+ font-size: 12px;
372
+ cursor: pointer;
373
+ }
374
+ .dcAgentPromptAction.is-primary { border-color: #2869e6; background: #2869e6; color: white; }
375
+ .dcAgentPromptAction:disabled { opacity: .5; cursor: default; }
376
+
160
377
  .dcAgentHeaderAction {
161
378
  display: inline-flex;
162
379
  align-items: center;
@@ -459,13 +676,54 @@ window.__ModuleLoader__.load({
459
676
  line-height: 1.6;
460
677
  color: light-dark(#33415a, #c9d4e5);
461
678
  }
679
+ .dcAgentHomeHero { display: flex; align-items: center; justify-content: space-between; gap: 18px; }
680
+ .dcAgentHomeHero p { margin: 5px 0 0; color: light-dark(#697386, #aab5c7); font-size: 12px; line-height: 18px; }
681
+ .dcAgentHomeEyebrow { display: block; margin-bottom: 4px; color: light-dark(#2869e6, #8cb3ff); font-size: 11px; font-weight: 700; letter-spacing: .08em; }
682
+ .dcAgentHomeActions { display: flex; flex-wrap: wrap; gap: 8px; margin-top: 10px; }
683
+ .dcAgentHomeActions button,
684
+ .dcAgentHomeRecent button,
685
+ .dcAgentHistoryTask { border: 1px solid light-dark(#dce4f1, #344052); border-radius: 9px; background: light-dark(#fff, #1b222e); color: inherit; font: inherit; cursor: pointer; }
686
+ .dcAgentHomeActions button { padding: 6px 9px; font-size: 11px; }
687
+ .dcAgentHomeRecent { display: grid; gap: 6px; margin-top: 12px; }
688
+ .dcAgentHomeRecent > b { font-size: 12px; }
689
+ .dcAgentHomeRecent button { display: flex; justify-content: space-between; gap: 12px; padding: 7px 9px; text-align: left; font-size: 11px; }
690
+ .dcAgentHomeRecent small { color: light-dark(#697386, #aab5c7); }
691
+ .dcAgentWizardNav { display: grid; grid-template-columns: repeat(4, 1fr); gap: 6px; margin-bottom: 12px; }
692
+ .dcAgentWizardStep { display: flex; align-items: center; gap: 6px; padding: 7px; border: 1px solid light-dark(#d9dee8, #394352); border-radius: 9px; background: transparent; color: inherit; font: inherit; font-size: 11px; cursor: pointer; }
693
+ .dcAgentWizardStep b { display: grid; place-items: center; width: 20px; height: 20px; border-radius: 50%; background: light-dark(#eef1f6, #252e3b); color: light-dark(#607088, #b9c6da); }
694
+ .dcAgentWizardStep.is-active { border-color: #2869e6; background: light-dark(#edf4ff, #172841); color: light-dark(#1556cf, #8cb3ff); }
695
+ .dcAgentWizardStep.is-active b { background: #2869e6; color: white; }
696
+ .dcAgentWizardPane { min-height: 230px; }
697
+ .dcAgentWizardPane h4 { margin: 0 0 3px; font-size: 14px; }
698
+ .dcAgentWizardPane > p { margin: 0 0 12px; color: light-dark(#697386, #aab5c7); font-size: 11px; line-height: 17px; }
699
+ .dcAgentPromptRules { display: grid; gap: 8px; margin-top: 14px; }
700
+ .dcAgentPromptRules label { font-size: 12px; }
701
+ .dcAgentPromptPreview { max-height: 260px; overflow: auto; margin: 10px 0 0; padding: 12px; border-radius: 10px; background: light-dark(#f6f8fb, #11171f); white-space: pre-wrap; word-break: break-word; font: inherit; font-size: 11px; line-height: 18px; }
702
+ .dcAgentFormField { display: grid; gap: 6px; font-size: 12px; font-weight: 600; }
703
+ .dcAgentMappingRow { display: grid; grid-template-columns: minmax(0, 1fr) 24px minmax(180px, 1fr); align-items: center; gap: 8px; margin-top: 7px; font-size: 12px; }
704
+ .dcAgentMappingRow > span { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
705
+ .dcAgentMappingRow > b { text-align: center; color: light-dark(#8792a3, #8290a5); }
706
+ .dcAgentRulesGrid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 8px; }
707
+ .dcAgentFieldGroup { display: grid; gap: 7px; margin-top: 12px; }
708
+ .dcAgentFieldGroup > b { font-size: 12px; }
709
+ .dcAgentPreviewTable { margin-top: 12px; overflow: auto; border: 1px solid light-dark(#e4e8f0, #303947); border-radius: 10px; }
710
+ .dcAgentHistoryTask { display: block; width: 100%; padding: 10px; text-align: left; }
711
+ .dcAgentHistoryTask h3 { margin: 0; font-size: 12px; }
462
712
  @media (max-width: 760px) {
463
713
  .dcAgentWorkbench, .dcAgentWorkbench.is-expanded { width: 100vw; border-radius: 0; }
464
714
  .dcAgentOverlay { background: rgba(8, 10, 14, 0.22); pointer-events: auto; }
465
715
  .dcAgentStepper { grid-template-columns: repeat(2, minmax(0, 1fr)); }
466
716
  .dcAgentQccBadge { display: none; }
467
717
  .dcAgentCandidate { grid-template-columns: 1fr; }
468
- .dcAgentCapabilityLabel { display: none; }
718
+ .dcAgentCapabilities { justify-content: flex-start; padding-inline: 12px; }
719
+ .dcAgentCapability { min-width: 92px; }
720
+ .dcAgentPromptPanel { left: 8px; width: calc(100vw - 88px); max-height: 72vh; }
721
+ .dcAgentHomeFlow { grid-template-columns: repeat(2, minmax(0, 1fr)); }
722
+ .dcAgentHomeHero { align-items: flex-start; flex-direction: column; }
723
+ .dcAgentWizardNav { grid-template-columns: repeat(2, 1fr); }
724
+ .dcAgentRulesGrid { grid-template-columns: 1fr; }
725
+ .dcAgentMappingRow { grid-template-columns: 1fr; }
726
+ .dcAgentMappingRow > b { display: none; }
469
727
  }
470
728
  `;
471
729
 
@@ -479,38 +737,339 @@ window.__ModuleLoader__.load({
479
737
  return () => { style.remove(); };
480
738
  }
481
739
 
482
- // 会话级数据(不进 store,避免大快照膨胀)。原始行仅用于后端往返,不进模型上下文。
483
- let session = { rows: [], headers: [] };
484
- let lastCsv = { clean: null, complete: null, qcc: null, review: null };
740
+ // 原始数据不进入 Host storageDomain 或模型上下文;只在当前页面按 taskId 隔离。
741
+ const runtimeTasks = new Map();
742
+ const workflowTaskBySession = new Map();
743
+ const workflowTaskCreationBySession = new Map();
744
+ const workflowOperationBySession = new Map();
745
+
746
+ function runtimeFor(taskId, create = true) {
747
+ const key = String(taskId || 'unassigned');
748
+ if (!runtimeTasks.has(key) && create) {
749
+ runtimeTasks.set(key, {
750
+ rows: [],
751
+ headers: [],
752
+ source: null,
753
+ lastCsv: { clean: null, complete: null, qcc: null, review: null },
754
+ resultRows: { clean: null, complete: null, qcc: null },
755
+ });
756
+ }
757
+ return runtimeTasks.get(key) ?? null;
758
+ }
759
+
760
+ function moveRuntime(fromId, toId) {
761
+ const from = runtimeFor(fromId, false);
762
+ if (!from || !toId || String(fromId) === String(toId)) return runtimeFor(toId);
763
+ const target = runtimeFor(toId);
764
+ target.rows = from.rows;
765
+ target.headers = from.headers;
766
+ target.source = from.source;
767
+ target.lastCsv = from.lastCsv;
768
+ target.resultRows = from.resultRows;
769
+ runtimeTasks.delete(String(fromId || 'unassigned'));
770
+ return target;
771
+ }
485
772
 
486
773
  const STEPS = [
487
- { key: 'upload', label: '上传与映射', icon: '📄' },
488
- { key: 'profile', label: '数据体检', icon: '🩺' },
489
- { key: 'review', label: '匹配核验', icon: '🔎' },
490
- { key: 'enrich', label: '补全与导出', icon: '⬇️' },
774
+ { key: 'upload', label: '上传数据', icon: '📄' },
775
+ { key: 'rules', label: '规则确认', icon: '' },
776
+ { key: 'match', label: '数据匹配', icon: '🔎' },
777
+ { key: 'enrich', label: '清洗补全', icon: '' },
778
+ { key: 'download', label: '下载数据', icon: '⬇️' },
491
779
  ];
492
780
 
493
781
  const CAPABILITIES = [
494
782
  { key: 'upload', label: '上传清洗', icon: IconPaperclipOutline16, fallback: '+' },
495
783
  { key: 'profile', label: '质量体检', icon: IconChecklistOutline14, fallback: '✓' },
496
- { key: 'review', label: '匹配核验', icon: IconSearchOutline16, fallback: '⌕' },
784
+ { key: 'match', label: '匹配核验', icon: IconSearchOutline16, fallback: '⌕' },
497
785
  { key: 'enrich', label: '字段补全', icon: IconDataOutline16, fallback: '▦' },
498
786
  { key: 'history', label: '任务历史', icon: IconArchiveOutline20, fallback: '◷' },
499
787
  ];
500
788
 
501
- const DEFAULT_SESSION_PROMPT = '请帮我清洗并补全企业名单。我会在右侧数据清洗工作台上传文件或粘贴数据。';
789
+ const CLEANING_OPTIONS = [
790
+ ['clean_name', '名称补全与规范'],
791
+ ['deduplicate', '重复企业去重'],
792
+ ['validate_identity', '主体标识校验'],
793
+ ['complete_fields', '补全所选企业字段'],
794
+ ];
795
+ const FIELD_GROUPS = [
796
+ ['identity', '基础工商信息', [
797
+ ['company_name', '企业名称'], ['credit_no', '统一社会信用代码'], ['reg_no', '注册号'],
798
+ ['reg_status', '登记状态'], ['legal_rep', '法定代表人'], ['reg_capital', '注册资本'],
799
+ ['paid_capital', '实缴资本'], ['establish_date', '成立日期'], ['company_type', '企业类型'],
800
+ ['registration_authority', '登记机关'], ['former_name', '曾用名'], ['english_name', '英文名'],
801
+ ]],
802
+ ['contact', '地址与联系方式', [
803
+ ['registered_address', '注册地址'], ['province', '省份地区'], ['city', '城市'],
804
+ ['district', '区县'], ['phone', '电话'], ['email', '邮箱'], ['website', '官网'],
805
+ ]],
806
+ ['operation', '经营信息', [
807
+ ['business_scope', '经营范围'], ['industry_category', '国标行业'], ['industry_large', '一级行业'],
808
+ ['industry_middle', '二级行业'], ['operating_period', '营业期限'], ['company_size', '企业规模'],
809
+ ['company_profile', '企业简介'],
810
+ ]],
811
+ ['risk', '风险摘要', [
812
+ ['risk_summary', '风险摘要'], ['operating_exception', '经营异常摘要'], ['administrative_penalty', '行政处罚摘要'],
813
+ ]],
814
+ ['ipr', '知识产权摘要', [
815
+ ['trademark_summary', '商标摘要'], ['patent_summary', '专利摘要'], ['software_copyright_summary', '软件著作权摘要'],
816
+ ]],
817
+ ];
818
+ const ENRICHMENT_OPTIONS = FIELD_GROUPS.flatMap(([, , fields]) => fields);
819
+ const MATCH_ANCHOR_OPTIONS = [
820
+ ['company_name', '企业名称'], ['credit_no', '统一社会信用代码'], ['reg_no', '注册号'],
821
+ ];
822
+ const DEFAULT_CLEANING_KEYS = ['clean_name', 'deduplicate', 'validate_identity', 'complete_fields'];
823
+ const DEFAULT_ENRICHMENT_KEYS = ['credit_no', 'legal_rep', 'reg_capital', 'establish_date', 'reg_status'];
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();
502
828
 
503
- /** 后端往返:POST JSON,返回解析后的对象。 */
504
- async function api(path, body) {
829
+ 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
+ }
834
+ } catch (_error) {
835
+ // sessionStorage 可能被禁用;当前页面内的 Set 仍可工作。
836
+ }
837
+
838
+ function isCleaningSession(sessionId) {
839
+ return typeof sessionId === 'string' && cleaningSessionIds.has(sessionId);
840
+ }
841
+
842
+ function markCleaningSession(sessionId) {
843
+ 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
+ // 不因浏览器存储策略阻断会话。
849
+ }
850
+ if (typeof window.CustomEvent === 'function' && typeof window.dispatchEvent === 'function') {
851
+ window.dispatchEvent(new window.CustomEvent(CLEANING_SESSION_EVENT, { detail: { sessionId } }));
852
+ }
853
+ }
854
+
855
+ function useCleaningSession(sessionId) {
856
+ const [markedSessionId, setMarkedSessionId] = react.useState(
857
+ isCleaningSession(sessionId) ? sessionId : null,
858
+ );
859
+ react.useEffect(() => {
860
+ const handleMarked = (event) => {
861
+ if (event?.detail?.sessionId === sessionId) setMarkedSessionId(sessionId);
862
+ };
863
+ window.addEventListener?.(CLEANING_SESSION_EVENT, handleMarked);
864
+ return () => window.removeEventListener?.(CLEANING_SESSION_EVENT, handleMarked);
865
+ }, [sessionId]);
866
+ // DSH 可能复用同一个 slot component 切换会话;状态必须绑定具体 sessionId,
867
+ // 避免从清洗会话切回普通会话后仍残留业务首页与提示词入口。
868
+ return markedSessionId === sessionId || isCleaningSession(sessionId);
869
+ }
870
+
871
+ function optionLabels(options, keys) {
872
+ const selected = new Set(Array.isArray(keys) ? keys : []);
873
+ return options.filter(([key]) => selected.has(key)).map(([, label]) => label);
874
+ }
875
+
876
+ /** 纯函数:把向导选择转换成可编辑、可审计的自然语言任务描述。 */
877
+ function buildTaskPrompt(config = {}) {
878
+ const mode = config.mode ?? 'text';
879
+ const cleanLabels = optionLabels(CLEANING_OPTIONS, config.cleaningKeys);
880
+ const enrichLabels = optionLabels(ENRICHMENT_OPTIONS, config.enrichmentKeys);
881
+ const anchorLabels = optionLabels(MATCH_ANCHOR_OPTIONS, config.anchorKeys);
882
+ const entries = Array.isArray(config.entries)
883
+ ? config.entries.map((value) => String(value ?? '').trim()).filter(Boolean)
884
+ : [];
885
+ const entryCount = Number.isFinite(config.entryCount) ? config.entryCount : entries.length;
886
+ const source = mode === 'image'
887
+ ? `已附加图片${config.fileName ? `「${config.fileName}」` : ''}`
888
+ : mode === 'excel'
889
+ ? `本地表格${config.fileName ? `「${config.fileName}」` : ''},已解析 ${entryCount} 条主体标识,完整数据已载入数据清洗补全工作台`
890
+ : `手工录入,共 ${entryCount} 条主体标识`;
891
+ const lines = [
892
+ '请执行一项企业名单数据清洗补全任务。',
893
+ `输入来源:${source}。`,
894
+ ];
895
+ if (mode === 'image') {
896
+ lines.push('请先使用当前已连接且可用的企查查智能文档解析 MCP 能力,从图片中提取企业全称或统一社会信用代码;若该能力未连接或不可用,请先提示我改用文本或 Excel。');
897
+ }
898
+ if (entries.length) {
899
+ lines.push(`${mode === 'excel' ? '主体预览' : '待处理主体'}:\n${entries.map((entry, index) => `${index + 1}. ${entry}`).join('\n')}`);
900
+ }
901
+ lines.push(`清洗要求:${cleanLabels.length ? cleanLabels.join('、') : '仅解析,不自动修改'}。`);
902
+ lines.push(`需要补全的字段 / 维度:${enrichLabels.length ? enrichLabels.join('、') : '不调用外部补全,仅输出本地清洗结果'}。`);
903
+ lines.push(`处理规则:优先使用${anchorLabels.length ? anchorLabels.join('、') : '企业名称、统一社会信用代码或注册号'}精确匹配;${config.matchRules?.manualReviewAmbiguous === false ? '精确匹配失败时直接输出未匹配记录' : '精确匹配失败时再进入模糊候选,存在多个候选必须暂停并让我确认,不得默认选择第一项'}。`);
904
+ lines.push('请先生成数据质量体检与匹配摘要,经我确认后再执行需要消耗额度的企查查 MCP 调用;企查查连接、套餐额度和费用均由当前用户自己的账号承担。缺失或无权限字段请留空并标记原因,不得编造。');
905
+ lines.push('完成后保留来源原值、标准主体、匹配状态与字段来源,并提供结果和待复核清单的导出。');
906
+ return lines.join('\n\n');
907
+ }
908
+
909
+ function entriesToDataset(entries) {
910
+ // 文本向导允许企业名称和信用代码混输。统一为一个检索锚点列,避免 G5 Bridge
911
+ // 只能选择单一 nameField 时遗漏信用代码行;QCC entity lookup 对两者均可检索。
912
+ const rows = (Array.isArray(entries) ? entries : [])
913
+ .map((value) => ({ 主体标识: String(value ?? '').trim() }))
914
+ .filter((row) => row.主体标识);
915
+ return {
916
+ ok: true,
917
+ fmt: 'text',
918
+ headers: ['主体标识'],
919
+ rowCount: rows.length,
920
+ rows,
921
+ preview: rows.slice(0, 5),
922
+ };
923
+ }
924
+
925
+ const MAPPING_PATTERNS = [
926
+ ['company_name', /^(name|company|company_name|企业名称|公司名称|单位名称)$/i],
927
+ ['credit_no', /^(credit_no|creditCode|unified_credit_code|统一社会信用代码|信用代码)$/i],
928
+ ['reg_no', /^(reg_no|registration_no|注册号)$/i],
929
+ ['phone', /^(phone|mobile|tel|telephone|联系电话|手机号码|手机号)$/i],
930
+ ['province', /^(province|省份|省份地区)$/i],
931
+ ['registered_address', /^(address|registered_address|注册地址|企业地址|公司地址)$/i],
932
+ ];
933
+
934
+ function guessMappings(headers) {
935
+ const output = [];
936
+ const usedTargets = new Set();
937
+ for (const sourceField of Array.isArray(headers) ? headers : []) {
938
+ const match = MAPPING_PATTERNS.find(([targetField, pattern]) => !usedTargets.has(targetField) && pattern.test(String(sourceField)));
939
+ if (match) {
940
+ output.push({ sourceField: String(sourceField), targetField: match[0] });
941
+ usedTargets.add(match[0]);
942
+ }
943
+ }
944
+ if (!output.length && Array.isArray(headers) && headers[0]) {
945
+ output.push({ sourceField: String(headers[0]), targetField: 'company_name' });
946
+ }
947
+ return output;
948
+ }
949
+
950
+ function qualitySummaryFor(rows, mappings) {
951
+ const list = Array.isArray(rows) ? rows : [];
952
+ const anchors = (Array.isArray(mappings) ? mappings : []).filter((mapping) => ['company_name', 'credit_no', 'reg_no'].includes(mapping.targetField));
953
+ const signatures = new Set();
954
+ let missingAnchor = 0;
955
+ let duplicates = 0;
956
+ let invalidCreditNo = 0;
957
+ let invalidPhone = 0;
958
+ let emptyFields = 0;
959
+ const creditSource = mappings?.find((mapping) => mapping.targetField === 'credit_no')?.sourceField;
960
+ const phoneSource = mappings?.find((mapping) => mapping.targetField === 'phone')?.sourceField;
961
+ for (const row of list) {
962
+ const values = anchors.map((mapping) => String(row?.[mapping.sourceField] ?? '').trim()).filter(Boolean);
963
+ if (!values.length) missingAnchor += 1;
964
+ const signature = values.join('|').toUpperCase();
965
+ if (signature && signatures.has(signature)) duplicates += 1;
966
+ if (signature) signatures.add(signature);
967
+ const credit = creditSource ? String(row?.[creditSource] ?? '').trim() : '';
968
+ if (credit && !/^[0-9A-Z]{18}$/.test(credit)) invalidCreditNo += 1;
969
+ const phone = phoneSource ? String(row?.[phoneSource] ?? '').replace(/[\s-]/g, '') : '';
970
+ if (phone && !/^(?:\+?86)?(?:1\d{10}|0\d{9,11})$/.test(phone)) invalidPhone += 1;
971
+ emptyFields += Object.values(row ?? {}).filter((value) => String(value ?? '').trim() === '').length;
972
+ }
973
+ return {
974
+ total: list.length,
975
+ valid: Math.max(0, list.length - missingAnchor),
976
+ missingAnchor,
977
+ duplicates,
978
+ invalidCreditNo,
979
+ invalidPhone,
980
+ emptyFields,
981
+ };
982
+ }
983
+
984
+ /** 同源 Host API;保留旧 api(path, body) 调用,同时为工作流开放 PATCH。 */
985
+ async function requestJson(path, method = 'GET', body) {
505
986
  const res = await fetch(path, {
506
- method: body ? 'POST' : 'GET',
507
- headers: body ? { 'content-type': 'application/json' } : undefined,
508
- body: body ? JSON.stringify(body) : undefined,
987
+ method,
988
+ headers: body !== undefined ? { 'content-type': 'application/json' } : undefined,
989
+ body: body !== undefined ? JSON.stringify(body) : undefined,
509
990
  });
510
- return res.json();
991
+ const payload = await res.json();
992
+ if (res.ok === false || payload?.ok === false) {
993
+ const error = new Error(payload?.message || payload?.error || `Host request failed (${res.status})`);
994
+ error.code = payload?.code;
995
+ throw error;
996
+ }
997
+ return payload;
998
+ }
999
+
1000
+ async function api(path, body) {
1001
+ return requestJson(path, body === undefined ? 'GET' : 'POST', body);
1002
+ }
1003
+
1004
+ function cacheWorkflowTask(actions, sessionId, task) {
1005
+ if (!task) return null;
1006
+ if (sessionId) workflowTaskBySession.set(String(sessionId), task);
1007
+ actions.setWorkflowTask(task);
1008
+ if (task.title) actions.setTaskTitle(task.title);
1009
+ if (Array.isArray(task.objectives)) actions.setObjectives(task.objectives);
1010
+ if (Array.isArray(task.fieldSelection)) actions.setFieldSelection(task.fieldSelection);
1011
+ if (Array.isArray(task.mappings) && task.mappings.length) actions.setMappings(task.mappings);
1012
+ return task;
511
1013
  }
512
1014
 
513
- /** 工作台 store:入口按钮与工作台共享 open,另存四步流程 UI 状态。 */
1015
+ async function ensureWorkflowTask(actions, sessionId, draft = {}) {
1016
+ const key = String(sessionId || 'unassigned');
1017
+ const cached = workflowTaskBySession.get(key);
1018
+ if (cached) return cacheWorkflowTask(actions, sessionId, cached);
1019
+ let pending = workflowTaskCreationBySession.get(key);
1020
+ if (!pending) {
1021
+ pending = requestJson('/data-cleaning/api/workflow/tasks', 'POST', {
1022
+ title: draft.title || '企业数据清洗补全任务',
1023
+ objectives: draft.objectives || DEFAULT_CLEANING_KEYS,
1024
+ fieldSelection: draft.fieldSelection || DEFAULT_ENRICHMENT_KEYS,
1025
+ mappings: draft.mappings || [],
1026
+ matchRules: draft.matchRules,
1027
+ });
1028
+ workflowTaskCreationBySession.set(key, pending);
1029
+ const clearPending = () => {
1030
+ if (workflowTaskCreationBySession.get(key) === pending) workflowTaskCreationBySession.delete(key);
1031
+ };
1032
+ pending.then(clearPending, clearPending);
1033
+ }
1034
+ const response = await pending;
1035
+ moveRuntime(`session:${key}`, response.task.id);
1036
+ return cacheWorkflowTask(actions, sessionId, response.task);
1037
+ }
1038
+
1039
+ /** 同一会话的 Host 写操作串行化,确保 expectedRevision 不因 UI 事件竞态失效。 */
1040
+ function queueWorkflowOperation(sessionId, operation) {
1041
+ const key = String(sessionId || 'unassigned');
1042
+ const previous = workflowOperationBySession.get(key) ?? Promise.resolve();
1043
+ const next = previous.catch(() => {}).then(operation);
1044
+ workflowOperationBySession.set(key, next);
1045
+ const clear = () => {
1046
+ if (workflowOperationBySession.get(key) === next) workflowOperationBySession.delete(key);
1047
+ };
1048
+ next.then(clear, clear);
1049
+ return next;
1050
+ }
1051
+
1052
+ async function updateWorkflowTask(actions, sessionId, task, input) {
1053
+ const latest = workflowTaskBySession.get(String(sessionId || 'unassigned')) ?? task;
1054
+ if (!latest) return ensureWorkflowTask(actions, sessionId, input);
1055
+ const response = await requestJson(`/data-cleaning/api/workflow/tasks/${encodeURIComponent(latest.id)}`, 'PATCH', {
1056
+ ...input,
1057
+ expectedRevision: latest.revision,
1058
+ });
1059
+ return cacheWorkflowTask(actions, sessionId, response.task);
1060
+ }
1061
+
1062
+ async function workflowAction(actions, sessionId, task, action, input = {}) {
1063
+ const latest = workflowTaskBySession.get(String(sessionId || 'unassigned')) ?? task;
1064
+ if (!latest) throw new Error('请先创建数据清洗补全任务。');
1065
+ const response = await requestJson(`/data-cleaning/api/workflow/tasks/${encodeURIComponent(latest.id)}/actions/${action}`, 'POST', {
1066
+ ...input,
1067
+ expectedRevision: latest.revision,
1068
+ });
1069
+ return cacheWorkflowTask(actions, sessionId, response.task);
1070
+ }
1071
+
1072
+ /** 工作台 store:入口按钮与工作台共享 open,并保存当前 taskId 的可持久化 UI 元数据。 */
514
1073
  function createWorkbenchStore() {
515
1074
  return defineStore({
516
1075
  init: () => ({
@@ -520,6 +1079,14 @@ window.__ModuleLoader__.load({
520
1079
  busy: false,
521
1080
  error: null,
522
1081
  input: '',
1082
+ workflowContract: null,
1083
+ workflowTask: null,
1084
+ workflowTasks: [],
1085
+ taskTitle: '企业数据清洗补全任务',
1086
+ mappings: [],
1087
+ objectives: [...DEFAULT_CLEANING_KEYS],
1088
+ fieldSelection: [...DEFAULT_ENRICHMENT_KEYS],
1089
+ matchRules: { normalizeNames: true, preferCreditNo: true, deduplicate: true, manualReviewAmbiguous: true },
523
1090
  dataset: null, // { fmt, headers, rowCount, preview }
524
1091
  profile: null, // summary
525
1092
  clean: null, // summary
@@ -547,6 +1114,46 @@ window.__ModuleLoader__.load({
547
1114
  setBusy: (draft, busy) => { draft.busy = busy; },
548
1115
  setError: (draft, error) => { draft.error = error; },
549
1116
  setInput: (draft, input) => { draft.input = input; },
1117
+ setWorkflowContract: (draft, value) => { draft.workflowContract = value; },
1118
+ setWorkflowTask: (draft, value) => { draft.workflowTask = value ?? null; },
1119
+ setWorkflowTasks: (draft, value) => { draft.workflowTasks = Array.isArray(value) ? value : []; },
1120
+ setTaskTitle: (draft, value) => { draft.taskTitle = String(value ?? ''); },
1121
+ setMappings: (draft, value) => { draft.mappings = Array.isArray(value) ? value : []; },
1122
+ setMapping: (draft, sourceField, targetField) => {
1123
+ const mappings = draft.mappings.filter((item) => item.sourceField !== sourceField && item.targetField !== targetField);
1124
+ if (targetField) mappings.push({ sourceField, targetField });
1125
+ draft.mappings = mappings;
1126
+ },
1127
+ setObjectives: (draft, value) => { draft.objectives = Array.isArray(value) ? value : []; },
1128
+ toggleObjective: (draft, value) => {
1129
+ const selected = new Set(draft.objectives);
1130
+ if (selected.has(value)) selected.delete(value); else selected.add(value);
1131
+ draft.objectives = [...selected];
1132
+ },
1133
+ setFieldSelection: (draft, value) => { draft.fieldSelection = Array.isArray(value) ? value : []; },
1134
+ toggleField: (draft, value) => {
1135
+ const selected = new Set(draft.fieldSelection);
1136
+ if (selected.has(value)) selected.delete(value); else selected.add(value);
1137
+ draft.fieldSelection = [...selected];
1138
+ },
1139
+ setMatchRule: (draft, key, value) => { draft.matchRules = { ...draft.matchRules, [key]: Boolean(value) }; },
1140
+ resetWorkflow: (draft) => {
1141
+ draft.step = 'upload';
1142
+ draft.workflowTask = null;
1143
+ draft.taskTitle = '企业数据清洗补全任务';
1144
+ draft.mappings = [];
1145
+ draft.objectives = [...DEFAULT_CLEANING_KEYS];
1146
+ draft.fieldSelection = [...DEFAULT_ENRICHMENT_KEYS];
1147
+ draft.matchRules = { normalizeNames: true, preferCreditNo: true, deduplicate: true, manualReviewAmbiguous: true };
1148
+ draft.dataset = null;
1149
+ draft.profile = null;
1150
+ draft.clean = null;
1151
+ draft.complete = null;
1152
+ draft.qccEstimate = null;
1153
+ draft.qccRun = null;
1154
+ draft.paidConfirmed = false;
1155
+ draft.error = null;
1156
+ },
550
1157
  setDataset: (draft, dataset) => { draft.dataset = dataset; draft.error = null; },
551
1158
  setProfile: (draft, profile) => { draft.profile = profile; draft.error = null; },
552
1159
  setClean: (draft, clean) => { draft.clean = clean; draft.error = null; },
@@ -598,7 +1205,7 @@ window.__ModuleLoader__.load({
598
1205
  : items.find((item) => Array.isArray(item.sessionIds) && item.sessionIds.includes(current))?.workspaceId;
599
1206
  const targetWorkspaceId = currentWorkspaceId ?? workspace.recentWorkspaceId ?? items[0]?.workspaceId;
600
1207
  if (targetWorkspaceId === undefined) {
601
- throw new Error('请先选择一个工作空间,再打开数据清洗智能体');
1208
+ throw new Error('请先选择一个工作空间,再打开数据清洗补全智能体');
602
1209
  }
603
1210
  // rc.2 暴露 workspaces.connectWorkspace;alpha.2 把同一导航策略迁移到
604
1211
  // uiWorkspace.connectWorkspace,纯 workspaces controller 不再带该方法。
@@ -617,11 +1224,33 @@ window.__ModuleLoader__.load({
617
1224
  if (!sessionId) throw new Error('当前 DSH 版本没有可用的工作区会话创建能力');
618
1225
  const conversation = typeof ctx.get === 'function' ? ctx.get('conversation') : ctx.conversation;
619
1226
  if (!conversation) throw new Error('DSH 对话服务尚未就绪,请稍后重试');
1227
+ markCleaningSession(sessionId);
620
1228
  conversation.input.shell(sessionId).setDraft(DEFAULT_SESSION_PROMPT);
621
1229
  ctx.sessions.open(sessionId);
622
1230
  return sessionId;
623
1231
  }
624
1232
 
1233
+ /**
1234
+ * 图片接入兼容 Bridge。仅在运行时同时探测到 createDraftImages / input.shell.addImages
1235
+ * 时使用;失败会释放浏览器临时对象,不假设 alpha 实验 API 稳定。
1236
+ */
1237
+ function attachPromptImages(ctx, sessionId, files) {
1238
+ const conversation = typeof ctx.get === 'function' ? ctx.get('conversation') : ctx.conversation;
1239
+ if (!conversation || typeof conversation.createDraftImages !== 'function') {
1240
+ throw new Error('当前 DSH 版本不支持从插件附加图片,请使用输入框原生“+”按钮上传。');
1241
+ }
1242
+ const shell = conversation.input?.shell?.(sessionId);
1243
+ if (!shell || typeof shell.addImages !== 'function') {
1244
+ throw new Error('当前会话的图片输入能力尚未就绪,请稍后重试。');
1245
+ }
1246
+ const images = conversation.createDraftImages(files);
1247
+ if (!shell.addImages(images.map((image) => image.id))) {
1248
+ conversation.releaseDraftImages?.(images);
1249
+ throw new Error('图片未能加入当前对话,请检查格式或数量限制。');
1250
+ }
1251
+ return images.length;
1252
+ }
1253
+
625
1254
  function capabilityIcon(item, size = 16) {
626
1255
  return typeof item.icon === 'function'
627
1256
  ? h(item.icon, { size, 'aria-hidden': 'true' })
@@ -634,27 +1263,60 @@ window.__ModuleLoader__.load({
634
1263
  }
635
1264
 
636
1265
  const WORKBENCH_OPEN_EVENT = 'dsh:data-cleaning-workbench-open';
1266
+ const WORKBENCH_DATASET_EVENT = 'dsh:data-cleaning-workbench-dataset';
1267
+ const WORKBENCH_DRAFT_EVENT = 'dsh:data-cleaning-workbench-draft';
637
1268
 
638
1269
  // session scope 不能复用 root scope 的 store handle。DSH 当前会分别物化 root/session
639
1270
  // 插槽组件,因此以 document 事件跨 scope 通知;闭包引用仅作为无 DOM 测试降级。
640
1271
  let rootWorkbenchActions = null;
641
1272
 
642
1273
  /** 从 session scope 的 composer/header 触发 root scope 工作台。 */
643
- function requestWorkbenchOpen(step, sessionId) {
1274
+ function requestWorkbenchOpen(step, sessionId, task = null) {
644
1275
  if (typeof document !== 'undefined'
645
1276
  && typeof document.createEvent === 'function'
646
1277
  && typeof document.dispatchEvent === 'function') {
647
1278
  const event = document.createEvent('CustomEvent');
648
- event.initCustomEvent(WORKBENCH_OPEN_EVENT, false, true, { step, sessionId });
1279
+ event.initCustomEvent(WORKBENCH_OPEN_EVENT, false, true, { step, sessionId, task });
649
1280
  if (!document.dispatchEvent(event)) return;
650
1281
  }
651
1282
  if (rootWorkbenchActions) openWorkbench(rootWorkbenchActions, step, sessionId);
652
1283
  }
653
1284
 
1285
+ /** 把提示词生成器解析出的完整表格交给 root 工作台,不把整表塞进模型上下文。 */
1286
+ function requestWorkbenchDataset(result, sessionId, source) {
1287
+ if (typeof document !== 'undefined'
1288
+ && typeof document.createEvent === 'function'
1289
+ && typeof document.dispatchEvent === 'function') {
1290
+ const event = document.createEvent('CustomEvent');
1291
+ event.initCustomEvent(WORKBENCH_DATASET_EVENT, false, true, { result, sessionId, source });
1292
+ if (!document.dispatchEvent(event)) return;
1293
+ }
1294
+ if (rootWorkbenchActions) {
1295
+ applyParsed(result, rootWorkbenchActions, `session:${sessionId || 'unassigned'}`, source);
1296
+ rootWorkbenchActions.setActiveSession(sessionId);
1297
+ }
1298
+ }
1299
+
1300
+ /** 提示词向导只提交可持久化的任务设置;名单明细仍通过 dataset 事件进入 task runtime。 */
1301
+ function requestWorkbenchDraft(draft, sessionId) {
1302
+ if (typeof document !== 'undefined'
1303
+ && typeof document.createEvent === 'function'
1304
+ && typeof document.dispatchEvent === 'function') {
1305
+ const event = document.createEvent('CustomEvent');
1306
+ event.initCustomEvent(WORKBENCH_DRAFT_EVENT, false, true, { draft, sessionId });
1307
+ if (!document.dispatchEvent(event)) return;
1308
+ }
1309
+ if (rootWorkbenchActions) {
1310
+ rootWorkbenchActions.setTaskTitle(draft.title);
1311
+ rootWorkbenchActions.setObjectives(draft.objectives);
1312
+ rootWorkbenchActions.setFieldSelection(draft.fieldSelection);
1313
+ }
1314
+ }
1315
+
654
1316
  /** 左栏入口按钮:公开 footer 托管,真实按钮 Portal 到「新会话 / 工作区」之间。 */
655
1317
  function SidebarEntry(props) {
656
1318
  const { wide, useStore, actions, startSession } = props;
657
- const open = useStore((state) => state.open);
1319
+ useStore((state) => state.open);
658
1320
  const [topMount, setTopMount] = react.useState(null);
659
1321
 
660
1322
  react.useEffect(() => {
@@ -683,12 +1345,9 @@ window.__ModuleLoader__.load({
683
1345
  variant: 'ghost',
684
1346
  className: 'dcAgentLauncher',
685
1347
  'data-wide': wide,
686
- 'aria-label': '数据清洗',
687
- 'aria-haspopup': 'dialog',
688
- 'aria-expanded': open,
1348
+ 'aria-label': '数据清洗补全',
689
1349
  onClick: async () => {
690
1350
  try {
691
- openWorkbench(actions, 'upload');
692
1351
  if (typeof startSession === 'function') {
693
1352
  const sessionId = await startSession();
694
1353
  actions.setActiveSession(sessionId);
@@ -698,7 +1357,7 @@ window.__ModuleLoader__.load({
698
1357
  console.error('[dc-agent] open failed:', error);
699
1358
  }
700
1359
  },
701
- children: wide ? '🧹 数据清洗' : '🧹',
1360
+ children: wide ? '🧹 数据清洗补全' : '🧹',
702
1361
  });
703
1362
  if (!topMount || typeof reactDom.createPortal !== 'function') return launcher;
704
1363
  return reactDom.createPortal(h('div', {
@@ -707,10 +1366,10 @@ window.__ModuleLoader__.load({
707
1366
  }, launcher), topMount);
708
1367
  }
709
1368
 
710
- /** Mockup 五能力入口:位于原生 composer 的交互工具行。 */
1369
+ /** Mockup 五能力入口:由 input.dock 承载,并在 CSS 中排到原生 composer 下方。 */
711
1370
  function CapabilityBar(props) {
712
1371
  const { sessionId } = props;
713
- return h('div', { className: 'dcAgentCapabilities', 'aria-label': '数据清洗智能体能力' },
1372
+ return h('div', { className: 'dcAgentCapabilities', 'aria-label': '数据清洗补全智能体能力' },
714
1373
  CAPABILITIES.map((item) => h('button', {
715
1374
  key: item.key,
716
1375
  type: 'button',
@@ -725,20 +1384,395 @@ window.__ModuleLoader__.load({
725
1384
  );
726
1385
  }
727
1386
 
1387
+ const WORKFLOW_STATE_LABELS = {
1388
+ draft: '待上传', uploaded: '待确认规则', rules_confirmed: '规则已确认', diagnosed: '体检完成',
1389
+ matching: '匹配中', review_required: '待人工核验', matched: '匹配完成', enriching: '补全中',
1390
+ export_ready: '可下载', completed: '已完成', partial: '部分完成', failed: '失败',
1391
+ authorization_required: '待连接企查查', cancelled: '已取消', parse_failed: '解析失败',
1392
+ };
1393
+
1394
+ function ProductHome(props = {}) {
1395
+ const { sessionId } = props;
1396
+ const [recentTasks, setRecentTasks] = react.useState([]);
1397
+ const flow = [
1398
+ ['01', '任务设置', '提示词向导明确目标'],
1399
+ ['02', '上传数据', '文本、Excel、JSON 或图片'],
1400
+ ['03', '规则确认', '字段映射与匹配主键'],
1401
+ ['04', '质量体检', '缺失、重复与格式诊断'],
1402
+ ['05', '数据匹配', '精确优先,多候选复核'],
1403
+ ['06', '清洗补全', '按需选择当前企业字段'],
1404
+ ['07', '下载数据', '保留原值、依据与状态'],
1405
+ ];
1406
+ react.useEffect(() => {
1407
+ let disposed = false;
1408
+ requestJson('/data-cleaning/api/workflow/tasks').then((response) => {
1409
+ if (!disposed) setRecentTasks((response.tasks || []).slice(0, 3));
1410
+ }).catch(() => {});
1411
+ return () => { disposed = true; };
1412
+ }, [sessionId]);
1413
+ return h('section', { className: 'dcAgentProductHome', 'aria-label': '数据清洗补全产品介绍' },
1414
+ h('div', { className: 'dcAgentHomeHero' },
1415
+ h('div', null,
1416
+ h('span', { className: 'dcAgentHomeEyebrow' }, '企业主数据治理工作台'),
1417
+ h('h2', null, '把企业名单变成可核验、可回写的标准数据'),
1418
+ h('p', null, '面向销售线索、客户尽调、供应商管理与 CRM / ERP 数据治理,先确认任务和规则,再按需使用当前用户连接的企查查 MCP。'),
1419
+ ),
1420
+ h('button', { type: 'button', className: 'dcAgentButton is-primary', onClick: () => requestWorkbenchOpen('upload', sessionId) }, '开始新任务'),
1421
+ ),
1422
+ h('div', { className: 'dcAgentHomeFlow', 'aria-label': '数据清洗补全工作流' },
1423
+ flow.map(([index, title, hint]) => h('div', { className: 'dcAgentHomeStep', key: index },
1424
+ h('b', null, index),
1425
+ h('span', null, title),
1426
+ h('small', { style: { display: 'block', opacity: .72 } }, hint),
1427
+ )),
1428
+ ),
1429
+ h('div', { className: 'dcAgentHomeTrust' },
1430
+ h('span', null, '本地解析与确定性清洗'),
1431
+ h('span', null, '匹配候选人工确认'),
1432
+ h('span', null, '客户自带企查查连接与额度'),
1433
+ h('span', null, '结果与待复核清单可导出'),
1434
+ ),
1435
+ h('div', { className: 'dcAgentHomeActions' },
1436
+ h('button', { type: 'button', onClick: () => requestWorkbenchOpen('profile', sessionId) }, '🩺 查看质量体检'),
1437
+ h('button', { type: 'button', onClick: () => requestWorkbenchOpen('match', sessionId) }, '🔎 进入匹配核验'),
1438
+ h('button', { type: 'button', onClick: () => requestWorkbenchOpen('history', sessionId) }, '◷ 查看任务历史'),
1439
+ ),
1440
+ recentTasks.length ? h('div', { className: 'dcAgentHomeRecent', 'aria-label': '最近任务' },
1441
+ h('b', null, '最近任务'),
1442
+ recentTasks.map((task) => h('button', {
1443
+ key: task.id,
1444
+ type: 'button',
1445
+ onClick: () => requestWorkbenchOpen(task.stage || 'upload', sessionId, task),
1446
+ },
1447
+ h('span', null, task.title || '未命名任务'),
1448
+ h('small', null, WORKFLOW_STATE_LABELS[task.state] || task.state),
1449
+ )),
1450
+ ) : null,
1451
+ );
1452
+ }
1453
+
1454
+ /**
1455
+ * DSH 当前只公开 hero brand.mark,没有 headline slot。这里仅在本插件会话且 blank hero
1456
+ * 阶段精确替换原生中英文标题,并在卸载时恢复,避免污染普通会话。
1457
+ */
1458
+ function rewriteHeroChrome(sessionId, enabled) {
1459
+ if (!enabled || typeof document === 'undefined' || typeof document.querySelectorAll !== 'function') return () => {};
1460
+ const marker = [...document.querySelectorAll('.dcAgentExperience')]
1461
+ .find((element) => element?.dataset?.sessionId === sessionId);
1462
+ const hero = marker?.closest?.('[data-phase="hero"]');
1463
+ 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
+ }
1479
+ return () => {
1480
+ if (title?.dataset?.dcAgentOriginalText !== undefined) {
1481
+ title.textContent = title.dataset.dcAgentOriginalText;
1482
+ delete title.dataset.dcAgentOriginalText;
1483
+ delete title.dataset.dcAgentHeroTitle;
1484
+ }
1485
+ if (badge?.dataset?.dcAgentOriginalDisplay !== undefined) {
1486
+ if (badge.style) badge.style.display = badge.dataset.dcAgentOriginalDisplay;
1487
+ delete badge.dataset.dcAgentOriginalDisplay;
1488
+ delete badge.dataset.dcAgentHeroBadge;
1489
+ }
1490
+ };
1491
+ }
1492
+
1493
+ function DataCleaningExperience(props) {
1494
+ const { sessionId, session: ownerSession } = props;
1495
+ const enabled = useCleaningSession(sessionId);
1496
+ const hero = ownerSession?.composerPhase === 'blank';
1497
+ react.useEffect(
1498
+ () => rewriteHeroChrome(sessionId, enabled && hero),
1499
+ [sessionId, enabled, hero, ownerSession?.openState]
1500
+ );
1501
+ if (!enabled) return null;
1502
+ return h('div', {
1503
+ className: `dcAgentExperience${hero ? ' is-home' : ''}`,
1504
+ 'data-session-id': sessionId,
1505
+ },
1506
+ h(CapabilityBar, { sessionId }),
1507
+ hero ? h(ProductHome, { sessionId }) : null,
1508
+ );
1509
+ }
1510
+
1511
+ function extractPromptEntries(result) {
1512
+ const rows = Array.isArray(result?.rows) ? result.rows : [];
1513
+ const headers = Array.isArray(result?.headers) && result.headers.length
1514
+ ? result.headers
1515
+ : rows.length ? Object.keys(rows[0]) : [];
1516
+ const nameField = headers.find((name) => /^(name|company|company_name|企业名称|公司名称)$/i.test(name))
1517
+ ?? headers.find((name) => /企业|公司|名称|name/i.test(name));
1518
+ const creditField = headers.find((name) => /^(credit_no|creditCode|unified_credit_code|统一社会信用代码|信用代码)$/i.test(name))
1519
+ ?? headers.find((name) => /信用.*代码|credit/i.test(name));
1520
+ return rows.map((row) => {
1521
+ const name = nameField ? String(row?.[nameField] ?? '').trim() : '';
1522
+ const credit = creditField ? String(row?.[creditField] ?? '').trim() : '';
1523
+ return [name, credit].filter(Boolean).join(' | ');
1524
+ }).filter(Boolean).slice(0, 50);
1525
+ }
1526
+
1527
+ function PromptGenerator(props) {
1528
+ const { sessionId, inputActions, attachImages } = props;
1529
+ const enabled = useCleaningSession(sessionId);
1530
+ const [open, setOpen] = react.useState(false);
1531
+ const [wizardStep, setWizardStep] = react.useState(1);
1532
+ const [mode, setMode] = react.useState('text');
1533
+ const [rawText, setRawText] = react.useState('');
1534
+ const [entries, setEntries] = react.useState([]);
1535
+ const [entryCount, setEntryCount] = react.useState(0);
1536
+ const [spreadsheetFileName, setSpreadsheetFileName] = react.useState('');
1537
+ const [imageFileName, setImageFileName] = react.useState('');
1538
+ const [imageAttached, setImageAttached] = react.useState(false);
1539
+ const [cleaningKeys, setCleaningKeys] = react.useState(DEFAULT_CLEANING_KEYS);
1540
+ const [enrichmentKeys, setEnrichmentKeys] = react.useState(DEFAULT_ENRICHMENT_KEYS);
1541
+ const [anchorKeys, setAnchorKeys] = react.useState(['company_name', 'credit_no']);
1542
+ const [matchRules, setPromptMatchRules] = react.useState({
1543
+ normalizeNames: true,
1544
+ preferCreditNo: true,
1545
+ deduplicate: true,
1546
+ manualReviewAmbiguous: true,
1547
+ });
1548
+ const [busy, setBusy] = react.useState(false);
1549
+ const [error, setError] = react.useState(null);
1550
+
1551
+ if (!enabled) return null;
1552
+
1553
+ const setSourceMode = (nextMode) => {
1554
+ setMode(nextMode);
1555
+ setError(null);
1556
+ };
1557
+ const toggle = (setter, values, key) => setter(values.includes(key)
1558
+ ? values.filter((item) => item !== key)
1559
+ : [...values, key]);
1560
+ const handleFile = async (event) => {
1561
+ const file = event.target?.files?.[0];
1562
+ if (!file || busy) return;
1563
+ setBusy(true);
1564
+ setError(null);
1565
+ try {
1566
+ if (mode === 'image') {
1567
+ if (typeof attachImages !== 'function') throw new Error('当前 DSH 没有可用的图片接入 Bridge。');
1568
+ await attachImages(sessionId, [file]);
1569
+ setImageFileName(file.name);
1570
+ setImageAttached(true);
1571
+ } else {
1572
+ const result = await parseFile(file);
1573
+ if (!result || result.ok === false) throw new Error(result?.message || result?.error || '表格解析失败');
1574
+ const extracted = extractPromptEntries(result);
1575
+ if (!extracted.length) throw new Error('未识别到企业名称或统一社会信用代码列,请检查表头。');
1576
+ setEntries(extracted);
1577
+ setEntryCount(Number.isFinite(result.rowCount) ? result.rowCount : (Array.isArray(result.rows) ? result.rows.length : extracted.length));
1578
+ setSpreadsheetFileName(file.name);
1579
+ requestWorkbenchDataset(result, sessionId, {
1580
+ type: result.fmt || 'xlsx', fileName: file.name, sizeBytes: file.size || 0,
1581
+ });
1582
+ }
1583
+ } catch (fileError) {
1584
+ setError(fileError instanceof Error ? fileError.message : String(fileError));
1585
+ } finally {
1586
+ setBusy(false);
1587
+ if (event.target) event.target.value = '';
1588
+ }
1589
+ };
1590
+ const selectedEntries = () => mode === 'text'
1591
+ ? rawText.split(/\r?\n/).map((line) => line.trim()).filter(Boolean)
1592
+ : entries;
1593
+ const validateSource = () => {
1594
+ const values = selectedEntries();
1595
+ if (mode === 'text' && !values.length) return '请先录入企业名称或统一社会信用代码,每行一条。';
1596
+ if (mode === 'excel' && !values.length) return '请先上传并解析 Excel / CSV / JSON 文件。';
1597
+ if (mode === 'image' && !imageAttached) return '请先选择并附加一张包含企业名单的图片。';
1598
+ return null;
1599
+ };
1600
+ const promptConfig = () => ({
1601
+ mode,
1602
+ entries: selectedEntries(),
1603
+ entryCount: mode === 'excel' ? entryCount : selectedEntries().length,
1604
+ fileName: mode === 'image' ? imageFileName : spreadsheetFileName,
1605
+ cleaningKeys,
1606
+ enrichmentKeys,
1607
+ anchorKeys,
1608
+ matchRules,
1609
+ });
1610
+ const generatePrompt = () => {
1611
+ const sourceError = validateSource();
1612
+ if (sourceError) { setError(sourceError); return; }
1613
+ if (!anchorKeys.length) { setError('请至少选择一个企业主体匹配主键。'); return; }
1614
+ if (!cleaningKeys.length) { setError('请至少选择一个清洗或补全目标。'); return; }
1615
+ if (!inputActions || typeof inputActions.setDraft !== 'function') {
1616
+ setError('当前会话输入机尚未就绪,请稍后重试。');
1617
+ return;
1618
+ }
1619
+ const config = promptConfig();
1620
+ inputActions.setDraft(buildTaskPrompt(config));
1621
+ if (mode === 'text') {
1622
+ requestWorkbenchDataset(entriesToDataset(config.entries), sessionId, { type: 'text', fileName: '' });
1623
+ }
1624
+ requestWorkbenchDraft({
1625
+ title: `企业数据清洗补全任务 · ${config.entryCount || '待解析'} 条`,
1626
+ objectives: cleaningKeys,
1627
+ fieldSelection: enrichmentKeys,
1628
+ matchRules,
1629
+ source: { type: mode === 'excel' ? 'xlsx' : mode, fileName: config.fileName || '', rowCount: config.entryCount || 0 },
1630
+ }, sessionId);
1631
+ setError(null);
1632
+ setOpen(false);
1633
+ setWizardStep(1);
1634
+ };
1635
+
1636
+ const nextWizard = () => {
1637
+ if (wizardStep === 1) {
1638
+ const sourceError = validateSource();
1639
+ if (sourceError) { setError(sourceError); return; }
1640
+ }
1641
+ if (wizardStep === 2 && !anchorKeys.length) { setError('请至少选择一个匹配主键。'); return; }
1642
+ if (wizardStep === 3 && !cleaningKeys.length) { setError('请至少选择一个清洗或补全目标。'); return; }
1643
+ if (wizardStep < 4) { setError(null); setWizardStep(wizardStep + 1); return; }
1644
+ generatePrompt();
1645
+ };
1646
+
1647
+ const choice = (options, values, setter) => options.map(([key, label]) => h('label', {
1648
+ key,
1649
+ className: `dcAgentPromptChoice${values.includes(key) ? ' is-selected' : ''}`,
1650
+ },
1651
+ h('input', {
1652
+ type: 'checkbox',
1653
+ checked: values.includes(key),
1654
+ onChange: () => toggle(setter, values, key),
1655
+ }),
1656
+ label,
1657
+ ));
1658
+
1659
+ return h('div', { className: 'dcAgentPromptLayer' },
1660
+ h('button', {
1661
+ type: 'button',
1662
+ className: 'dcAgentPromptTrigger',
1663
+ 'aria-label': '打开提示词生成',
1664
+ 'aria-expanded': open,
1665
+ onClick: () => setOpen(!open),
1666
+ }, '✨ 提示词生成'),
1667
+ open ? h('section', {
1668
+ className: 'dcAgentPromptPanel',
1669
+ role: 'dialog',
1670
+ 'aria-modal': 'false',
1671
+ 'aria-label': '数据清洗补全任务生成器',
1672
+ onKeyDown: (event) => {
1673
+ if (event.key === 'Escape') {
1674
+ event.preventDefault();
1675
+ setOpen(false);
1676
+ }
1677
+ },
1678
+ },
1679
+ h('div', { className: 'dcAgentPromptHead' },
1680
+ h('div', null,
1681
+ h('h3', null, '生成数据清洗补全任务'),
1682
+ h('p', null, '录入主体、选择清洗动作与补全维度,生成后仍可在对话框中人工修改。'),
1683
+ ),
1684
+ h('button', { type: 'button', className: 'dcAgentPromptClose', 'aria-label': '关闭提示词生成', onClick: () => setOpen(false) }, '✕'),
1685
+ ),
1686
+ h('nav', { className: 'dcAgentWizardNav', 'aria-label': '任务设置步骤' },
1687
+ [['1', '数据来源'], ['2', '匹配规则'], ['3', '清洗与补全'], ['4', '确认描述']].map(([index, label]) => h('button', {
1688
+ key: index,
1689
+ type: 'button',
1690
+ className: `dcAgentWizardStep${wizardStep === Number(index) ? ' is-active' : ''}`,
1691
+ 'aria-current': wizardStep === Number(index) ? 'step' : undefined,
1692
+ onClick: () => setWizardStep(Number(index)),
1693
+ }, h('b', null, index), h('span', null, label))),
1694
+ ),
1695
+ wizardStep === 1 ? h('div', { className: 'dcAgentWizardPane' },
1696
+ h('h4', null, '先告诉我数据从哪里来'),
1697
+ h('p', null, '企业名称、统一社会信用代码、注册号任一项都可作为匹配起点。'),
1698
+ h('div', { className: 'dcAgentPromptTabs', role: 'tablist', 'aria-label': '名单录入方式' },
1699
+ [['text', '粘贴企业名单'], ['excel', '上传本地文件'], ['image', '上传图片识别']].map(([key, label]) => h('button', {
1700
+ key, type: 'button', role: 'tab', 'aria-selected': mode === key,
1701
+ className: `dcAgentPromptTab${mode === key ? ' is-active' : ''}`,
1702
+ onClick: () => setSourceMode(key),
1703
+ }, label)),
1704
+ ),
1705
+ mode === 'text' ? h('label', { className: 'dcAgentPromptField' },
1706
+ h('span', null, '企业名称或统一社会信用代码(每行一条)'),
1707
+ h('textarea', {
1708
+ className: 'dcAgentPromptText', value: rawText,
1709
+ placeholder: '企查查科技股份有限公司\n9132…\n某某信息技术(上海)有限公司',
1710
+ onChange: (event) => setRawText(event.target.value),
1711
+ }),
1712
+ ) : h('label', { className: 'dcAgentPromptFile' },
1713
+ h('span', null, mode === 'image' ? '选择包含企业名单的图片' : '选择 CSV / XLSX / JSON 名单'),
1714
+ h('input', {
1715
+ type: 'file', accept: mode === 'image' ? 'image/png,image/jpeg,image/webp' : '.xlsx,.xls,.csv,.json',
1716
+ disabled: busy, onChange: handleFile,
1717
+ }),
1718
+ (mode === 'excel' ? spreadsheetFileName : imageFileName)
1719
+ ? h('b', null, `${mode === 'excel' ? spreadsheetFileName : imageFileName}${mode === 'excel' ? ` · ${entryCount} 条` : ' · 已附加'}`)
1720
+ : null,
1721
+ ),
1722
+ ) : null,
1723
+ wizardStep === 2 ? h('div', { className: 'dcAgentWizardPane' },
1724
+ h('h4', null, '确认匹配规则'),
1725
+ h('p', null, '强标识优先;名称定位不明确时生成候选,不自动写回。'),
1726
+ h('div', { className: 'dcAgentPromptChoices' }, choice(MATCH_ANCHOR_OPTIONS, anchorKeys, setAnchorKeys)),
1727
+ h('div', { className: 'dcAgentPromptRules' },
1728
+ [['normalizeNames', '匹配前统一企业名称格式'], ['preferCreditNo', '有信用代码时优先精确匹配'], ['deduplicate', '匹配前合并重复主体'], ['manualReviewAmbiguous', '多候选必须人工确认']].map(([key, label]) => h('label', { key },
1729
+ h('input', { type: 'checkbox', checked: matchRules[key], onChange: (event) => setPromptMatchRules({ ...matchRules, [key]: event.target.checked }) }), label,
1730
+ )),
1731
+ ),
1732
+ ) : null,
1733
+ wizardStep === 3 ? h('div', { className: 'dcAgentWizardPane' },
1734
+ h('h4', null, '选择清洗目标和补全字段'),
1735
+ h('p', null, '仅展示当前企业数据范围;最终可用字段以当前用户连接能力为准。'),
1736
+ h('div', { className: 'dcAgentPromptGroup' },
1737
+ h('b', null, '清洗目标'),
1738
+ h('div', { className: 'dcAgentPromptChoices' }, choice(CLEANING_OPTIONS, cleaningKeys, setCleaningKeys)),
1739
+ ),
1740
+ FIELD_GROUPS.map(([groupId, label, fields]) => h('div', { className: 'dcAgentPromptGroup', key: groupId },
1741
+ h('b', null, label),
1742
+ h('div', { className: 'dcAgentPromptChoices' }, choice(fields, enrichmentKeys, setEnrichmentKeys)),
1743
+ )),
1744
+ ) : null,
1745
+ wizardStep === 4 ? h('div', { className: 'dcAgentWizardPane' },
1746
+ h('h4', null, '确认任务描述'),
1747
+ h('p', null, '回填后仍可在对话框继续编辑;回填本身不会调用企查查 MCP。'),
1748
+ h('pre', { className: 'dcAgentPromptPreview' }, buildTaskPrompt(promptConfig())),
1749
+ ) : null,
1750
+ h('p', { className: 'dcAgentPromptNote' }, '向导只生成任务描述并保存任务设置。只有你在任务中明确确认后,才会使用当前客户自己的企查查 MCP 连接、账号和额度。'),
1751
+ error ? h('p', { className: 'dcAgentPromptError', role: 'alert' }, error) : null,
1752
+ h('div', { className: 'dcAgentPromptActions' },
1753
+ h('button', { type: 'button', className: 'dcAgentPromptAction', disabled: wizardStep === 1, onClick: () => setWizardStep(Math.max(1, wizardStep - 1)) }, '上一步'),
1754
+ h('button', { type: 'button', className: 'dcAgentPromptAction is-primary', disabled: busy, onClick: nextWizard }, busy ? '处理中…' : wizardStep === 4 ? '回填到对话框' : '下一步'),
1755
+ ),
1756
+ ) : null,
1757
+ );
1758
+ }
1759
+
728
1760
  /** 会话头部的可恢复入口:关闭右栏后仍可从当前会话再次打开。 */
729
1761
  function WorkbenchHeaderEntry(props) {
730
1762
  const { sessionId } = props;
1763
+ const enabled = useCleaningSession(sessionId);
1764
+ if (!enabled) return null;
731
1765
  return h('button', {
732
1766
  type: 'button',
733
1767
  className: 'dcAgentHeaderAction',
734
- 'aria-label': '打开数据清洗工作台',
735
- title: '打开数据清洗工作台',
1768
+ 'aria-label': '打开数据清洗补全工作台',
1769
+ title: '打开数据清洗补全工作台',
736
1770
  onClick: () => requestWorkbenchOpen(null, sessionId),
737
1771
  },
738
1772
  typeof IconDataOutline16 === 'function'
739
1773
  ? h(IconDataOutline16, { size: 16, 'aria-hidden': 'true' })
740
1774
  : h('span', { 'aria-hidden': 'true' }, '▦'),
741
- h('span', null, '清洗工作台'),
1775
+ h('span', null, '清洗补全工作台'),
742
1776
  );
743
1777
  }
744
1778
 
@@ -803,6 +1837,15 @@ window.__ModuleLoader__.load({
803
1837
  // 静默:jobs 不可用不影响工作台主流程。
804
1838
  }
805
1839
  }
1840
+ async function loadWorkflowTasks(actions) {
1841
+ try {
1842
+ const response = await requestJson('/data-cleaning/api/workflow/tasks');
1843
+ actions.setWorkflowTasks(response.tasks || []);
1844
+ return response.tasks || [];
1845
+ } catch (_error) {
1846
+ return [];
1847
+ }
1848
+ }
806
1849
  function startJobsPolling(actions) {
807
1850
  if (jobsTimer !== null) return;
808
1851
  pollJobsOnce(actions);
@@ -839,14 +1882,9 @@ window.__ModuleLoader__.load({
839
1882
  if (trimmed.startsWith('[')) {
840
1883
  try {
841
1884
  const rows = JSON.parse(trimmed);
842
- session.rows = Array.isArray(rows) ? rows : [];
843
- session.headers = session.rows.length ? Object.keys(session.rows[0]) : [];
844
- actions.setDataset({
845
- fmt: 'json',
846
- headers: session.headers,
847
- rowCount: session.rows.length,
848
- preview: session.rows.slice(0, 5),
849
- });
1885
+ const list = Array.isArray(rows) ? rows : [];
1886
+ const headers = list.length ? Object.keys(list[0]) : [];
1887
+ return { ok: true, fmt: 'json', headers, rowCount: list.length, rows: list, preview: list.slice(0, 5) };
850
1888
  } catch (error) {
851
1889
  actions.setError(`JSON 解析失败:${error instanceof Error ? error.message : String(error)}`);
852
1890
  }
@@ -855,21 +1893,50 @@ window.__ModuleLoader__.load({
855
1893
  return api('/data-cleaning/api/mvp/parse', { filename: 'data.csv', content: trimmed });
856
1894
  }
857
1895
 
858
- /** 解析成功落库 + 前进到「数据体检」。 */
859
- function applyParsed(result, actions) {
860
- session.rows = Array.isArray(result.rows) ? result.rows : [];
861
- session.headers = Array.isArray(result.headers) ? result.headers : [];
862
- const guessedNameField = session.headers.find((name) => /^(name|company|company_name|企业名称|公司名称)$/i.test(name))
863
- ?? session.headers.find((name) => /企业|公司|名称|name/i.test(name))
864
- ?? session.headers[0]
1896
+ /** 解析成功进入 taskId runtime;不把整表写入 Host store。 */
1897
+ function applyParsed(result, actions, taskId = 'unassigned', source = {}) {
1898
+ const runtime = runtimeFor(taskId);
1899
+ runtime.rows = Array.isArray(result.rows) ? result.rows : [];
1900
+ runtime.headers = Array.isArray(result.headers) ? result.headers : [];
1901
+ runtime.source = {
1902
+ type: source.type || result.fmt || 'csv',
1903
+ fileName: source.fileName || '',
1904
+ sizeBytes: source.sizeBytes || 0,
1905
+ };
1906
+ const mappings = guessMappings(runtime.headers);
1907
+ const guessedNameField = mappings.find((mapping) => mapping.targetField === 'company_name')?.sourceField
1908
+ ?? mappings.find((mapping) => mapping.targetField === 'credit_no')?.sourceField
1909
+ ?? mappings.find((mapping) => mapping.targetField === 'reg_no')?.sourceField
1910
+ ?? runtime.headers[0]
865
1911
  ?? 'name';
866
1912
  actions.setNameField(guessedNameField);
1913
+ actions.setMappings(mappings);
867
1914
  actions.setDataset({
868
1915
  fmt: result.fmt ?? 'csv',
869
- headers: session.headers,
870
- rowCount: typeof result.rowCount === 'number' ? result.rowCount : session.rows.length,
871
- preview: Array.isArray(result.preview) ? result.preview : session.rows.slice(0, 5),
1916
+ headers: runtime.headers,
1917
+ rowCount: typeof result.rowCount === 'number' ? result.rowCount : runtime.rows.length,
1918
+ preview: Array.isArray(result.preview) ? result.preview : runtime.rows.slice(0, 5),
872
1919
  });
1920
+ return runtime;
1921
+ }
1922
+
1923
+ async function persistParsedWorkflow(actions, sessionId, result, source = {}, task) {
1924
+ let current = task ?? await ensureWorkflowTask(actions, sessionId);
1925
+ const stagingKey = `session:${sessionId || 'unassigned'}`;
1926
+ moveRuntime(stagingKey, current.id);
1927
+ if (['draft', 'uploaded', 'parse_failed'].includes(current.state)) {
1928
+ current = await workflowAction(actions, sessionId, current, 'upload', {
1929
+ source: {
1930
+ type: source.type || result.fmt || 'csv',
1931
+ fileName: source.fileName || '',
1932
+ rowCount: typeof result.rowCount === 'number' ? result.rowCount : (result.rows || []).length,
1933
+ columnCount: Array.isArray(result.headers) ? result.headers.length : 0,
1934
+ headers: Array.isArray(result.headers) ? result.headers : [],
1935
+ sizeBytes: source.sizeBytes || 0,
1936
+ },
1937
+ });
1938
+ }
1939
+ return current;
873
1940
  }
874
1941
 
875
1942
  /** 右侧非模态工作台:中央区域始终保留 DSH 原生会话。 */
@@ -881,6 +1948,14 @@ window.__ModuleLoader__.load({
881
1948
  const busy = useStore((state) => state.busy);
882
1949
  const error = useStore((state) => state.error);
883
1950
  const input = useStore((state) => state.input);
1951
+ const workflowContract = useStore((state) => state.workflowContract);
1952
+ const workflowTask = useStore((state) => state.workflowTask);
1953
+ const workflowTasks = useStore((state) => state.workflowTasks);
1954
+ const taskTitle = useStore((state) => state.taskTitle);
1955
+ const mappings = useStore((state) => state.mappings);
1956
+ const objectives = useStore((state) => state.objectives);
1957
+ const fieldSelection = useStore((state) => state.fieldSelection);
1958
+ const matchRules = useStore((state) => state.matchRules);
884
1959
  const dataset = useStore((state) => state.dataset);
885
1960
  const profile = useStore((state) => state.profile);
886
1961
  const clean = useStore((state) => state.clean);
@@ -899,20 +1974,75 @@ window.__ModuleLoader__.load({
899
1974
  const handleOpen = (event) => {
900
1975
  event?.preventDefault?.();
901
1976
  const detail = event?.detail ?? {};
1977
+ if (detail.task?.id) {
1978
+ workflowTaskBySession.set(String(detail.sessionId || 'unassigned'), detail.task);
1979
+ actions.setWorkflowTask(detail.task);
1980
+ actions.setTaskTitle(detail.task.title);
1981
+ actions.setMappings(detail.task.mappings || []);
1982
+ actions.setObjectives(detail.task.objectives || []);
1983
+ actions.setFieldSelection(detail.task.fieldSelection || []);
1984
+ for (const [key, value] of Object.entries(detail.task.matchRules || {})) actions.setMatchRule(key, value);
1985
+ }
902
1986
  openWorkbench(actions, detail.step ?? null, detail.sessionId ?? null);
903
1987
  };
1988
+ const handleDataset = (event) => {
1989
+ event?.preventDefault?.();
1990
+ const detail = event?.detail ?? {};
1991
+ if (detail.result) applyParsed(detail.result, actions, `session:${detail.sessionId || 'unassigned'}`, detail.source);
1992
+ if (detail.sessionId) actions.setActiveSession(detail.sessionId);
1993
+ if (detail.result) {
1994
+ void queueWorkflowOperation(detail.sessionId, () => (
1995
+ persistParsedWorkflow(actions, detail.sessionId, detail.result, detail.source)
1996
+ )).catch((eventError) => {
1997
+ actions.setError(eventError instanceof Error ? eventError.message : String(eventError));
1998
+ });
1999
+ }
2000
+ };
2001
+ const handleDraft = (event) => {
2002
+ event?.preventDefault?.();
2003
+ const detail = event?.detail ?? {};
2004
+ const draft = detail.draft ?? {};
2005
+ if (detail.sessionId) actions.setActiveSession(detail.sessionId);
2006
+ if (draft.title) actions.setTaskTitle(draft.title);
2007
+ if (draft.objectives) actions.setObjectives(draft.objectives);
2008
+ if (draft.fieldSelection) actions.setFieldSelection(draft.fieldSelection);
2009
+ for (const [key, value] of Object.entries(draft.matchRules || {})) actions.setMatchRule(key, value);
2010
+ void queueWorkflowOperation(detail.sessionId, async () => {
2011
+ const task = await ensureWorkflowTask(actions, detail.sessionId, draft);
2012
+ return updateWorkflowTask(actions, detail.sessionId, task, draft);
2013
+ }).catch((eventError) => actions.setError(eventError instanceof Error ? eventError.message : String(eventError)));
2014
+ };
904
2015
  if (typeof document !== 'undefined' && typeof document.addEventListener === 'function') {
905
2016
  document.addEventListener(WORKBENCH_OPEN_EVENT, handleOpen);
2017
+ document.addEventListener(WORKBENCH_DATASET_EVENT, handleDataset);
2018
+ document.addEventListener(WORKBENCH_DRAFT_EVENT, handleDraft);
906
2019
  }
907
2020
  return () => {
908
2021
  if (typeof document !== 'undefined' && typeof document.removeEventListener === 'function') {
909
2022
  document.removeEventListener(WORKBENCH_OPEN_EVENT, handleOpen);
2023
+ document.removeEventListener(WORKBENCH_DATASET_EVENT, handleDataset);
2024
+ document.removeEventListener(WORKBENCH_DRAFT_EVENT, handleDraft);
910
2025
  }
911
2026
  stopJobsPolling();
912
2027
  if (rootWorkbenchActions === actions) rootWorkbenchActions = null;
913
2028
  };
914
2029
  }, [actions]);
915
2030
 
2031
+ react.useEffect(() => {
2032
+ if (!open) return undefined;
2033
+ let disposed = false;
2034
+ requestJson('/data-cleaning/api/workflow/contract').then((response) => {
2035
+ if (!disposed) actions.setWorkflowContract(response.contract);
2036
+ }).catch(() => {});
2037
+ loadWorkflowTasks(actions);
2038
+ if (activeSessionId && !workflowTaskBySession.has(String(activeSessionId))) {
2039
+ queueWorkflowOperation(activeSessionId, () => ensureWorkflowTask(actions, activeSessionId)).catch((taskError) => {
2040
+ if (!disposed) actions.setError(taskError instanceof Error ? taskError.message : String(taskError));
2041
+ });
2042
+ }
2043
+ return () => { disposed = true; };
2044
+ }, [open, activeSessionId, actions]);
2045
+
916
2046
  // 非模态右栏会覆盖 Host 的中央画布;按实际重叠量把 composer 能力栏左移,
917
2047
  // 保证 Mockup 中的五个入口在窄桌面视口仍可见、可点击。展开态专注工作台,
918
2048
  // 不强行挤压到不足一栏的剩余区域。
@@ -958,7 +2088,11 @@ window.__ModuleLoader__.load({
958
2088
  if (!open) return null;
959
2089
 
960
2090
  const hasData = dataset !== null && dataset.rowCount > 0;
961
- const fieldByPattern = (pattern) => session.headers.find((field) => pattern.test(field)) ?? null;
2091
+ const cachedTask = workflowTaskBySession.get(String(activeSessionId || 'unassigned')) ?? workflowTask;
2092
+ const runtimeKey = cachedTask?.id ?? `session:${activeSessionId || 'unassigned'}`;
2093
+ const runtime = runtimeFor(runtimeKey);
2094
+ const lastCsv = runtime.lastCsv;
2095
+ const fieldByPattern = (pattern) => runtime.headers.find((field) => pattern.test(field)) ?? null;
962
2096
  const phoneField = fieldByPattern(/^(phone|mobile|tel|telephone|联系电话|手机号码|手机号)$/i);
963
2097
  const amountField = fieldByPattern(/^(amount|price|金额|注册资本)$/i);
964
2098
  const localCleanOptions = {
@@ -980,7 +2114,10 @@ window.__ModuleLoader__.load({
980
2114
  actions.setError(null);
981
2115
  try {
982
2116
  const result = await parseText(input, actions);
983
- if (result) applyParsed(result, actions);
2117
+ if (result) {
2118
+ applyParsed(result, actions, runtimeKey, { type: result.fmt || 'csv' });
2119
+ await persistParsedWorkflow(actions, activeSessionId, result, { type: result.fmt || 'csv' }, cachedTask);
2120
+ }
984
2121
  } catch (err) {
985
2122
  actions.setError(err instanceof Error ? err.message : String(err));
986
2123
  } finally {
@@ -995,7 +2132,10 @@ window.__ModuleLoader__.load({
995
2132
  actions.setError(null);
996
2133
  try {
997
2134
  const result = await parseFile(file);
998
- if (result && result.ok !== false) applyParsed(result, actions);
2135
+ if (result && result.ok !== false) {
2136
+ applyParsed(result, actions, runtimeKey, { type: result.fmt || 'csv', fileName: file.name, sizeBytes: file.size || 0 });
2137
+ await persistParsedWorkflow(actions, activeSessionId, result, { type: result.fmt || 'csv', fileName: file.name, sizeBytes: file.size || 0 }, cachedTask);
2138
+ }
999
2139
  else actions.setError((result && (result.message || result.error)) || '解析失败');
1000
2140
  } catch (err) {
1001
2141
  actions.setError(err instanceof Error ? err.message : String(err));
@@ -1004,25 +2144,28 @@ window.__ModuleLoader__.load({
1004
2144
  }
1005
2145
  };
1006
2146
 
2147
+ const performProfile = async (task) => {
2148
+ const r = await api('/data-cleaning/api/mvp/profile', {
2149
+ rows: runtime.rows,
2150
+ headers: runtime.headers,
2151
+ options: { amountField },
2152
+ });
2153
+ if (!r || r.ok === false) throw new Error((r && (r.message || r.error)) || '体检失败');
2154
+ const quality = qualitySummaryFor(runtime.rows, mappings);
2155
+ actions.setProfile({ ...(r.summary ?? r), workflowSummary: quality });
2156
+ return workflowAction(actions, activeSessionId, task, 'quality', { summary: quality });
2157
+ };
2158
+
1007
2159
  const runProfile = async () => {
1008
- if (busy || !session.rows.length) {
1009
- if (!session.rows.length) actions.setError('请先上传并解析数据。');
2160
+ if (busy || !runtime.rows.length) {
2161
+ if (!runtime.rows.length) actions.setError('请先上传并解析数据。');
1010
2162
  return;
1011
2163
  }
1012
2164
  actions.setBusy(true);
1013
2165
  actions.setError(null);
1014
2166
  try {
1015
- const r = await api('/data-cleaning/api/mvp/profile', {
1016
- rows: session.rows,
1017
- headers: session.headers,
1018
- options: { amountField },
1019
- });
1020
- if (r && r.ok !== false) {
1021
- actions.setProfile(r.summary ?? r);
1022
- actions.setStep('review');
1023
- } else {
1024
- actions.setError((r && (r.message || r.error)) || '体检失败');
1025
- }
2167
+ await performProfile(cachedTask);
2168
+ actions.setStep('match');
1026
2169
  } catch (err) {
1027
2170
  actions.setError(err instanceof Error ? err.message : String(err));
1028
2171
  } finally {
@@ -1031,21 +2174,23 @@ window.__ModuleLoader__.load({
1031
2174
  };
1032
2175
 
1033
2176
  const runClean = async () => {
1034
- if (busy || !session.rows.length) {
1035
- if (!session.rows.length) actions.setError('请先上传并解析数据。');
2177
+ if (busy || !runtime.rows.length) {
2178
+ if (!runtime.rows.length) actions.setError('请先上传并解析数据。');
1036
2179
  return;
1037
2180
  }
1038
2181
  actions.setBusy(true);
1039
2182
  actions.setError(null);
1040
2183
  try {
1041
2184
  const r = await api('/data-cleaning/api/mvp/clean', {
1042
- rows: session.rows,
1043
- headers: session.headers,
2185
+ rows: runtime.rows,
2186
+ headers: runtime.headers,
1044
2187
  options: localCleanOptions,
1045
2188
  });
1046
2189
  if (r && r.ok !== false) {
1047
2190
  actions.setClean(r.summary ?? r);
1048
2191
  lastCsv.clean = { csv: r.csv ?? '', name: r.downloadName ?? 'cleaned.csv' };
2192
+ runtime.resultRows.clean = Array.isArray(r.rows) ? r.rows : null;
2193
+ if (Array.isArray(r.headers) && r.headers.length) runtime.headers = r.headers;
1049
2194
  } else {
1050
2195
  actions.setError((r && (r.message || r.error)) || '清洗失败');
1051
2196
  }
@@ -1057,21 +2202,23 @@ window.__ModuleLoader__.load({
1057
2202
  };
1058
2203
 
1059
2204
  const runComplete = async () => {
1060
- if (busy || !session.rows.length) {
1061
- if (!session.rows.length) actions.setError('请先上传并解析数据。');
2205
+ if (busy || !runtime.rows.length) {
2206
+ if (!runtime.rows.length) actions.setError('请先上传并解析数据。');
1062
2207
  return;
1063
2208
  }
1064
2209
  actions.setBusy(true);
1065
2210
  actions.setError(null);
1066
2211
  try {
1067
2212
  const r = await api('/data-cleaning/api/mvp/complete', {
1068
- rows: session.rows,
1069
- headers: session.headers,
2213
+ rows: runtime.rows,
2214
+ headers: runtime.headers,
1070
2215
  options: localCompleteOptions,
1071
2216
  });
1072
2217
  if (r && r.ok !== false) {
1073
2218
  actions.setComplete(r.summary ?? r);
1074
2219
  lastCsv.complete = { csv: r.csv ?? '', name: r.downloadName ?? 'completed.csv' };
2220
+ runtime.resultRows.complete = Array.isArray(r.rows) ? r.rows : null;
2221
+ if (Array.isArray(r.headers) && r.headers.length) runtime.headers = r.headers;
1075
2222
  } else {
1076
2223
  actions.setError((r && (r.message || r.error)) || '补全失败');
1077
2224
  }
@@ -1087,7 +2234,7 @@ window.__ModuleLoader__.load({
1087
2234
  actions.setBusy(true);
1088
2235
  actions.setError(null);
1089
2236
  try {
1090
- const r = await api('/data-cleaning/api/phase3/capabilities');
2237
+ const r = await api('/data-cleaning/api/g5/capabilities');
1091
2238
  if (r && r.ok !== false) actions.setQccCapabilities(r);
1092
2239
  else actions.setError((r && (r.message || r.error)) || '企查查能力检测失败');
1093
2240
  } catch (err) {
@@ -1098,31 +2245,61 @@ window.__ModuleLoader__.load({
1098
2245
  };
1099
2246
 
1100
2247
  const estimateQcc = async () => {
1101
- if (busy || !selectedDomains.length) {
1102
- if (!selectedDomains.length) actions.setError('请至少选择一个补全域。');
1103
- return;
1104
- }
1105
- actions.setBusy(true);
1106
- actions.setError(null);
1107
- try {
1108
- const r = await api('/data-cleaning/api/phase3/estimate', {
1109
- rows: session.rows, nameField, domains: selectedDomains, maxCalls: 500,
1110
- });
1111
- if (r && r.ok !== false) actions.setQccEstimate(r.estimate);
1112
- else actions.setError((r && (r.message || r.error)) || '调用估算失败');
1113
- } catch (err) {
1114
- actions.setError(err instanceof Error ? err.message : String(err));
1115
- } finally {
1116
- actions.setBusy(false);
1117
- }
2248
+ const uniqueCompanies = new Set(runtime.rows.map((row) => String(row?.[nameField] ?? '').trim()).filter(Boolean)).size;
2249
+ actions.setQccEstimate({
2250
+ uniqueCompanies,
2251
+ tools: ['主体查询', '工商信息'],
2252
+ estimatedCalls: uniqueCompanies * 2,
2253
+ maxCalls: 200,
2254
+ withinLimit: runtime.rows.length <= 100,
2255
+ estimateType: 'upper-bound',
2256
+ });
1118
2257
  };
1119
2258
 
1120
- const applyQccRun = (run) => {
2259
+ const applyQccRun = async (run) => {
1121
2260
  actions.setQccRun(run);
1122
- session.rows = Array.isArray(run.rows) ? run.rows : session.rows;
1123
- lastCsv.qcc = { csv: run.csv ?? '', name: run.downloadName ?? 'qcc-phase3-enriched.csv' };
1124
- lastCsv.review = { csv: run.reviewCsv ?? '', name: run.reviewDownloadName ?? 'qcc-phase3-review.csv' };
1125
- if (!Array.isArray(run.reviewQueue) || run.reviewQueue.length === 0) actions.setStep('enrich');
2261
+ runtime.rows = Array.isArray(run.rows) ? run.rows : runtime.rows;
2262
+ runtime.resultRows.qcc = Array.isArray(run.rows) ? run.rows : null;
2263
+ lastCsv.qcc = { csv: run.csv ?? '', name: run.downloadName ?? 'qcc-enriched.csv' };
2264
+ if (run.reviewCsv) lastCsv.review = { csv: run.reviewCsv, name: run.reviewDownloadName ?? 'qcc-review.csv' };
2265
+ const summary = run.summary ?? {};
2266
+ const reviewRequired = Number(summary.ambiguous ?? run.reviewQueue?.length ?? 0);
2267
+ const currentTask = workflowTaskBySession.get(String(activeSessionId || 'unassigned')) ?? cachedTask;
2268
+ let current = currentTask;
2269
+ // partial 表示上一轮仍有失败项。重试成功后可直接重进 enrichment,
2270
+ // 无需伪造一次新的 match 转换;若重试产生候选,再回到人工核验。
2271
+ if (currentTask?.state !== 'partial' || reviewRequired > 0) {
2272
+ current = await workflowAction(actions, activeSessionId, currentTask, 'match', {
2273
+ qccRunId: run.runId,
2274
+ summary: {
2275
+ total: Number(summary.totalRows ?? runtime.rows.length),
2276
+ exact: Number(summary.enriched ?? 0),
2277
+ candidate: reviewRequired,
2278
+ confirmed: 0,
2279
+ unresolved: Number(summary.unresolved ?? summary.missingName ?? 0),
2280
+ failed: Number(summary.failed ?? 0),
2281
+ reviewRequired,
2282
+ },
2283
+ });
2284
+ }
2285
+ if (reviewRequired > 0) {
2286
+ actions.setStep('match');
2287
+ return current;
2288
+ }
2289
+ current = await workflowAction(actions, activeSessionId, current, 'enrich-start', { fieldSelection });
2290
+ current = await workflowAction(actions, activeSessionId, current, 'enrichment', {
2291
+ qccRunId: run.runId,
2292
+ summary: {
2293
+ total: Number(summary.totalRows ?? runtime.rows.length),
2294
+ completed: Number(summary.enriched ?? 0),
2295
+ unchanged: Number(summary.unresolved ?? 0) + Number(summary.missingName ?? 0),
2296
+ failed: Number(summary.failed ?? 0),
2297
+ reviewRequired: 0,
2298
+ callsUsed: Array.isArray(run.audit) ? run.audit.length : 0,
2299
+ },
2300
+ });
2301
+ actions.setStep(current.stage === 'download' ? 'download' : 'enrich');
2302
+ return current;
1126
2303
  };
1127
2304
 
1128
2305
  const runQcc = async () => {
@@ -1130,20 +2307,23 @@ window.__ModuleLoader__.load({
1130
2307
  actions.setBusy(true);
1131
2308
  actions.setError(null);
1132
2309
  try {
1133
- const key = `phase3-ui-${Date.now()}-${Math.random().toString(36).slice(2, 10)}`;
1134
- const r = await api('/data-cleaning/api/phase3/enrich', {
1135
- rows: session.rows,
1136
- headers: session.headers,
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', {
2313
+ rows: runtime.rows,
2314
+ headers: runtime.headers,
1137
2315
  nameField,
1138
- domains: selectedDomains,
1139
- maxCalls: qccEstimate.maxCalls,
2316
+ includeRisk: false,
1140
2317
  concurrency: 2,
1141
2318
  confirmPaidCalls: true,
1142
2319
  idempotencyKey: key,
1143
2320
  });
1144
- if (r && r.ok !== false) applyQccRun(r);
1145
- else actions.setError((r && (r.message || r.error)) || '三域补全失败');
2321
+ if (r && r.ok !== false) await applyQccRun(r);
2322
+ else actions.setError((r && (r.message || r.error)) || '企业匹配补全失败');
1146
2323
  } catch (err) {
2324
+ if (['QCC_NOT_CONNECTED', 'QCC_TOOL_UNAVAILABLE', 'QCC_AUTH_REQUIRED'].includes(err?.code)) {
2325
+ try { await workflowAction(actions, activeSessionId, cachedTask, 'authorization-required'); } catch (_workflowError) {}
2326
+ }
1147
2327
  actions.setError(err instanceof Error ? err.message : String(err));
1148
2328
  } finally {
1149
2329
  actions.setBusy(false);
@@ -1155,14 +2335,14 @@ window.__ModuleLoader__.load({
1155
2335
  actions.setBusy(true);
1156
2336
  actions.setError(null);
1157
2337
  try {
1158
- const r = await api('/data-cleaning/api/phase3/resolve', {
2338
+ const r = await api('/data-cleaning/api/g5/resolve', {
1159
2339
  runId: qccRun.runId,
1160
2340
  companyName: item.companyName,
1161
2341
  selectedCreditNo: candidate.creditNo,
1162
2342
  confirmPaidCalls: true,
1163
- idempotencyKey: `phase3-resolve-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
2343
+ idempotencyKey: `g5-resolve-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
1164
2344
  });
1165
- if (r && r.ok !== false) applyQccRun(r);
2345
+ if (r && r.ok !== false) await applyQccRun(r);
1166
2346
  else actions.setError((r && (r.message || r.error)) || '候选确认失败');
1167
2347
  } catch (err) {
1168
2348
  actions.setError(err instanceof Error ? err.message : String(err));
@@ -1178,13 +2358,13 @@ window.__ModuleLoader__.load({
1178
2358
  actions.setBusy(true);
1179
2359
  actions.setError(null);
1180
2360
  try {
1181
- const r = await api('/data-cleaning/api/phase3/retry', {
2361
+ const r = await api('/data-cleaning/api/g5/retry', {
1182
2362
  runId: qccRun.runId,
1183
2363
  companyNames: names,
1184
2364
  confirmPaidCalls: true,
1185
- idempotencyKey: `phase3-retry-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
2365
+ idempotencyKey: `g5-retry-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
1186
2366
  });
1187
- if (r && r.ok !== false) applyQccRun(r);
2367
+ if (r && r.ok !== false) await applyQccRun(r);
1188
2368
  else actions.setError((r && (r.message || r.error)) || '失败项重试失败');
1189
2369
  } catch (err) {
1190
2370
  actions.setError(err instanceof Error ? err.message : String(err));
@@ -1193,46 +2373,192 @@ window.__ModuleLoader__.load({
1193
2373
  }
1194
2374
  };
1195
2375
 
1196
- const download = (slot) => {
1197
- const item = lastCsv[slot];
1198
- if (!item || !item.csv) return;
1199
- const blob = new Blob([item.csv], { type: 'text/csv;charset=utf-8' });
1200
- const a = document.createElement('a');
1201
- a.href = URL.createObjectURL(blob);
1202
- a.download = item.name;
1203
- a.click();
1204
- URL.revokeObjectURL(a.href);
2376
+ const saveTaskSettings = async () => {
2377
+ actions.setBusy(true);
2378
+ actions.setError(null);
2379
+ try {
2380
+ const current = await ensureWorkflowTask(actions, activeSessionId, {
2381
+ title: taskTitle, objectives, fieldSelection, mappings, matchRules,
2382
+ });
2383
+ await updateWorkflowTask(actions, activeSessionId, current, {
2384
+ title: taskTitle, objectives, fieldSelection, mappings, matchRules,
2385
+ });
2386
+ } catch (saveError) {
2387
+ actions.setError(saveError instanceof Error ? saveError.message : String(saveError));
2388
+ } finally {
2389
+ actions.setBusy(false);
2390
+ }
2391
+ };
2392
+
2393
+ const confirmRuleSettings = async () => {
2394
+ if (!hasData) { actions.setError('请先上传并解析数据。'); return; }
2395
+ const anchorMappings = mappings.filter((mapping) => ['company_name', 'credit_no', 'reg_no'].includes(mapping.targetField));
2396
+ if (!anchorMappings.length) { actions.setError('请至少映射企业名称、统一社会信用代码或注册号。'); return; }
2397
+ if (!objectives.length) { actions.setError('请至少选择一个清洗或补全目标。'); return; }
2398
+ actions.setBusy(true);
2399
+ actions.setError(null);
2400
+ try {
2401
+ let current = await ensureWorkflowTask(actions, activeSessionId, {
2402
+ title: taskTitle, objectives, fieldSelection, mappings, matchRules,
2403
+ });
2404
+ if (current.state === 'draft') {
2405
+ current = await workflowAction(actions, activeSessionId, current, 'upload', {
2406
+ source: {
2407
+ type: runtime.source?.type || dataset.fmt || 'csv',
2408
+ fileName: runtime.source?.fileName || '',
2409
+ rowCount: dataset.rowCount,
2410
+ columnCount: dataset.headers?.length || 0,
2411
+ headers: dataset.headers || [],
2412
+ sizeBytes: runtime.source?.sizeBytes || 0,
2413
+ },
2414
+ });
2415
+ }
2416
+ current = await updateWorkflowTask(actions, activeSessionId, current, {
2417
+ title: taskTitle, objectives, fieldSelection, mappings, matchRules,
2418
+ });
2419
+ current = await workflowAction(actions, activeSessionId, current, 'rules', {
2420
+ objectives, fieldSelection, mappings, matchRules,
2421
+ });
2422
+ current = await performProfile(current);
2423
+ const primary = mappings.find((mapping) => mapping.targetField === 'company_name')
2424
+ ?? mappings.find((mapping) => mapping.targetField === 'credit_no')
2425
+ ?? mappings.find((mapping) => mapping.targetField === 'reg_no');
2426
+ if (primary) actions.setNameField(primary.sourceField);
2427
+ actions.setStep('profile');
2428
+ } catch (ruleError) {
2429
+ actions.setError(ruleError instanceof Error ? ruleError.message : String(ruleError));
2430
+ } finally {
2431
+ actions.setBusy(false);
2432
+ }
2433
+ };
2434
+
2435
+ const resumeWorkflowTask = (task) => {
2436
+ workflowTaskBySession.set(String(activeSessionId || 'unassigned'), task);
2437
+ actions.setWorkflowTask(task);
2438
+ actions.setTaskTitle(task.title);
2439
+ actions.setMappings(task.mappings || []);
2440
+ actions.setObjectives(task.objectives || []);
2441
+ actions.setFieldSelection(task.fieldSelection || []);
2442
+ for (const [key, value] of Object.entries(task.matchRules || {})) actions.setMatchRule(key, value);
2443
+ actions.setStep(task.stage || 'upload');
2444
+ if (!runtimeFor(task.id, false)?.rows?.length && task.source?.rowCount && !(task.artifacts || []).length) {
2445
+ actions.setDataset(null);
2446
+ actions.setError('已恢复任务元数据。出于隐私保护,原始企业名单未持久化;继续处理前请重新上传源文件。');
2447
+ }
2448
+ };
2449
+
2450
+ const startNewWorkflow = () => {
2451
+ if (cachedTask?.id) runtimeTasks.delete(String(cachedTask.id));
2452
+ runtimeTasks.delete('unassigned');
2453
+ workflowTaskBySession.delete(String(activeSessionId || 'unassigned'));
2454
+ actions.resetWorkflow();
2455
+ };
2456
+
2457
+ const exportRows = () => runtime.resultRows.qcc
2458
+ ?? runtime.resultRows.complete
2459
+ ?? runtime.resultRows.clean
2460
+ ?? (runtime.rows.length ? runtime.rows : null);
2461
+
2462
+ const createArtifactBundle = async () => {
2463
+ let latest = workflowTaskBySession.get(String(activeSessionId || 'unassigned')) ?? cachedTask;
2464
+ if (!latest) throw new Error('请先创建并执行数据清洗补全任务。');
2465
+ if (Array.isArray(latest.artifacts) && latest.artifacts.length) return latest;
2466
+ const rows = exportRows();
2467
+ if (!rows?.length) throw new Error('当前页面没有可导出的明细。若任务来自历史记录,请重新上传源文件并继续处理。');
2468
+ const response = await requestJson(`/data-cleaning/api/workflow/tasks/${encodeURIComponent(latest.id)}/artifacts`, 'POST', {
2469
+ expectedRevision: latest.revision,
2470
+ headers: runtime.headers,
2471
+ rows,
2472
+ baseName: latest.title || taskTitle,
2473
+ summary: {
2474
+ total: rows.length,
2475
+ completed: rows.length,
2476
+ unchanged: 0,
2477
+ failed: 0,
2478
+ },
2479
+ });
2480
+ latest = cacheWorkflowTask(actions, activeSessionId, response.task);
2481
+ loadWorkflowTasks(actions);
2482
+ return latest;
2483
+ };
2484
+
2485
+ const downloadArtifact = async (artifact) => {
2486
+ if (!artifact || busy) return;
2487
+ actions.setBusy(true);
2488
+ actions.setError(null);
2489
+ try {
2490
+ const latest = workflowTaskBySession.get(String(activeSessionId || 'unassigned')) ?? cachedTask;
2491
+ const response = await fetch(`/data-cleaning/api/workflow/tasks/${encodeURIComponent(latest.id)}/artifacts/${encodeURIComponent(artifact.id)}`, {
2492
+ method: 'GET', credentials: 'same-origin', headers: { accept: artifact.mediaType || 'application/octet-stream' },
2493
+ });
2494
+ if (!response.ok) {
2495
+ let payload = null;
2496
+ try { payload = await response.json(); } catch {}
2497
+ throw new Error(payload?.message || `下载失败(HTTP ${response.status})`);
2498
+ }
2499
+ const blob = await response.blob();
2500
+ const href = URL.createObjectURL(blob);
2501
+ const anchor = document.createElement('a');
2502
+ anchor.href = href;
2503
+ anchor.download = artifact.fileName || `export.${artifact.format || 'bin'}`;
2504
+ anchor.click();
2505
+ URL.revokeObjectURL(href);
2506
+ } catch (downloadError) {
2507
+ actions.setError(downloadError instanceof Error ? downloadError.message : String(downloadError));
2508
+ } finally {
2509
+ actions.setBusy(false);
2510
+ }
2511
+ };
2512
+
2513
+ const createAndDownload = async (kind, format) => {
2514
+ if (busy) return;
2515
+ actions.setBusy(true);
2516
+ actions.setError(null);
2517
+ try {
2518
+ const latest = await createArtifactBundle();
2519
+ const artifact = (latest.artifacts || []).find((item) => item.kind === kind && item.format === format);
2520
+ if (!artifact) throw new Error('Host 未生成所选格式的制品。');
2521
+ actions.setBusy(false);
2522
+ await downloadArtifact(artifact);
2523
+ } catch (downloadError) {
2524
+ actions.setError(downloadError instanceof Error ? downloadError.message : String(downloadError));
2525
+ actions.setBusy(false);
2526
+ }
1205
2527
  };
1206
2528
 
1207
2529
  const stat = (label, value, tone) => h('div', { className: 'dcAgentCard' },
1208
2530
  h('span', null, label),
1209
2531
  h('b', { className: tone ? `is-${tone}` : null }, value)
1210
2532
  );
2533
+ const uiFieldGroups = Array.isArray(workflowContract?.fieldCatalog)
2534
+ ? workflowContract.fieldCatalog.map((group) => [group.id, group.label, (group.fields || []).map((field) => [field.id, field.label])])
2535
+ : FIELD_GROUPS;
2536
+ const targetFields = uiFieldGroups.flatMap(([, , fields]) => fields);
1211
2537
 
1212
2538
  let pane;
1213
2539
  if (step === 'history') {
1214
2540
  pane = h('div', { className: 'dcAgentPane' },
1215
- h('p', { className: 'dcAgentHint' }, '查看当前 Host 的数据清洗后台任务。任务数据仅在本机保存,不进入模型上下文。'),
2541
+ h('p', { className: 'dcAgentHint' }, '查看当前 Host 的数据清洗补全任务。Host 只保存任务元数据和摘要,不保存原始企业名单。'),
1216
2542
  h('div', { className: 'dcAgentRow' },
1217
2543
  h('button', {
1218
2544
  type: 'button',
1219
2545
  className: 'dcAgentButton',
1220
2546
  disabled: busy,
1221
2547
  'aria-label': '刷新任务历史',
1222
- onClick: () => pollJobsOnce(actions),
2548
+ onClick: () => loadWorkflowTasks(actions),
1223
2549
  }, '刷新任务'),
1224
2550
  h('button', {
1225
2551
  type: 'button',
1226
2552
  className: 'dcAgentButton is-primary',
1227
- onClick: () => actions.setStep('upload'),
2553
+ onClick: startNewWorkflow,
1228
2554
  }, '新建清洗任务'),
1229
2555
  ),
1230
- jobs.length ? h('div', { className: 'dcAgentPane', 'aria-label': '任务历史列表' },
1231
- jobs.map((job) => h('section', { key: job.id, className: 'dcAgentSection' },
1232
- h('h3', null, job.name || job.id || '数据清洗任务'),
2556
+ workflowTasks.length ? h('div', { className: 'dcAgentPane', 'aria-label': '任务历史列表' },
2557
+ workflowTasks.map((task) => h('button', { key: task.id, type: 'button', className: 'dcAgentHistoryTask', onClick: () => resumeWorkflowTask(task) },
2558
+ h('h3', null, task.title || task.id || '数据清洗任务'),
1233
2559
  h('div', { className: 'dcAgentRow' },
1234
- h('span', { className: 'dcAgentJobsPill', 'data-state': job.state ?? 'idle' }, JOB_STATE_LABEL[job.state] ?? job.state ?? '未知'),
1235
- h('span', { className: 'dcAgentHint' }, job.createdAt ? new Date(job.createdAt).toLocaleString() : ''),
2560
+ h('span', { className: 'dcAgentJobsPill', 'data-state': task.state ?? 'idle' }, WORKFLOW_STATE_LABELS[task.state] ?? task.state ?? '未知'),
2561
+ h('span', { className: 'dcAgentHint' }, `${task.source?.rowCount ?? 0} 行 · ${task.updatedAt ? new Date(task.updatedAt).toLocaleString() : ''}`),
1236
2562
  ),
1237
2563
  )),
1238
2564
  ) : h('section', { className: 'dcAgentSection' },
@@ -1240,6 +2566,58 @@ window.__ModuleLoader__.load({
1240
2566
  h('p', { className: 'dcAgentHint' }, '上传企业名单并开始处理后,任务状态会显示在这里。'),
1241
2567
  ),
1242
2568
  );
2569
+ } else if (step === 'rules') {
2570
+ pane = h('div', { className: 'dcAgentPane' },
2571
+ h('p', { className: 'dcAgentHint' }, '确认任务名称、字段映射、匹配规则和输出字段。保存草稿不会调用企查查;确认规则后才可进入质量体检。'),
2572
+ h('label', { className: 'dcAgentFormField' },
2573
+ h('span', null, '任务名称'),
2574
+ h('input', { className: 'dcAgentField', value: taskTitle, maxLength: 120, onChange: (event) => actions.setTaskTitle(event.target.value) }),
2575
+ ),
2576
+ h('section', { className: 'dcAgentSection' },
2577
+ h('h3', null, '字段映射'),
2578
+ h('p', { className: 'dcAgentHint' }, '至少映射企业名称、统一社会信用代码或注册号其中一项。未映射列会原样保留,但不参与匹配。'),
2579
+ (dataset?.headers || []).map((sourceField) => h('label', { className: 'dcAgentMappingRow', key: sourceField },
2580
+ h('span', { title: sourceField }, sourceField),
2581
+ h('b', { 'aria-hidden': 'true' }, '→'),
2582
+ h('select', {
2583
+ className: 'dcAgentField',
2584
+ value: mappings.find((mapping) => mapping.sourceField === sourceField)?.targetField || '',
2585
+ 'aria-label': `${sourceField} 字段映射`,
2586
+ onChange: (event) => actions.setMapping(sourceField, event.target.value),
2587
+ },
2588
+ h('option', { value: '' }, '不参与匹配 / 补全'),
2589
+ targetFields.map(([id, label]) => h('option', { key: id, value: id }, label)),
2590
+ ),
2591
+ )),
2592
+ ),
2593
+ h('section', { className: 'dcAgentSection' },
2594
+ h('h3', null, '任务目标'),
2595
+ h('div', { className: 'dcAgentChips' }, CLEANING_OPTIONS.map(([key, label]) => h('label', { key, className: `dcAgentChip${objectives.includes(key) ? ' is-selected' : ''}` },
2596
+ h('input', { type: 'checkbox', checked: objectives.includes(key), onChange: () => actions.toggleObjective(key) }), ` ${label}`,
2597
+ ))),
2598
+ ),
2599
+ h('section', { className: 'dcAgentSection' },
2600
+ h('h3', null, '匹配规则'),
2601
+ h('div', { className: 'dcAgentRulesGrid' },
2602
+ [['normalizeNames', '匹配前规范企业名称'], ['preferCreditNo', '信用代码优先精确匹配'], ['deduplicate', '重复主体合并处理'], ['manualReviewAmbiguous', '多候选必须人工确认']].map(([key, label]) => h('label', { key, className: 'dcAgentCheck' },
2603
+ h('input', { type: 'checkbox', checked: matchRules[key], onChange: (event) => actions.setMatchRule(key, event.target.checked) }), label,
2604
+ )),
2605
+ ),
2606
+ ),
2607
+ h('section', { className: 'dcAgentSection' },
2608
+ h('h3', null, `输出字段 · 已选 ${fieldSelection.length}`),
2609
+ uiFieldGroups.map(([groupId, label, fields]) => h('div', { key: groupId, className: 'dcAgentFieldGroup' },
2610
+ h('b', null, label),
2611
+ h('div', { className: 'dcAgentChips' }, fields.map(([key, fieldLabel]) => h('label', { key, className: `dcAgentChip${fieldSelection.includes(key) ? ' is-selected' : ''}` },
2612
+ h('input', { type: 'checkbox', checked: fieldSelection.includes(key), onChange: () => actions.toggleField(key) }), ` ${fieldLabel}`,
2613
+ ))),
2614
+ )),
2615
+ ),
2616
+ h('div', { className: 'dcAgentRow' },
2617
+ h('button', { type: 'button', className: 'dcAgentButton', disabled: busy, onClick: saveTaskSettings }, '保存草稿'),
2618
+ h('button', { type: 'button', className: 'dcAgentButton is-primary', disabled: busy || !hasData, 'aria-label': '确认规则并运行质量体检', onClick: confirmRuleSettings }, busy ? '保存中…' : '确认规则并运行质量体检'),
2619
+ ),
2620
+ );
1243
2621
  } else if (step === 'profile') {
1244
2622
  pane = h('div', { className: 'dcAgentPane' },
1245
2623
  h('p', { className: 'dcAgentHint' }, '基于已解析数据生成本地质量画像(缺失率、去重值、金额分布)。本步骤不发起企查查调用。'),
@@ -1267,45 +2645,28 @@ window.__ModuleLoader__.load({
1267
2645
  ))),
1268
2646
  ) : null,
1269
2647
  h('div', { className: 'dcAgentRow' },
1270
- h('button', { type: 'button', className: 'dcAgentButton is-primary', disabled: busy, onClick: () => actions.setStep('review') }, '下一步:匹配核验'),
2648
+ h('button', { type: 'button', className: 'dcAgentButton is-primary', disabled: busy, onClick: () => actions.setStep('match') }, '下一步:匹配核验'),
1271
2649
  ),
1272
2650
  ) : h('div', { className: 'dcAgentRow' },
1273
2651
  h('button', { type: 'button', className: 'dcAgentButton is-primary', disabled: busy || !hasData, 'aria-label': '生成体检报告', onClick: runProfile }, busy ? '体检中…' : '生成体检报告'),
1274
2652
  ),
1275
2653
  );
1276
- } else if (step === 'review') {
2654
+ } else if (step === 'match') {
1277
2655
  pane = h('div', { className: 'dcAgentPane' },
1278
- h('p', { className: 'dcAgentHint' }, '先执行本地确定性清洗,再按真实 ToolRuntime 状态进行主体匹配。多候选始终由人工选择;界面不生成虚构置信度。'),
2656
+ h('p', { className: 'dcAgentHint' }, '通过当前用户连接的企查查 MCP 定位企业主体并补全基础工商字段。多候选始终由人工选择;界面不生成虚构置信度。'),
1279
2657
  h('section', { className: 'dcAgentSection' },
1280
- h('h3', null, '本地清洗预处理'),
1281
- h('div', { className: 'dcAgentRow' },
1282
- h('button', { type: 'button', className: 'dcAgentButton', disabled: busy, 'aria-label': '执行清洗', onClick: runClean }, busy ? '处理中…' : '执行清洗'),
1283
- h('button', { type: 'button', className: 'dcAgentButton', disabled: busy, 'aria-label': '执行补全', onClick: runComplete }, busy ? '处理中…' : '本地规则补全'),
1284
- ),
1285
- clean ? h('div', { className: 'dcAgentGrid' },
1286
- stat('总数', clean.total), stat('保留', clean.kept, 'good'), stat('剔除', clean.dropped, 'bad'),
1287
- stat('缺失关键字段', clean.badMissing, clean.badMissing > 0 ? 'bad' : null),
1288
- stat('非法金额', clean.badAmount, clean.badAmount > 0 ? 'warn' : null),
1289
- stat('重复', clean.badDuplicate, clean.badDuplicate > 0 ? 'warn' : null),
1290
- ) : null,
2658
+ h('h3', null, '匹配依据'),
2659
+ h('div', { className: 'dcAgentChips' }, mappings.filter((mapping) => ['company_name', 'credit_no', 'reg_no'].includes(mapping.targetField)).map((mapping) => h('span', { key: mapping.sourceField, className: 'dcAgentChip' }, `${mapping.sourceField} → ${ENRICHMENT_OPTIONS.find(([id]) => id === mapping.targetField)?.[1] || mapping.targetField}`))),
2660
+ h('p', { className: 'dcAgentHint' }, '统一社会信用代码等强标识优先;名称匹配不明确时进入人工核验队列。'),
1291
2661
  ),
1292
2662
  h('section', { className: 'dcAgentSection' },
1293
- h('h3', null, '企查查三域能力与调用范围'),
2663
+ h('h3', null, '企查查基础企业能力'),
1294
2664
  h('div', { className: 'dcAgentRow' },
1295
2665
  h('button', { type: 'button', className: 'dcAgentButton', disabled: busy, onClick: loadQccCapabilities }, busy ? '检测中…' : '检测企查查连接'),
1296
- qccCapabilities ? h('span', { className: 'dcAgentHint' }, qccCapabilities.capabilities?.ready ? '91 个三域工具已就绪' : `${qccCapabilities.capabilities?.totalRegistered ?? 0}/${qccCapabilities.capabilities?.total ?? 91} 工具可用`) : null,
1297
- ),
1298
- h('div', { className: 'dcAgentChips' },
1299
- [['risk', '风险信息 · 38'], ['ipr', '知识产权 · 18'], ['operation', '经营信息 · 35']].map(([domain, label]) => h('label', {
1300
- key: domain,
1301
- className: `dcAgentChip${selectedDomains.includes(domain) ? ' is-selected' : ''}`,
1302
- },
1303
- h('input', { type: 'checkbox', checked: selectedDomains.includes(domain), 'aria-label': label, onChange: () => actions.toggleDomain(domain) }),
1304
- ` ${label}`,
1305
- )),
2666
+ qccCapabilities ? h('span', { className: 'dcAgentHint' }, qccCapabilities.capabilities?.ready ? '主体查询与工商信息工具已就绪' : '连接未就绪或基础工具不完整') : null,
1306
2667
  ),
1307
2668
  h('div', { className: 'dcAgentRow' },
1308
- h('button', { type: 'button', className: 'dcAgentButton', disabled: busy || !selectedDomains.length, onClick: estimateQcc }, '估算调用量'),
2669
+ h('button', { type: 'button', className: 'dcAgentButton', disabled: busy || !runtime.rows.length, onClick: estimateQcc }, '估算调用量'),
1309
2670
  ),
1310
2671
  qccEstimate ? h('div', null,
1311
2672
  h('div', { className: 'dcAgentGrid' },
@@ -1316,10 +2677,10 @@ window.__ModuleLoader__.load({
1316
2677
  ),
1317
2678
  h('label', { className: 'dcAgentCheck' },
1318
2679
  h('input', { type: 'checkbox', checked: paidConfirmed, 'aria-label': '确认使用当前用户的企查查账号额度', onChange: (event) => actions.setPaidConfirmed(event.target.checked) }),
1319
- '我已核对企业数量、所选域及调用上界,并确认使用自己连接的企查查 MCP 账号;额度或费用由该账号自行承担',
2680
+ '我已核对企业数量和调用上界,并确认使用自己连接的企查查 MCP 账号;额度或费用由该账号自行承担',
1320
2681
  ),
1321
2682
  h('div', { className: 'dcAgentRow' },
1322
- h('button', { type: 'button', className: 'dcAgentButton is-primary', disabled: busy || !paidConfirmed || !qccEstimate.withinLimit, onClick: runQcc }, busy ? '执行中…' : '开始匹配与补全'),
2683
+ h('button', { type: 'button', className: 'dcAgentButton is-primary', disabled: busy || !paidConfirmed || !qccEstimate.withinLimit, onClick: runQcc }, busy ? '执行中…' : '开始主体匹配'),
1323
2684
  ),
1324
2685
  ) : null,
1325
2686
  ),
@@ -1327,11 +2688,21 @@ window.__ModuleLoader__.load({
1327
2688
  h('h3', null, `任务 ${qccRun.runId} · ${qccRun.state}`),
1328
2689
  h('div', { className: 'dcAgentGrid' },
1329
2690
  stat('已补全', qccRun.summary?.enriched ?? 0, 'good'),
1330
- stat('部分成功', qccRun.summary?.partial ?? 0, (qccRun.summary?.partial ?? 0) > 0 ? 'warn' : null),
1331
2691
  stat('待核验', qccRun.summary?.ambiguous ?? 0, (qccRun.summary?.ambiguous ?? 0) > 0 ? 'warn' : null),
2692
+ stat('未匹配', qccRun.summary?.unresolved ?? 0, (qccRun.summary?.unresolved ?? 0) > 0 ? 'warn' : null),
1332
2693
  stat('失败', qccRun.summary?.failed ?? 0, (qccRun.summary?.failed ?? 0) > 0 ? 'bad' : null),
1333
- stat('实际调用', qccRun.summary?.actualCalls ?? 0),
1334
2694
  ),
2695
+ Array.isArray(qccRun.rows) && qccRun.rows.length ? h('div', { className: 'dcAgentTableWrap' },
2696
+ h('table', { className: 'dcAgentTable', 'aria-label': '匹配补全结果预览' },
2697
+ h('thead', null, h('tr', null,
2698
+ Object.keys(qccRun.rows[0] || {}).slice(0, 6).map((key) => h('th', { key }, key)),
2699
+ )),
2700
+ h('tbody', null, qccRun.rows.slice(0, 8).map((row, index) => h('tr', { key: `${index}-${row?.qcc_credit_no || row?.credit_no || ''}` },
2701
+ Object.keys(qccRun.rows[0] || {}).slice(0, 6).map((key) => h('td', { key }, String(row?.[key] ?? ''))),
2702
+ ))),
2703
+ ),
2704
+ h('p', { className: 'dcAgentHint' }, `显示前 ${Math.min(8, qccRun.rows.length)} 行;完整结果在下载阶段生成耐久制品。`),
2705
+ ) : null,
1335
2706
  (qccRun.reviewQueue || []).map((item) => h('div', { key: item.companyName, className: 'dcAgentSection' },
1336
2707
  h('h3', null, `待核验:${item.companyName}`),
1337
2708
  item.candidates.map((candidate) => h('div', { key: candidate.creditNo, className: 'dcAgentCandidate' },
@@ -1341,16 +2712,27 @@ window.__ModuleLoader__.load({
1341
2712
  )),
1342
2713
  )),
1343
2714
  (qccRun.errors || []).some((item) => item.error?.retryable) ? h('button', { type: 'button', className: 'dcAgentButton', disabled: busy || !paidConfirmed, onClick: retryFailures }, '重试可恢复失败项') : null,
1344
- h('div', { className: 'dcAgentRow' },
1345
- h('button', { type: 'button', className: 'dcAgentButton is-primary', onClick: () => actions.setStep('enrich') }, '进入补全与导出'),
1346
- ),
1347
- ) : h('div', { className: 'dcAgentRow' },
1348
- h('button', { type: 'button', className: 'dcAgentButton', onClick: () => actions.setStep('enrich') }, '仅使用本地结果并导出'),
1349
- ),
2715
+ ) : null,
1350
2716
  );
1351
2717
  } else if (step === 'enrich') {
1352
2718
  pane = h('div', { className: 'dcAgentPane' },
1353
- h('p', { className: 'dcAgentHint' }, '导出本地清洗结果、企查查三域补全结果和待核验清单。每个三域值均保留 sourceTool 与上游原值。'),
2719
+ h('p', { className: 'dcAgentHint' }, '执行本地确定性清洗并核对本任务所选补全字段。外部字段以当前用户企查查 MCP 的真实返回为准,缺失值不会被编造。'),
2720
+ h('section', { className: 'dcAgentSection' },
2721
+ h('h3', null, `已选补全字段 · ${fieldSelection.length}`),
2722
+ h('div', { className: 'dcAgentChips' }, fieldSelection.map((id) => h('span', { className: 'dcAgentChip', key: id }, ENRICHMENT_OPTIONS.find(([fieldId]) => fieldId === id)?.[1] || id))),
2723
+ ),
2724
+ h('section', { className: 'dcAgentSection' },
2725
+ h('h3', null, '本地清洗预处理'),
2726
+ h('div', { className: 'dcAgentRow' },
2727
+ h('button', { type: 'button', className: 'dcAgentButton', disabled: busy || !hasData, 'aria-label': '执行清洗', onClick: runClean }, busy ? '处理中…' : '执行确定性清洗'),
2728
+ h('button', { type: 'button', className: 'dcAgentButton', disabled: busy || !hasData, 'aria-label': '执行补全', onClick: runComplete }, busy ? '处理中…' : '本地规则补全'),
2729
+ ),
2730
+ clean ? h('div', { className: 'dcAgentGrid' },
2731
+ stat('总数', clean.total), stat('保留', clean.kept, 'good'), stat('剔除', clean.dropped, 'bad'),
2732
+ stat('缺失关键字段', clean.badMissing, clean.badMissing > 0 ? 'bad' : null),
2733
+ stat('重复', clean.badDuplicate, clean.badDuplicate > 0 ? 'warn' : null),
2734
+ ) : null,
2735
+ ),
1354
2736
  h('div', { className: 'dcAgentGrid' },
1355
2737
  stat('输入行数', dataset ? dataset.rowCount : '—'),
1356
2738
  stat('清洗保留', clean ? clean.kept : '—', clean && clean.kept > 0 ? 'good' : null),
@@ -1358,11 +2740,36 @@ window.__ModuleLoader__.load({
1358
2740
  stat('QCC 已补全', qccRun ? qccRun.summary?.enriched ?? 0 : '—', qccRun && qccRun.summary?.enriched > 0 ? 'good' : null),
1359
2741
  stat('待核验', qccRun ? qccRun.summary?.ambiguous ?? 0 : '—', qccRun && qccRun.summary?.ambiguous > 0 ? 'warn' : null),
1360
2742
  ),
1361
- h('div', { className: 'dcAgentRow' },
1362
- h('button', { type: 'button', className: 'dcAgentButton', disabled: !lastCsv.clean, 'aria-label': '下载清洗结果', onClick: () => download('clean') }, '下载清洗结果 CSV'),
1363
- h('button', { type: 'button', className: 'dcAgentButton', disabled: !lastCsv.complete, 'aria-label': '下载补全结果', onClick: () => download('complete') }, '下载补全结果 CSV'),
1364
- h('button', { type: 'button', className: 'dcAgentButton is-primary', disabled: !lastCsv.qcc, 'aria-label': '下载 QCC 补全结果', onClick: () => download('qcc') }, '下载 QCC 补全结果 CSV'),
1365
- h('button', { type: 'button', className: 'dcAgentButton', disabled: !lastCsv.review, 'aria-label': '下载待核验清单', onClick: () => download('review') }, '下载待核验清单 CSV'),
2743
+ h('div', { className: 'dcAgentRow' }, h('button', { type: 'button', className: 'dcAgentButton is-primary', onClick: () => actions.setStep('download') }, '进入下载数据')),
2744
+ );
2745
+ } else if (step === 'download') {
2746
+ const availableArtifacts = cachedTask?.artifacts || [];
2747
+ const canCreateArtifacts = Boolean(exportRows()?.length) && ['rules_confirmed', 'diagnosed', 'export_ready', 'partial'].includes(cachedTask?.state);
2748
+ pane = h('div', { className: 'dcAgentPane' },
2749
+ h('p', { className: 'dcAgentHint' }, '结果与异常清单由 Host 生成 CSV/XLSX 四件套并耐久保存。任务或插件重启后,可从任务历史继续下载;无需再次调用企查查。'),
2750
+ h('div', { className: 'dcAgentGrid' },
2751
+ 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),
2754
+ stat('任务状态', WORKFLOW_STATE_LABELS[cachedTask?.state] || cachedTask?.state || '进行中'),
2755
+ ),
2756
+ availableArtifacts.length ? h('div', { className: 'dcAgentArtifactList' },
2757
+ availableArtifacts.map((artifact) => h('button', {
2758
+ key: artifact.id,
2759
+ type: 'button',
2760
+ className: `dcAgentButton${artifact.kind === 'complete' && artifact.format === 'xlsx' ? ' is-primary' : ''}`,
2761
+ disabled: busy,
2762
+ onClick: () => downloadArtifact(artifact),
2763
+ 'aria-label': `下载 ${artifact.fileName}`,
2764
+ }, `${artifact.kind === 'review' ? '异常清单' : '清洗补全结果'} ${String(artifact.format).toUpperCase()} · ${artifact.rowCount} 行`)),
2765
+ ) : h('div', null,
2766
+ h('div', { className: 'dcAgentRow' },
2767
+ h('button', { type: 'button', className: 'dcAgentButton is-primary', disabled: busy || !canCreateArtifacts, onClick: () => createAndDownload('complete', 'xlsx') }, '生成并下载结果 XLSX'),
2768
+ h('button', { type: 'button', className: 'dcAgentButton', disabled: busy || !canCreateArtifacts, onClick: () => createAndDownload('complete', 'csv') }, '生成并下载结果 CSV'),
2769
+ h('button', { type: 'button', className: 'dcAgentButton', disabled: busy || !canCreateArtifacts, onClick: () => createAndDownload('review', 'xlsx') }, '生成并下载异常清单 XLSX'),
2770
+ h('button', { type: 'button', className: 'dcAgentButton', disabled: busy || !canCreateArtifacts, onClick: () => createAndDownload('review', 'csv') }, '生成并下载异常清单 CSV'),
2771
+ ),
2772
+ !canCreateArtifacts ? h('p', { className: 'dcAgentHint' }, '请先完成规则确认与清洗补全;历史任务若尚未生成制品,需要重新上传源文件。') : null,
1366
2773
  ),
1367
2774
  );
1368
2775
  } else {
@@ -1388,15 +2795,15 @@ window.__ModuleLoader__.load({
1388
2795
  (dataset.headers || []).slice(0, 12).map((name) => h('span', { key: name, className: 'dcAgentChip' }, name)),
1389
2796
  (dataset.headers || []).length > 12 ? h('span', { className: 'dcAgentChip' }, `+${dataset.headers.length - 12} 列`) : null,
1390
2797
  ) : null,
1391
- dataset ? h('label', { className: 'dcAgentRow' },
1392
- h('span', { className: 'dcAgentHint' }, '企业名称字段'),
1393
- h('select', { className: 'dcAgentField', value: nameField, 'aria-label': '企业名称字段映射', onChange: (event) => actions.setNameField(event.target.value) },
1394
- (dataset.headers || []).map((name) => h('option', { key: name, value: name }, name)),
2798
+ dataset ? h('div', { className: 'dcAgentPreviewTable', 'aria-label': '数据预览' },
2799
+ h('table', { className: 'dcAgentTable' },
2800
+ h('thead', null, h('tr', null, (dataset.headers || []).slice(0, 8).map((name) => h('th', { key: name }, name)))),
2801
+ h('tbody', null, (dataset.preview || []).slice(0, 5).map((row, index) => h('tr', { key: index }, (dataset.headers || []).slice(0, 8).map((name) => h('td', { key: name }, String(row?.[name] ?? '')))))),
1395
2802
  ),
1396
2803
  ) : null,
1397
2804
  h('div', { className: 'dcAgentRow' },
1398
2805
  h('button', { type: 'button', className: 'dcAgentButton is-primary', disabled: busy, 'aria-label': '解析数据', onClick: handleParse }, busy ? '解析中…' : '解析数据'),
1399
- hasData ? h('button', { type: 'button', className: 'dcAgentButton', onClick: () => actions.setStep('profile') }, '继续到数据体检') : null,
2806
+ hasData ? h('button', { type: 'button', className: 'dcAgentButton', onClick: () => actions.setStep('rules') }, '下一步:字段映射与规则') : null,
1400
2807
  ),
1401
2808
  );
1402
2809
  }
@@ -1415,7 +2822,7 @@ window.__ModuleLoader__.load({
1415
2822
  className: `dcAgentWorkbench${expanded ? ' is-expanded' : ''}`,
1416
2823
  role: 'dialog',
1417
2824
  'aria-modal': 'false',
1418
- 'aria-label': '数据清洗工作台',
2825
+ 'aria-label': '数据清洗补全工作台',
1419
2826
  'data-session-id': activeSessionId ?? undefined,
1420
2827
  onKeyDown: (event) => {
1421
2828
  if (event.key === 'Escape') {
@@ -1430,7 +2837,7 @@ window.__ModuleLoader__.load({
1430
2837
  h('span', { className: 'dcAgentWbIcon', 'aria-hidden': 'true' }, '🧹'),
1431
2838
  h('div', null,
1432
2839
  h('b', null, '数据清洗补全'),
1433
- h('small', null, '本地确定性清洗 · 可选企查查 MCP 补全'),
2840
+ h('small', null, cachedTask ? `${WORKFLOW_STATE_LABELS[cachedTask.state] || cachedTask.state} · ${cachedTask.id.slice(0, 18)}` : '正在创建 Host taskId…'),
1434
2841
  ),
1435
2842
  ),
1436
2843
  h('span', {
@@ -1479,7 +2886,9 @@ window.__ModuleLoader__.load({
1479
2886
  applied: true,
1480
2887
  entry: 'sidebar.workspaces:before(portal)',
1481
2888
  lifecycleSlot: 'sidebar.footer.action',
1482
- capabilitySlot: 'conversation.input.left',
2889
+ capabilitySlot: 'conversation.input.dock',
2890
+ promptSlot: 'conversation.input.overlay',
2891
+ heroTitleBridge: 'exact-text/reversible',
1483
2892
  headerSlot: 'conversation.session.header.actions',
1484
2893
  overlay: 'shell.overlay',
1485
2894
  error: null,
@@ -1506,12 +2915,20 @@ window.__ModuleLoader__.load({
1506
2915
  inject: () => ({ startSession: () => startCleaningSession(ctx) }),
1507
2916
  }, SidebarEntry));
1508
2917
 
1509
- // 原生会话输入框工具行:对齐 Mockup 的五个能力 ICON,不创建第二套聊天 UI。
1510
- ctx.slots.inject('conversation.input.left', () => ctx.slots.register({
1511
- name: 'conversation.input.left',
2918
+ // 独立 dock 行:对齐 Mockup 的五个能力 ICON,CSS 只把本 cell 排到输入框下方。
2919
+ ctx.slots.inject('conversation.input.dock', () => ctx.slots.register({
2920
+ name: 'conversation.input.dock',
1512
2921
  id: 'data-cleaning-agent-capabilities',
1513
2922
  order: 110,
1514
- }, CapabilityBar));
2923
+ }, DataCleaningExperience));
2924
+
2925
+ // 输入框左上角提示词生成器:文本 / Excel / 图片 → 可编辑任务描述。
2926
+ ctx.slots.inject('conversation.input.overlay', () => ctx.slots.register({
2927
+ name: 'conversation.input.overlay',
2928
+ id: 'data-cleaning-agent-prompt-generator',
2929
+ order: 110,
2930
+ inject: () => ({ attachImages: (sessionId, files) => attachPromptImages(ctx, sessionId, files) }),
2931
+ }, PromptGenerator));
1515
2932
 
1516
2933
  // 会话头部恢复入口:用户关闭右栏后可随时重新打开当前任务工作台。
1517
2934
  ctx.slots.inject('conversation.session.header.actions', () => ctx.slots.register({
@@ -1541,6 +2958,17 @@ window.__ModuleLoader__.load({
1541
2958
 
1542
2959
  exports.apply = apply;
1543
2960
  exports.inject = inject;
2961
+ // 测试用纯函数,不构成 Host / DSH 稳定 API。
2962
+ exports.__testing = {
2963
+ buildTaskPrompt,
2964
+ entriesToDataset,
2965
+ extractPromptEntries,
2966
+ guessMappings,
2967
+ markCleaningSession,
2968
+ qualitySummaryFor,
2969
+ ensureWorkflowTask,
2970
+ queueWorkflowOperation,
2971
+ };
1544
2972
  return module.exports;
1545
2973
  },
1546
2974
  });