dsh-data-cleaning-agent 0.9.13 → 0.9.16

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/lib/client.js CHANGED
@@ -473,6 +473,15 @@ body:has(.dcAgentPromptBackdrop) { overflow: hidden; }
473
473
  }
474
474
  .dcAgentStep:last-child { border-right: 0; }
475
475
  .dcAgentStep:hover { background: var(--dc-page); }
476
+ .dcAgentStep[data-access="locked"] {
477
+ color: light-dark(#8491a1, #8291a0);
478
+ cursor: not-allowed;
479
+ }
480
+ .dcAgentStep[data-access="locked"]:hover,
481
+ .dcAgentStep[data-access="locked"]:focus-visible {
482
+ background: var(--dc-page);
483
+ color: var(--dc-muted);
484
+ }
476
485
  .dcAgentStep.is-active {
477
486
  color: var(--dc-action);
478
487
  background: var(--dc-selected);
@@ -488,6 +497,7 @@ body:has(.dcAgentPromptBackdrop) { overflow: hidden; }
488
497
  background: var(--dc-action);
489
498
  }
490
499
  .dcAgentStepIcon {
500
+ position: relative;
491
501
  display: grid;
492
502
  place-items: center;
493
503
  width: 30px;
@@ -497,6 +507,30 @@ body:has(.dcAgentPromptBackdrop) { overflow: hidden; }
497
507
  border-radius: 8px;
498
508
  }
499
509
  .dcAgentStepIcon svg { display: block; }
510
+ .dcAgentStep[data-access="locked"] .dcAgentStepIcon::before {
511
+ content: '';
512
+ position: absolute;
513
+ right: -4px;
514
+ bottom: -3px;
515
+ box-sizing: border-box;
516
+ width: 11px;
517
+ height: 9px;
518
+ border: 1px solid var(--dc-border);
519
+ border-radius: 2px;
520
+ background: var(--dc-surface);
521
+ }
522
+ .dcAgentStep[data-access="locked"] .dcAgentStepIcon::after {
523
+ content: '';
524
+ position: absolute;
525
+ right: -2px;
526
+ bottom: 5px;
527
+ box-sizing: border-box;
528
+ width: 7px;
529
+ height: 6px;
530
+ border: 1px solid var(--dc-muted);
531
+ border-bottom: 0;
532
+ border-radius: 5px 5px 0 0;
533
+ }
500
534
  .dcAgentStepLabel {
501
535
  display: block;
502
536
  width: 100%;
@@ -611,6 +645,34 @@ body:has(.dcAgentPromptBackdrop) { overflow: hidden; }
611
645
  color: light-dark(#d2454f, #ff838b);
612
646
  font-size: 12px;
613
647
  }
648
+ .dcAgentNavigationNotice {
649
+ position: relative;
650
+ flex: none;
651
+ padding: 9px 12px 9px 34px;
652
+ border: 1px solid light-dark(#b8dbef, #315a72);
653
+ border-radius: 6px;
654
+ background: var(--dc-table-head);
655
+ color: var(--dc-muted);
656
+ font-size: 12px;
657
+ line-height: 18px;
658
+ }
659
+ .dcAgentNavigationNotice::before {
660
+ content: 'i';
661
+ position: absolute;
662
+ top: 10px;
663
+ left: 12px;
664
+ box-sizing: border-box;
665
+ width: 14px;
666
+ height: 14px;
667
+ display: grid;
668
+ place-items: center;
669
+ border: 1px solid light-dark(#8fc4e4, #4f829f);
670
+ border-radius: 50%;
671
+ color: var(--dc-action);
672
+ font-size: 9px;
673
+ font-weight: 700;
674
+ line-height: 1;
675
+ }
614
676
 
615
677
  .dcAgentChips { display: flex; flex-wrap: wrap; gap: 6px; }
616
678
  .dcAgentChip {
@@ -1133,41 +1195,288 @@ body:has(.dcAgentPromptBackdrop) { overflow: hidden; }
1133
1195
  .dcAgentTable th, .dcAgentTable td { height: 44px; box-sizing: border-box; font-size: 13px; }
1134
1196
  .dcAgentTable tbody tr:hover { background: var(--dc-selected); }
1135
1197
  .dcAgentSection, .dcAgentCard { background: var(--dc-surface); }
1136
- .dcAgentWbBody > .dcAgentSection[aria-label="当前任务进展"] {
1198
+ .dcAgentTaskContext {
1199
+ flex: none;
1200
+ margin: -18px -18px 0;
1201
+ border-bottom: 1px solid var(--dc-border);
1202
+ background: var(--dc-surface);
1203
+ }
1204
+ .dcAgentTaskStatus {
1205
+ position: relative;
1206
+ padding: 11px 18px 10px;
1207
+ border-bottom: 1px solid var(--dc-border);
1208
+ background: var(--dc-table-head);
1209
+ }
1210
+ .dcAgentTaskStatusRow {
1211
+ min-height: 46px;
1137
1212
  display: grid;
1138
- grid-template-columns: minmax(0, 1fr) auto auto;
1213
+ grid-template-columns: minmax(0, 1fr) auto auto auto;
1139
1214
  align-items: center;
1140
- gap: 6px 10px;
1141
- padding: 14px 16px;
1215
+ gap: 10px;
1216
+ color: var(--dc-muted);
1217
+ font-size: 12px;
1142
1218
  }
1143
- .dcAgentWbBody > .dcAgentSection[aria-label="当前任务进展"] > h3 {
1144
- grid-column: 1;
1145
- grid-row: 1;
1219
+ .dcAgentTaskStage,
1220
+ .dcAgentTaskCurrent {
1146
1221
  min-width: 0;
1147
- margin: 0;
1148
- overflow-wrap: anywhere;
1149
- font-size: 14px;
1222
+ display: flex;
1223
+ align-items: center;
1224
+ gap: 7px;
1150
1225
  }
1151
- .dcAgentWbBody > .dcAgentSection[aria-label="当前任务进展"] > b {
1152
- grid-column: 2;
1153
- grid-row: 1;
1154
- padding: 2px 8px;
1155
- border: 1px solid var(--dc-border);
1226
+ .dcAgentTaskDoneMark {
1227
+ box-sizing: border-box;
1228
+ width: 17px;
1229
+ height: 17px;
1230
+ display: grid;
1231
+ place-items: center;
1232
+ flex: none;
1233
+ border: 1px solid light-dark(#91cdb2, #3f7861);
1234
+ border-radius: 50%;
1235
+ color: light-dark(#16845b, #5ed29f);
1236
+ font-size: 11px;
1237
+ line-height: 1;
1238
+ }
1239
+ .dcAgentTaskDoneMark[data-complete="false"] {
1240
+ border-color: light-dark(#9bcceb, #477894);
1241
+ background: var(--dc-selected);
1242
+ color: var(--dc-action);
1243
+ }
1244
+ .dcAgentTaskStageCopy { min-width: 0; }
1245
+ .dcAgentTaskStageTitle {
1246
+ display: flex;
1247
+ align-items: center;
1248
+ gap: 7px;
1249
+ min-width: 0;
1250
+ }
1251
+ .dcAgentTaskStageTitle strong {
1252
+ overflow: hidden;
1253
+ color: var(--dc-text);
1254
+ font-size: 13px;
1255
+ font-weight: 650;
1256
+ text-overflow: ellipsis;
1257
+ white-space: nowrap;
1258
+ }
1259
+ .dcAgentTaskState {
1260
+ flex: none;
1261
+ padding: 1px 6px;
1262
+ border: 1px solid light-dark(#b9ddcc, #37634f);
1156
1263
  border-radius: 4px;
1157
- background: var(--dc-page);
1264
+ background: light-dark(#eaf7f1, #173529);
1265
+ color: light-dark(#16845b, #5ed29f);
1266
+ font-size: 10px;
1267
+ font-weight: 650;
1268
+ white-space: nowrap;
1269
+ }
1270
+ .dcAgentTaskState[data-complete="false"] {
1271
+ border-color: light-dark(#b5d8ef, #426c83);
1272
+ background: var(--dc-selected);
1273
+ color: var(--dc-action);
1274
+ }
1275
+ .dcAgentTaskMeta {
1276
+ display: flex;
1277
+ min-width: 0;
1278
+ margin-top: 2px;
1279
+ overflow: hidden;
1158
1280
  color: var(--dc-muted);
1159
- font-size: 12px;
1160
- font-weight: 600;
1281
+ font-size: 11px;
1282
+ line-height: 16px;
1283
+ white-space: nowrap;
1284
+ }
1285
+ .dcAgentTaskMeta > span {
1286
+ overflow: hidden;
1287
+ text-overflow: ellipsis;
1288
+ }
1289
+ .dcAgentTaskMeta > span + span::before {
1290
+ content: "·";
1291
+ margin: 0 6px;
1292
+ color: var(--dc-border);
1293
+ }
1294
+ .dcAgentTaskCurrent {
1295
+ padding-left: 10px;
1296
+ border-left: 1px solid var(--dc-border);
1297
+ white-space: nowrap;
1298
+ }
1299
+ .dcAgentTaskCurrent span { color: var(--dc-muted); font-size: 11px; }
1300
+ .dcAgentTaskCurrent strong { color: var(--dc-action); font-size: 12px; }
1301
+ .dcAgentTaskReturn {
1302
+ min-height: 28px;
1303
+ padding: 3px 7px;
1304
+ border: 0;
1305
+ border-radius: 5px;
1306
+ background: transparent;
1307
+ color: var(--dc-action);
1308
+ cursor: pointer;
1161
1309
  white-space: nowrap;
1162
1310
  }
1163
- .dcAgentWbBody > .dcAgentSection[aria-label="当前任务进展"] > details {
1164
- grid-column: 3;
1165
- grid-row: 1;
1311
+ .dcAgentTaskReturn:hover { background: var(--dc-selected); }
1312
+ .dcAgentTaskDetails { position: relative; }
1313
+ .dcAgentTaskDetails > summary {
1314
+ box-sizing: border-box;
1315
+ width: 28px;
1316
+ height: 28px;
1317
+ display: grid;
1318
+ place-items: center;
1319
+ border: 1px solid transparent;
1320
+ border-radius: 6px;
1166
1321
  color: var(--dc-muted);
1322
+ cursor: pointer;
1167
1323
  font-size: 12px;
1324
+ font-weight: 700;
1325
+ list-style: none;
1326
+ }
1327
+ .dcAgentTaskDetails > summary::-webkit-details-marker { display: none; }
1328
+ .dcAgentTaskDetails > summary:hover {
1329
+ border-color: var(--dc-border);
1330
+ background: var(--dc-surface);
1331
+ color: var(--dc-action);
1332
+ }
1333
+ .dcAgentTaskDetailsBody {
1334
+ position: absolute;
1335
+ z-index: 6;
1336
+ top: 32px;
1337
+ right: 0;
1338
+ width: min(310px, calc(100vw - 48px));
1339
+ padding: 12px;
1340
+ border: 1px solid var(--dc-border);
1341
+ border-radius: 8px;
1342
+ background: var(--dc-surface);
1343
+ box-shadow: 0 12px 30px light-dark(rgb(26 74 102 / 10%), rgb(0 0 0 / 34%));
1344
+ color: var(--dc-muted);
1345
+ font-size: 12px;
1346
+ }
1347
+ .dcAgentTaskDetailsBody p { margin: 4px 0; overflow-wrap: anywhere; }
1348
+ .dcAgentTaskSyncNote {
1349
+ margin: 6px 0 0 24px;
1350
+ color: var(--dc-muted);
1351
+ font-size: 11px;
1352
+ }
1353
+ .dcAgentTaskExecution {
1354
+ display: grid;
1355
+ grid-template-columns: auto minmax(100px, 1fr);
1356
+ align-items: center;
1357
+ gap: 8px;
1358
+ margin-top: 6px;
1359
+ color: var(--dc-muted);
1360
+ font-size: 11px;
1361
+ }
1362
+ .dcAgentTaskExecution progress { width: 100%; height: 6px; accent-color: var(--dc-action); }
1363
+ .dcAgentTaskMetrics {
1364
+ display: flex;
1365
+ align-items: center;
1366
+ gap: 16px;
1367
+ margin-top: 7px;
1368
+ padding-top: 8px;
1369
+ border-top: 1px solid var(--dc-border);
1370
+ color: var(--dc-muted);
1371
+ font-size: 11px;
1372
+ }
1373
+ .dcAgentTaskMetricGroup {
1374
+ min-width: 0;
1375
+ display: flex;
1376
+ align-items: center;
1377
+ gap: 10px;
1378
+ }
1379
+ .dcAgentTaskMetricGroup + .dcAgentTaskMetricGroup {
1380
+ flex: 1;
1381
+ padding-left: 16px;
1382
+ border-left: 1px solid var(--dc-border);
1383
+ }
1384
+ .dcAgentTaskMetricLabel { flex: none; color: var(--dc-muted); font-weight: 600; }
1385
+ .dcAgentTaskMetric { color: var(--dc-text); white-space: nowrap; }
1386
+ .dcAgentTaskMetric b { margin-left: 3px; font-size: 12px; font-weight: 650; }
1387
+ .dcAgentTaskMetric.is-good,
1388
+ .dcAgentTaskMetric.is-good b { color: light-dark(#16845b, #5ed29f); }
1389
+ .dcAgentWorkflowGuide { background: var(--dc-surface); }
1390
+ .dcAgentWorkflowGuide > summary {
1391
+ box-sizing: border-box;
1392
+ min-height: 40px;
1393
+ display: flex;
1394
+ align-items: center;
1395
+ gap: 7px;
1396
+ padding: 8px 18px;
1397
+ color: var(--dc-muted);
1398
+ font-size: 12px;
1399
+ cursor: pointer;
1400
+ list-style: none;
1401
+ }
1402
+ .dcAgentWorkflowGuide > summary::-webkit-details-marker { display: none; }
1403
+ .dcAgentWorkflowGuide > summary:hover {
1404
+ color: var(--dc-action);
1405
+ background: var(--dc-table-head);
1406
+ }
1407
+ .dcAgentWorkflowGuide > summary::before {
1408
+ content: "?";
1409
+ width: 16px;
1410
+ height: 16px;
1411
+ display: grid;
1412
+ place-items: center;
1413
+ border: 1px solid var(--dc-border);
1414
+ border-radius: 50%;
1415
+ font-size: 10px;
1416
+ }
1417
+ .dcAgentWorkflowGuide > summary::after {
1418
+ content: "›";
1419
+ margin-left: auto;
1420
+ font-size: 17px;
1421
+ transition: transform 120ms ease;
1422
+ }
1423
+ .dcAgentWorkflowGuide[open] > summary::after { transform: rotate(90deg); }
1424
+ .dcAgentWorkflowGuideContent { padding: 0 18px 16px; }
1425
+ .dcAgentWorkflowGuideIntro {
1426
+ margin: 0 0 5px;
1427
+ color: var(--dc-muted);
1428
+ font-size: 12px;
1429
+ line-height: 18px;
1430
+ }
1431
+ .dcAgentWorkflowGuideList {
1432
+ display: grid;
1433
+ grid-template-columns: repeat(2, minmax(0, 1fr));
1434
+ gap: 0 24px;
1435
+ margin-top: 11px;
1436
+ }
1437
+ .dcAgentWorkflowGuideStep {
1438
+ min-width: 0;
1439
+ display: grid;
1440
+ grid-template-columns: auto minmax(0, 1fr);
1441
+ gap: 9px;
1442
+ padding: 10px 0;
1443
+ border-top: 1px solid var(--dc-border);
1444
+ }
1445
+ .dcAgentWorkflowGuideStep:last-child:nth-child(odd) { grid-column: 1 / -1; }
1446
+ .dcAgentWorkflowGuideIndex {
1447
+ box-sizing: border-box;
1448
+ width: 22px;
1449
+ height: 22px;
1450
+ display: grid;
1451
+ place-items: center;
1452
+ border: 1px solid var(--dc-border);
1453
+ border-radius: 50%;
1454
+ background: var(--dc-page);
1455
+ color: var(--dc-action);
1456
+ font-size: 10px;
1457
+ font-weight: 700;
1458
+ }
1459
+ .dcAgentWorkflowGuideStep strong {
1460
+ display: block;
1461
+ margin-bottom: 2px;
1462
+ color: var(--dc-text);
1463
+ font-size: 12px;
1464
+ }
1465
+ .dcAgentWorkflowGuideStep p {
1466
+ margin: 0;
1467
+ color: var(--dc-muted);
1468
+ font-size: 11px;
1469
+ line-height: 17px;
1470
+ }
1471
+ .dcAgentWorkflowGuideBoundary {
1472
+ margin-top: 8px;
1473
+ padding: 9px 10px;
1474
+ border-left: 2px solid var(--dc-action);
1475
+ background: var(--dc-table-head);
1476
+ color: var(--dc-muted);
1477
+ font-size: 11px;
1478
+ line-height: 17px;
1168
1479
  }
1169
- .dcAgentWbBody > .dcAgentSection[aria-label="当前任务进展"] > :is(p, progress) { grid-column: 1 / -1; }
1170
- .dcAgentWbBody > .dcAgentSection[aria-label="当前任务进展"] > p { margin: 0; }
1171
1480
  .dcAgentWbBody > .dcAgentSection[role="status"]:not([aria-label]) {
1172
1481
  min-height: 156px;
1173
1482
  display: flex;
@@ -1222,10 +1531,32 @@ body:has(.dcAgentPromptBackdrop) { overflow: hidden; }
1222
1531
  -webkit-box-orient: vertical;
1223
1532
  -webkit-line-clamp: 2;
1224
1533
  }
1225
- .dcAgentWbBody > .dcAgentSection[aria-label="当前任务进展"] { grid-template-columns: minmax(0, 1fr) auto; }
1226
- .dcAgentWbBody > .dcAgentSection[aria-label="当前任务进展"] > h3 { grid-column: 1 / -1; }
1227
- .dcAgentWbBody > .dcAgentSection[aria-label="当前任务进展"] > b { grid-column: 1; grid-row: 2; justify-self: start; }
1228
- .dcAgentWbBody > .dcAgentSection[aria-label="当前任务进展"] > details { grid-column: 2; grid-row: 2; }
1534
+ .dcAgentTaskStatus { padding-inline: 10px; }
1535
+ .dcAgentTaskStatusRow { grid-template-columns: minmax(0, 1fr) auto; gap: 4px 8px; }
1536
+ .dcAgentTaskStage { grid-column: 1; grid-row: 1; }
1537
+ .dcAgentTaskCurrent {
1538
+ grid-column: 1;
1539
+ grid-row: 2;
1540
+ justify-self: start;
1541
+ margin-left: 24px;
1542
+ padding-left: 0;
1543
+ border-left: 0;
1544
+ }
1545
+ .dcAgentTaskReturn { grid-column: 2; grid-row: 2; }
1546
+ .dcAgentTaskDetails { grid-column: 2; grid-row: 1; justify-self: end; }
1547
+ .dcAgentTaskMetrics { align-items: flex-start; flex-direction: column; gap: 5px; }
1548
+ .dcAgentTaskMetricGroup { width: 100%; gap: 8px; }
1549
+ .dcAgentTaskMetricGroup + .dcAgentTaskMetricGroup {
1550
+ padding-top: 5px;
1551
+ padding-left: 0;
1552
+ border-top: 1px solid var(--dc-border);
1553
+ border-left: 0;
1554
+ }
1555
+ .dcAgentTaskMetricLabel { min-width: 48px; }
1556
+ .dcAgentWorkflowGuide > summary { padding-inline: 10px; }
1557
+ .dcAgentWorkflowGuideContent { padding-inline: 10px; }
1558
+ .dcAgentWorkflowGuideList { grid-template-columns: 1fr; }
1559
+ .dcAgentWorkflowGuideStep:last-child:nth-child(odd) { grid-column: auto; }
1229
1560
  .dcAgentDatasetHeader { display: grid; }
1230
1561
  .dcAgentDatasetHeader .dcAgentHint { text-align: left; }
1231
1562
  .dcAgentDatasetFooter { grid-template-columns: 1fr; }
@@ -1274,19 +1605,104 @@ body:has(.dcAgentPromptBackdrop) { overflow: hidden; }
1274
1605
  return () => { style.remove(); };
1275
1606
  }
1276
1607
 
1277
- // 原始数据不进入 Host storageDomain 或模型上下文;只在当前页面按 taskId 隔离。
1608
+ // 原始数据不进入 Host storageDomain 或模型上下文;仅在当前浏览器页签内按 taskId 隔离。
1609
+ // sessionStorage 只用于刷新恢复,关闭页签后不承诺保留;写入失败时降级为同源重新载入。
1610
+ const RUNTIME_SESSION_CACHE_KEY = 'dsh.data-cleaning-agent.runtime-tasks.v1';
1611
+ const RUNTIME_SESSION_CACHE_LIMIT = 20;
1612
+ const RUNTIME_SESSION_CACHE_MAX_ROWS = 5_000;
1613
+ const RUNTIME_SESSION_CACHE_MAX_CHARS = 2 * 1024 * 1024;
1278
1614
  const runtimeTasks = new Map();
1279
1615
  const workflowTaskBySession = new Map();
1280
1616
  const workflowTaskCreationBySession = new Map();
1281
1617
  const workflowOperationBySession = new Map();
1282
1618
 
1283
- function runtimeFor(taskId, create = true) {
1619
+ function readRuntimeSessionCache() {
1620
+ try {
1621
+ const value = JSON.parse(window.sessionStorage?.getItem(RUNTIME_SESSION_CACHE_KEY) || '{}');
1622
+ return value && typeof value === 'object' && !Array.isArray(value) ? value : {};
1623
+ } catch { return {}; }
1624
+ }
1625
+
1626
+ function runtimeFromSession(taskId, expectedChecksum = '') {
1627
+ const key = String(taskId || 'unassigned');
1628
+ const value = readRuntimeSessionCache()[key];
1629
+ const rows = Array.isArray(value?.rows)
1630
+ ? value.rows.filter((row) => row && typeof row === 'object' && !Array.isArray(row))
1631
+ : [];
1632
+ const checksum = String(value?.source?.checksum || '');
1633
+ if (!rows.length || !expectedChecksum || checksum !== expectedChecksum) return null;
1634
+ return {
1635
+ rows,
1636
+ sourceRows: rows,
1637
+ headers: Array.isArray(value.headers) ? value.headers.map(String) : [],
1638
+ source: value.source && typeof value.source === 'object' ? value.source : null,
1639
+ restoredFromSession: true,
1640
+ lastCsv: { clean: null, complete: null, qcc: null, review: null },
1641
+ resultRows: { clean: null, complete: null, qcc: null },
1642
+ };
1643
+ }
1644
+
1645
+ function persistRuntimeSession(taskId, runtime, removeTaskId = null) {
1646
+ const rows = Array.isArray(runtime?.sourceRows) && runtime.sourceRows.length
1647
+ ? runtime.sourceRows
1648
+ : Array.isArray(runtime?.rows) ? runtime.rows : [];
1649
+ if (!rows.length || rows.length > RUNTIME_SESSION_CACHE_MAX_ROWS
1650
+ || typeof window.sessionStorage?.setItem !== 'function') return false;
1651
+ try {
1652
+ const key = String(taskId || 'unassigned');
1653
+ const cache = readRuntimeSessionCache();
1654
+ if (removeTaskId !== null) delete cache[String(removeTaskId || 'unassigned')];
1655
+ delete cache[key];
1656
+ cache[key] = {
1657
+ rows,
1658
+ headers: Array.isArray(runtime.headers) ? runtime.headers : [],
1659
+ source: runtime.source && typeof runtime.source === 'object' ? runtime.source : null,
1660
+ };
1661
+ const entries = Object.entries(cache).slice(-RUNTIME_SESSION_CACHE_LIMIT);
1662
+ while (entries.length) {
1663
+ const serialized = JSON.stringify(Object.fromEntries(entries));
1664
+ if (serialized.length <= RUNTIME_SESSION_CACHE_MAX_CHARS) {
1665
+ window.sessionStorage?.setItem(RUNTIME_SESSION_CACHE_KEY, serialized);
1666
+ return true;
1667
+ }
1668
+ entries.shift();
1669
+ }
1670
+ return false;
1671
+ } catch { return false; }
1672
+ }
1673
+
1674
+ function removeRuntimeSession(taskId) {
1675
+ try {
1676
+ const key = String(taskId || 'unassigned');
1677
+ const cache = readRuntimeSessionCache();
1678
+ if (!(key in cache)) return;
1679
+ delete cache[key];
1680
+ window.sessionStorage?.setItem(RUNTIME_SESSION_CACHE_KEY, JSON.stringify(cache));
1681
+ } catch {}
1682
+ }
1683
+
1684
+ function removeRuntime(taskId) {
1685
+ runtimeTasks.delete(String(taskId || 'unassigned'));
1686
+ removeRuntimeSession(taskId);
1687
+ }
1688
+
1689
+ function runtimeFor(taskId, create = true, expectedChecksum = '') {
1284
1690
  const key = String(taskId || 'unassigned');
1691
+ const current = runtimeTasks.get(key);
1692
+ if (current && expectedChecksum && current.source?.checksum !== expectedChecksum) {
1693
+ runtimeTasks.delete(key);
1694
+ removeRuntimeSession(key);
1695
+ }
1696
+ if (!runtimeTasks.has(key)) {
1697
+ const restored = runtimeFromSession(key, expectedChecksum);
1698
+ if (restored) runtimeTasks.set(key, restored);
1699
+ }
1285
1700
  if (!runtimeTasks.has(key) && create) {
1286
1701
  runtimeTasks.set(key, {
1287
1702
  rows: [],
1288
1703
  headers: [],
1289
1704
  source: null,
1705
+ restoredFromSession: false,
1290
1706
  lastCsv: { clean: null, complete: null, qcc: null, review: null },
1291
1707
  resultRows: { clean: null, complete: null, qcc: null },
1292
1708
  });
@@ -1302,6 +1718,7 @@ body:has(.dcAgentPromptBackdrop) { overflow: hidden; }
1302
1718
  target.sourceRows = from.sourceRows;
1303
1719
  target.headers = from.headers;
1304
1720
  target.source = from.source;
1721
+ target.restoredFromSession = from.restoredFromSession;
1305
1722
  target.lastCsv = from.lastCsv;
1306
1723
  target.resultRows = from.resultRows;
1307
1724
  runtimeTasks.delete(String(fromId || 'unassigned'));
@@ -1316,6 +1733,14 @@ body:has(.dcAgentPromptBackdrop) { overflow: hidden; }
1316
1733
  { key: 'download', label: '结果下载', icon: 'table' },
1317
1734
  ];
1318
1735
 
1736
+ const WORKFLOW_GUIDE_STEPS = [
1737
+ ['导入与核验', '导入文本、Excel、JSON 或图片名单,并核对完整原始数据。'],
1738
+ ['规则与体检', '确认字段映射、匹配规则和补全范围;质量体检在本地完成。'],
1739
+ ['主体匹配', '优先使用统一社会信用代码等强标识;名称不明确时进入人工候选核验。'],
1740
+ ['字段补全', '只补空值并保留非空原值;外部字段以企查查真实返回为准。'],
1741
+ ['结果下载', 'Host 保存结果、异常清单与任务报告,可在任务历史中继续下载。'],
1742
+ ];
1743
+
1319
1744
  const CAPABILITIES = [
1320
1745
  { key: 'upload', label: '导入名单', icon: IconPaperclipOutline16, fallback: '+' },
1321
1746
  { key: 'profile', label: '质量体检', icon: IconChecklistOutline14, fallback: '✓' },
@@ -1442,7 +1867,12 @@ body:has(.dcAgentPromptBackdrop) { overflow: hidden; }
1442
1867
  ];
1443
1868
  const DEFAULT_CLEANING_KEYS = ['clean_name', 'deduplicate', 'validate_identity', 'complete_fields'];
1444
1869
  const DEFAULT_ENRICHMENT_KEYS = ['credit_no', 'legal_rep', 'reg_capital', 'establish_date', 'reg_status'];
1445
- const DEFAULT_SESSION_PROMPT = '请帮我清洗并补全企业名单。可点击输入框左上角「提示词生成」录入名单、上传 Excel 或图片,也可直接修改本段任务说明后开始。';
1870
+ const DEFAULT_SESSION_PROMPT = '请帮我清洗并补全企业名单。请粘贴企业名称,或上传 Excel / 名单图片,并说明需要补全的字段;也可点击左上角「提示词生成」设置匹配与输出。例如:补全【企业名单】的统一社会信用代码、法定代表人和联系方式,结果导出为 Excel。';
1871
+ const INITIAL_DRAFT_TEMPLATE = Object.freeze({ id: 'dsh-initial-draft/data-cleaning/1', version: 1,
1872
+ fingerprint: 'sha256:2b84b86f669e64e970381e4c7ab8547c77d5e10ee7d5441543c893b9f1272e2b', text: DEFAULT_SESSION_PROMPT });
1873
+ const INITIAL_DRAFT_LEDGER = Symbol.for('dsh.data-cleaning.initial-draft.v1');
1874
+ const compositionState = document[Symbol.for('dsh.data-cleaning.composition.v1')] ||= { active: false };
1875
+ let initialDraftEpoch = 0;
1446
1876
  const CLEANING_SESSION_STORAGE_KEYS = [
1447
1877
  'dsh.data-cleaning-agent.active-session.v2',
1448
1878
  'dsh.data-cleaning-agent.sessions.v1',
@@ -1488,28 +1918,17 @@ body:has(.dcAgentPromptBackdrop) { overflow: hidden; }
1488
1918
  }
1489
1919
 
1490
1920
  function isKnownCleaningDraft(value) {
1491
- if (value === DEFAULT_SESSION_PROMPT) return true;
1492
- if (typeof value !== 'string') return false;
1493
- const wizardDraft = value.startsWith('请执行一项企业名单数据清洗补全任务。')
1494
- && value.includes('企查查连接、套餐额度和费用均由当前用户自己的账号承担。')
1495
- && value.includes('提供结果和待复核清单的导出。');
1496
- const executionDraft = value.startsWith('请执行已在「数据清洗补全工作台」确认的企业数据任务。')
1497
- && value.includes('安全任务凭证:dcq-')
1498
- && value.includes('data_cleaning_qcc_run');
1499
- const imageDraft = value.startsWith('请识别我刚刚在向导中安全暂存的企业名单图片')
1500
- && value.includes('安全图片凭证:dci-')
1501
- && value.includes('data_cleaning_extract_image_companies');
1502
- return wizardDraft || executionDraft || imageDraft;
1921
+ // Exact text is the fingerprint: no trimming, substring or fuzzy recognition.
1922
+ return value === INITIAL_DRAFT_TEMPLATE.text;
1503
1923
  }
1504
1924
 
1505
- function clearCleaningDraft(ctx, sessionId, onlyDefault = false) {
1506
- if (!sessionId) return false;
1507
- const conversation = typeof ctx.get === 'function' ? ctx.get('conversation') : ctx.conversation;
1508
- const shell = conversation?.input?.shell?.(sessionId);
1509
- if (!shell || typeof shell.setDraft !== 'function') return false;
1510
- if (onlyDefault && !isKnownCleaningDraft(shell.snapshot?.draft)) return false;
1511
- shell.setDraft('');
1512
- return true;
1925
+ function wizardListEntries(value) {
1926
+ if (isKnownCleaningDraft(value)) return [];
1927
+ return String(value || '').split(/\r?\n/).map((line) => line.trim()).filter(Boolean);
1928
+ }
1929
+
1930
+ function invalidListInstruction(values) {
1931
+ return values.some((value) => /【[^】]*】|^(?:请|例如[::]|输入来源[::]|结果导出|需要补全)/u.test(value));
1513
1932
  }
1514
1933
 
1515
1934
  function deactivateCleaningSession(sessionId = activeCleaningSessionId) {
@@ -1550,43 +1969,19 @@ body:has(.dcAgentPromptBackdrop) { overflow: hidden; }
1550
1969
  */
1551
1970
  function installSessionOwnershipBridge(ctx) {
1552
1971
  const releaseNewSessionBridge = installNewSessionBridge(ctx);
1553
- let staleDraftObserver = null;
1554
- let staleDraftObserverTimer = null;
1555
- const clearStaleDefaultDraft = () => {
1556
- if (activeCleaningSessionId) return false;
1557
- const currentSessionId = ctx.sessions?.list?.getSnapshot?.()?.current;
1558
- const cleared = clearCleaningDraft(ctx, currentSessionId, true);
1559
- if (cleared && staleDraftObserver) {
1560
- staleDraftObserver.disconnect();
1561
- staleDraftObserver = null;
1562
- }
1563
- return cleared;
1564
- };
1972
+ // Observe composition only; never read/write another product's draft.
1973
+ const compositionStart = () => { compositionState.active = true; };
1974
+ const compositionEnd = () => { compositionState.active = false; };
1975
+ document.addEventListener?.('compositionstart', compositionStart, true);
1976
+ document.addEventListener?.('compositionend', compositionEnd, true);
1565
1977
  const leaveSubsystem = () => {
1566
1978
  const sessionId = activeCleaningSessionId;
1567
- if (sessionId && !ownedSessionId(sessionId)) clearCleaningDraft(ctx, sessionId, true);
1568
1979
  deactivateCleaningSession(sessionId);
1569
1980
  };
1570
1981
  // Only our independently-created native session namespace may regain ownership.
1571
1982
  const initialSession = ctx.sessions?.list?.getSnapshot?.()?.current;
1572
1983
  if (ownedSessionId(initialSession)) markCleaningSession(initialSession);
1573
- clearStaleDefaultDraft();
1574
- // DSH 会在插件 apply() 之后异步恢复 composer draft。在首屏稳定期内
1575
- // 监听 DOM 变化并仅清理完全匹配本插件默认文案,或同时命中
1576
- // 提示词向导固定首尾与费用声明签名的草稿;不触碰用户自写内容。
1577
- if (typeof MutationObserver === 'function' && document.documentElement) {
1578
- staleDraftObserver = new MutationObserver(clearStaleDefaultDraft);
1579
- staleDraftObserver.observe(document.documentElement, {
1580
- childList: true,
1581
- characterData: true,
1582
- subtree: true,
1583
- });
1584
- staleDraftObserverTimer = setTimeout(() => {
1585
- staleDraftObserver?.disconnect();
1586
- staleDraftObserver = null;
1587
- staleDraftObserverTimer = null;
1588
- }, 10_000);
1589
- }
1984
+ // Restore ownership only. Refresh/remount/navigation must never seed or clear drafts.
1590
1985
  const unsubscribe = ctx.sessions?.list?.subscribe?.((snapshot) => {
1591
1986
  // Store subscriptions may signal without a payload; read authoritative state.
1592
1987
  const selected = ctx.sessions?.list?.getSnapshot?.() ?? snapshot;
@@ -1597,14 +1992,13 @@ body:has(.dcAgentPromptBackdrop) { overflow: hidden; }
1597
1992
  }
1598
1993
  if (!activeCleaningSessionId && ownedSessionId(nextSessionId)) {
1599
1994
  markCleaningSession(nextSessionId);
1600
- } else if (!activeCleaningSessionId) {
1601
- clearStaleDefaultDraft();
1602
1995
  }
1603
1996
  });
1604
1997
  return () => {
1998
+ initialDraftEpoch++;
1605
1999
  releaseNewSessionBridge();
1606
- staleDraftObserver?.disconnect();
1607
- if (staleDraftObserverTimer !== null) clearTimeout(staleDraftObserverTimer);
2000
+ document.removeEventListener?.('compositionstart', compositionStart, true);
2001
+ document.removeEventListener?.('compositionend', compositionEnd, true);
1608
2002
  if (typeof unsubscribe === 'function') unsubscribe();
1609
2003
  };
1610
2004
  }
@@ -1952,14 +2346,18 @@ body:has(.dcAgentPromptBackdrop) { overflow: hidden; }
1952
2346
  if (!task?.originSessionId) return '历史版本未保存来源';
1953
2347
  return `工作区:${task.originWorkspaceName || task.originWorkspaceId || '未取得来源工作区'} · 会话:${task.originSessionName || task.originSessionId}`;
1954
2348
  }
1955
- function cacheWorkflowTask(actions, sessionId, task) {
2349
+ function cacheWorkflowTask(actions, sessionId, task, reason = 'update') {
1956
2350
  if (!task) return null;
1957
2351
  if (!taskBelongsToSession(task, sessionId)) throw new Error('此任务不属于当前会话;请在任务历史中只读查看或打开来源会话。');
1958
- const previous = workflowTaskBySession.get(String(sessionId));
1959
- if (previous?.id === task.id && previous.revision > task.revision) return previous;
1960
- if (sessionId) workflowTaskBySession.set(String(sessionId), task);
2352
+ const key = String(sessionId || 'unassigned');
2353
+ const current = workflowTaskBySession.get(key);
2354
+ const explicitSwitch = ['create', 'restore'].includes(reason);
2355
+ if (current && current.id !== task.id && !explicitSwitch) return current;
2356
+ if (current?.id === task.id && Number(current.revision ?? -1) > Number(task.revision ?? -1)) return current;
2357
+ workflowTaskBySession.set(key, task);
1961
2358
  bindTask(sessionId, task.id);
1962
- actions.setWorkflowTask(task);
2359
+ if (typeof actions.acceptWorkflowTask === 'function') actions.acceptWorkflowTask(task, reason);
2360
+ else actions.setWorkflowTask(task);
1963
2361
  if (task.title) actions.setTaskTitle(task.title);
1964
2362
  if (Array.isArray(task.objectives)) actions.setObjectives(task.objectives);
1965
2363
  if (Array.isArray(task.mappings)) actions.setMappings(task.mappings);
@@ -1991,7 +2389,7 @@ body:has(.dcAgentPromptBackdrop) { overflow: hidden; }
1991
2389
  }
1992
2390
  const response = await pending;
1993
2391
  moveRuntime(`session:${key}`, response.task.id);
1994
- return cacheWorkflowTask(actions, sessionId, response.task);
2392
+ return cacheWorkflowTask(actions, sessionId, response.task, 'create');
1995
2393
  }
1996
2394
 
1997
2395
  async function ensureWorkflowTask(actions, sessionId, draft = {}, restoreStage = true) {
@@ -2002,9 +2400,7 @@ body:has(.dcAgentPromptBackdrop) { overflow: hidden; }
2002
2400
  if (ownedSessionId(key) && /^dcw-[a-zA-Z0-9-]+$/.test(saved || '')) {
2003
2401
  const response = await requestJson(`/data-cleaning/api/workflow/tasks/${encodeURIComponent(saved)}`);
2004
2402
  if (taskBelongsToSession(response.task, sessionId)) {
2005
- const task = cacheWorkflowTask(actions, sessionId, response.task);
2006
- if (restoreStage) actions.setStep(task.stage || 'upload');
2007
- return task;
2403
+ return cacheWorkflowTask(actions, sessionId, response.task, restoreStage ? 'restore' : 'update');
2008
2404
  }
2009
2405
  bindTask(sessionId, null);
2010
2406
  }
@@ -2053,7 +2449,7 @@ body:has(.dcAgentPromptBackdrop) { overflow: hidden; }
2053
2449
  return cacheWorkflowTask(actions, sessionId, response.task);
2054
2450
  }
2055
2451
 
2056
- async function workflowAction(actions, sessionId, task, action, input = {}) {
2452
+ async function workflowAction(actions, sessionId, task, action, input = {}, reason = 'update') {
2057
2453
  const latest = workflowTaskBySession.get(String(sessionId || 'unassigned')) ?? task;
2058
2454
  if (!latest) throw new Error('请先创建数据清洗补全任务。');
2059
2455
  if (!taskBelongsToSession(latest, sessionId)) throw new Error('此任务不属于当前会话。');
@@ -2062,7 +2458,33 @@ body:has(.dcAgentPromptBackdrop) { overflow: hidden; }
2062
2458
  originSessionId: sessionId, originWorkspaceId: latest.originWorkspaceId,
2063
2459
  expectedRevision: latest.revision,
2064
2460
  });
2065
- return cacheWorkflowTask(actions, sessionId, response.task);
2461
+ return cacheWorkflowTask(actions, sessionId, response.task, reason);
2462
+ }
2463
+
2464
+ async function verifyRestoredRuntime(actions, sessionId, task, runtime) {
2465
+ if (!runtime?.restoredFromSession) return runtime;
2466
+ if (!task?.id || !task.source?.checksum || !runtime.sourceRows?.length) {
2467
+ if (task?.id) removeRuntime(task.id);
2468
+ actions.setDataset?.(null);
2469
+ throw new Error('刷新缓存缺少同源校验信息,请重新载入原文件或原文本。');
2470
+ }
2471
+ const response = await requestJson(
2472
+ `/data-cleaning/api/workflow/tasks/${encodeURIComponent(task.id)}/verify-source`,
2473
+ 'POST',
2474
+ {
2475
+ originSessionId: sessionId,
2476
+ originWorkspaceId: task.originWorkspaceId,
2477
+ headers: runtime.headers,
2478
+ rows: runtime.sourceRows,
2479
+ },
2480
+ );
2481
+ if (response.verified !== true) {
2482
+ removeRuntime(task.id);
2483
+ actions.setDataset?.(null);
2484
+ throw new Error('刷新缓存与原任务不一致,已停止本次操作。请重新载入原文件或新建任务。');
2485
+ }
2486
+ runtime.restoredFromSession = false;
2487
+ return runtime;
2066
2488
  }
2067
2489
 
2068
2490
  async function stageConfirmedWorkflow(actions, sessionId, expected = null) {
@@ -2086,9 +2508,10 @@ body:has(.dcAgentPromptBackdrop) { overflow: hidden; }
2086
2508
  throw new Error('工作台保存的范围与向导不一致,请重试确认;未生成执行凭证。');
2087
2509
  }
2088
2510
  }
2089
- const runtime = runtimeFor(task.id, false);
2511
+ const runtime = runtimeFor(task.id, false, task.source?.checksum || '');
2090
2512
  if (!task.fieldSelection?.length) throw new Error('当前未选择企查查补全字段。请在规则页选择补全字段,或使用工作台本地清洗与结果导出;不会发起企查查调用。');
2091
2513
  if (!runtime?.rows?.length) throw new Error('Host 名单尚未载入,请先解析数据。');
2514
+ await verifyRestoredRuntime(actions, sessionId, task, runtime);
2092
2515
  if (task.state === 'uploaded') task = await workflowAction(actions, sessionId, task, 'rules', {
2093
2516
  objectives: task.objectives, fieldSelection: task.fieldSelection, mappings: task.mappings, matchRules: task.matchRules,
2094
2517
  });
@@ -2104,7 +2527,7 @@ body:has(.dcAgentPromptBackdrop) { overflow: hidden; }
2104
2527
  return response.command;
2105
2528
  }
2106
2529
 
2107
- async function refreshHostedTask(actions, sessionId, taskId, run, navigate = true) {
2530
+ async function refreshHostedTask(actions, sessionId, taskId, run, reason = 'update') {
2108
2531
  const response = await requestJson(`/data-cleaning/api/workflow/tasks/${encodeURIComponent(taskId)}`);
2109
2532
  if (!taskBelongsToSession(response.task, sessionId)) {
2110
2533
  if (readTaskBindings()[sessionId] === taskId) bindTask(sessionId, null);
@@ -2113,22 +2536,20 @@ body:has(.dcAgentPromptBackdrop) { overflow: hidden; }
2113
2536
  const selectedTask = workflowTaskBySession.get(String(sessionId));
2114
2537
  if (selectedTask && selectedTask.id !== taskId) return response.task;
2115
2538
  if (selectedTask && selectedTask.revision > response.task.revision) return selectedTask;
2116
- workflowTaskBySession.set(String(sessionId), response.task);
2117
- bindTask(sessionId, taskId);
2118
2539
  if (isCleaningSession(sessionId)) {
2119
2540
  if (!run && response.task.qccRunId && ['review_required', 'partial'].includes(response.task.state)) {
2120
2541
  try { run = await requestJson(`/data-cleaning/api/g5/run/${encodeURIComponent(response.task.qccRunId)}`); }
2121
2542
  catch { actions.setError('Host 中的候选或重试数据已失效。已有结果仍可下载;重新查询需新建任务,不会自动重复扣量。'); }
2122
2543
  }
2123
- if (!isCleaningSession(sessionId) || workflowTaskBySession.get(String(sessionId))?.id !== taskId) return response.task;
2124
- cacheWorkflowTask(actions, sessionId, response.task);
2544
+ const latestSelectedTask = workflowTaskBySession.get(String(sessionId));
2545
+ if (!isCleaningSession(sessionId) || (latestSelectedTask && latestSelectedTask.id !== taskId)) return response.task;
2546
+ cacheWorkflowTask(actions, sessionId, response.task, reason);
2125
2547
  if (run) {
2126
2548
  actions.setQccRun(run);
2127
2549
  const runtime = runtimeFor(taskId);
2128
2550
  runtime.resultRows.qcc = run.rows;
2129
2551
  if (!runtime.rows.length) { runtime.rows = run.rows; runtime.headers = response.task.source?.headers || run.headers || []; }
2130
2552
  }
2131
- if (navigate) actions.setStep(response.task.stage || 'upload');
2132
2553
  }
2133
2554
  return response.task;
2134
2555
  }
@@ -2138,7 +2559,7 @@ body:has(.dcAgentPromptBackdrop) { overflow: hidden; }
2138
2559
  const isActive = () => generation === commandWatchGeneration;
2139
2560
  void waitForQccCommand(command.commandId, 1800, async (status) => {
2140
2561
  if (!status || status.state === 'prepared') return;
2141
- await refreshHostedTask(actions, sessionId, command.taskId, null, true).catch(() => {});
2562
+ await refreshHostedTask(actions, sessionId, command.taskId, null, 'update').catch(() => {});
2142
2563
  if (isActive() && workflowTaskBySession.get(String(sessionId))?.id === command.taskId) {
2143
2564
  sessionWorkbenchController?.revealSubmitted(sessionId, command.commandId,
2144
2565
  workflowTaskBySession.get(String(sessionId))?.stage || 'match');
@@ -2203,6 +2624,32 @@ body:has(.dcAgentPromptBackdrop) { overflow: hidden; }
2203
2624
  draft.workflowTask = value ?? null;
2204
2625
  if (value) draft.workflowTasks = [value, ...(draft.workflowTasks || []).filter(task => task.id !== value.id)];
2205
2626
  },
2627
+ acceptWorkflowTask: (draft, value, reason = 'update') => {
2628
+ if (!value) return;
2629
+ const previous = draft.workflowTask;
2630
+ if (previous?.id === value.id && Number(previous.revision ?? -1) > Number(value.revision ?? -1)) return;
2631
+ const previousStage = normalizeViewStage(draft.step);
2632
+ const followedCurrent = previous?.flow?.flowVersion === SUPPORTED_FLOW_VERSION
2633
+ && resolveStageAccess(previousStage, previous.flow) === 'current';
2634
+ draft.workflowTask = value;
2635
+ draft.workflowTasks = [value, ...(draft.workflowTasks || []).filter(task => task.id !== value.id)];
2636
+ if (value.flow?.flowVersion !== SUPPORTED_FLOW_VERSION) {
2637
+ draft.step = value.stage || 'upload';
2638
+ return;
2639
+ }
2640
+ if (draft.step === 'history' && reason !== 'restore') return;
2641
+ if (reason === 'import' && previousStage === 'upload'
2642
+ && resolveStageAccess('upload', value.flow) === 'read'
2643
+ && value.flow.currentStage === 'rules') {
2644
+ draft.step = 'upload';
2645
+ return;
2646
+ }
2647
+ if (!previous || previous.id !== value.id || ['create', 'restore'].includes(reason) || followedCurrent) {
2648
+ draft.step = value.flow.currentStage;
2649
+ return;
2650
+ }
2651
+ if (resolveStageAccess(previousStage, value.flow) === 'locked') draft.step = value.flow.currentStage;
2652
+ },
2206
2653
  setWorkflowTasks: (draft, value) => {
2207
2654
  const cached = new Map((draft.workflowTasks || []).map(task => [task.id, task]));
2208
2655
  draft.workflowTasks = (Array.isArray(value) ? value : []).map(task =>
@@ -2360,13 +2807,45 @@ body:has(.dcAgentPromptBackdrop) { overflow: hidden; }
2360
2807
  return `${CLEANING_SESSION_ID_PREFIX}${globalThis.crypto.randomUUID()}`;
2361
2808
  }
2362
2809
 
2810
+ function emptyInitialInput(snapshot) {
2811
+ // Missing Host capabilities fail closed. draftRev also detects type-then-clear.
2812
+ return snapshot?.phase === 'plain' && snapshot.draft === ''
2813
+ && snapshot.draftRev === 0 && Array.isArray(snapshot.imageIds) && snapshot.imageIds.length === 0
2814
+ && Array.isArray(snapshot.occurrences) && snapshot.occurrences.length === 0;
2815
+ }
2816
+
2817
+ async function initializeCleaningDraft(ctx, sessionId, entry) {
2818
+ const ledger = document[INITIAL_DRAFT_LEDGER] ||= new Map();
2819
+ if (!ownedSessionId(sessionId) || entry?.createdId !== sessionId || ledger.has(sessionId)) return false;
2820
+ // Reserve before yielding: no duplicate initialization, even on plugin remount.
2821
+ ledger.set(sessionId, { templateId: INITIAL_DRAFT_TEMPLATE.id, state: 'skipped' });
2822
+ const conversation = typeof ctx.get === 'function' ? ctx.get('conversation') : ctx.conversation;
2823
+ const shell = conversation?.input?.shell?.(sessionId);
2824
+ const eligible = () => !compositionState.active && !entry.interrupted()
2825
+ && ctx.sessions.list.getSnapshot().current === entry.previousSession
2826
+ && typeof shell?.setDraft === 'function' && typeof shell.state?.subscribe === 'function'
2827
+ && emptyInitialInput(shell.snapshot);
2828
+ if (!eligible()) return false;
2829
+ const revision = shell.snapshot.draftRev;
2830
+ let changed = false;
2831
+ const release = shell.state?.subscribe?.(() => { if (!emptyInitialInput(shell.snapshot)) changed = true; });
2832
+ try {
2833
+ await Promise.resolve();
2834
+ // Authoritative second snapshot, not captured render props. No await between CAS and write.
2835
+ if (changed || !eligible() || shell.snapshot.draftRev !== revision) return false;
2836
+ shell.setDraft(INITIAL_DRAFT_TEMPLATE.text);
2837
+ const accepted = shell.snapshot?.draft === INITIAL_DRAFT_TEMPLATE.text;
2838
+ ledger.set(sessionId, { templateId: INITIAL_DRAFT_TEMPLATE.id, state: accepted ? 'initialized' : 'skipped' });
2839
+ return accepted;
2840
+ } finally { if (typeof release === 'function') release(); }
2841
+ }
2842
+
2363
2843
  /** 使用 DSH 原生工作区、会话和输入机打开中央对话,不构造第二套聊天界面。 */
2364
2844
  async function startCleaningSession(ctx) {
2365
2845
  const workspace = ctx.workspaces.list.getSnapshot();
2366
2846
  const current = ctx.sessions.list.getSnapshot().current;
2367
2847
  if (ownedSessionId(current)) {
2368
2848
  markCleaningSession(current);
2369
- requestWorkbenchOpen(workflowTaskBySession.get(String(current))?.stage || 'upload', current);
2370
2849
  return current;
2371
2850
  }
2372
2851
  const items = Array.isArray(workspace.items) ? workspace.items : [];
@@ -2387,16 +2866,34 @@ body:has(.dcAgentPromptBackdrop) { overflow: hidden; }
2387
2866
  throw new Error('当前 DSH 版本没有可用的独立会话创建能力');
2388
2867
  }
2389
2868
  const requestedId = createCleaningSessionId();
2390
- const sessionId = await ctx.sessions.create({ workspaceId: targetWorkspace.workspaceId, sessionId: requestedId });
2391
- if (sessionId !== requestedId) {
2392
- throw new Error('当前 DSH 版本不支持独立会话创建,请升级 DSH 后再打开数据清洗补全智能体');
2869
+ const entryEpoch = initialDraftEpoch;
2870
+ let interrupted = compositionState.active;
2871
+ const interrupt = () => { interrupted = true; };
2872
+ const events = ['beforeinput', 'input', 'paste', 'drop', 'compositionstart'];
2873
+ for (const event of events) document.addEventListener?.(event, interrupt, true);
2874
+ const stopNavigation = ctx.sessions.list.subscribe?.(() => {
2875
+ if (ctx.sessions.list.getSnapshot().current !== current) interrupt();
2876
+ });
2877
+ let sessionId;
2878
+ try {
2879
+ sessionId = await ctx.sessions.create({ workspaceId: targetWorkspace.workspaceId, sessionId: requestedId });
2880
+ if (sessionId !== requestedId) {
2881
+ throw new Error('当前 DSH 版本不支持独立会话创建,请升级 DSH 后再打开数据清洗补全智能体');
2882
+ }
2883
+ await initializeCleaningDraft(ctx, sessionId, {
2884
+ createdId: requestedId, previousSession: current,
2885
+ interrupted: () => interrupted || entryEpoch !== initialDraftEpoch,
2886
+ });
2887
+ // A late create must not bring the user back from another Session/product.
2888
+ if (!interrupted && entryEpoch === initialDraftEpoch && ctx.sessions.list.getSnapshot().current === current) {
2889
+ ctx.sessions.open(sessionId);
2890
+ markCleaningSession(sessionId);
2891
+ }
2892
+ return sessionId;
2893
+ } finally {
2894
+ if (typeof stopNavigation === 'function') stopNavigation();
2895
+ for (const event of events) document.removeEventListener?.(event, interrupt, true);
2393
2896
  }
2394
- const conversation = typeof ctx.get === 'function' ? ctx.get('conversation') : ctx.conversation;
2395
- if (!conversation) throw new Error('DSH 对话服务尚未就绪,请稍后重试');
2396
- conversation.input.shell(sessionId).setDraft(DEFAULT_SESSION_PROMPT);
2397
- ctx.sessions.open(sessionId);
2398
- markCleaningSession(sessionId);
2399
- return sessionId;
2400
2897
  }
2401
2898
 
2402
2899
  /**
@@ -2524,8 +3021,11 @@ body:has(.dcAgentPromptBackdrop) { overflow: hidden; }
2524
3021
  }
2525
3022
 
2526
3023
  function openWorkbench(actions, step, sessionId) {
2527
- actions.openAt(step, sessionId);
2528
- sessionWorkbenchController?.open(sessionId, step);
3024
+ if (sessionWorkbenchController) sessionWorkbenchController.open(sessionId, step);
3025
+ else {
3026
+ const destination = navigateWorkflow(actions, sessionId, step);
3027
+ actions.openAt(destination.stage, sessionId);
3028
+ }
2529
3029
  }
2530
3030
 
2531
3031
  const WORKBENCH_OPEN_EVENT = 'dsh:data-cleaning-workbench-open';
@@ -2677,8 +3177,68 @@ body:has(.dcAgentPromptBackdrop) { overflow: hidden; }
2677
3177
  authorization_required: '待连接企查查', cancelled: '已取消', parse_failed: '解析失败',
2678
3178
  };
2679
3179
 
3180
+ const SUPPORTED_FLOW_VERSION = 1;
3181
+ function normalizeViewStage(stage) {
3182
+ return stage === 'profile' ? 'rules' : stage;
3183
+ }
3184
+ function resolveStageAccess(stage, flow) {
3185
+ const normalized = normalizeViewStage(stage);
3186
+ if (flow?.flowVersion !== SUPPORTED_FLOW_VERSION) return 'locked';
3187
+ return flow.stageAccess?.[normalized] || 'locked';
3188
+ }
3189
+ function workflowActionAllowed(task, action) {
3190
+ return task?.flow?.flowVersion === SUPPORTED_FLOW_VERSION
3191
+ && Array.isArray(task.flow.allowedActions)
3192
+ && task.flow.allowedActions.includes(action);
3193
+ }
3194
+
3195
+ function workflowDestination(requestedStage, task, currentView = 'upload') {
3196
+ if (requestedStage === 'history') return { stage: 'history', access: 'read' };
3197
+ if (!task) {
3198
+ return requestedStage === 'upload' || !requestedStage
3199
+ ? { stage: 'upload', access: 'current' }
3200
+ : { stage: 'upload', access: 'locked' };
3201
+ }
3202
+ if (task.flow?.flowVersion !== SUPPORTED_FLOW_VERSION) {
3203
+ return { stage: task.stage || 'upload', access: 'read', compatibility: true };
3204
+ }
3205
+ const requested = requestedStage || currentView || task.flow.currentStage;
3206
+ const access = resolveStageAccess(requested, task.flow);
3207
+ if (access === 'locked') return { stage: task.flow.currentStage, access };
3208
+ return { stage: requested, access };
3209
+ }
3210
+
3211
+ function workflowStageLabel(stage) {
3212
+ const normalized = normalizeViewStage(stage);
3213
+ return STEPS.find((item) => item.key === normalized)?.label ?? normalized ?? '当前节点';
3214
+ }
3215
+
3216
+ function lockedWorkflowNotice(requestedStage, task, fallbackStage = 'upload') {
3217
+ const currentStage = task?.flow?.currentStage || task?.stage || fallbackStage;
3218
+ return `流程提示:“${workflowStageLabel(requestedStage)}”尚未开放,请先完成“${workflowStageLabel(currentStage)}”。`;
3219
+ }
3220
+
3221
+ function navigateWorkflow(actions, sessionId, requestedStage, task) {
3222
+ const current = task ?? workflowTaskBySession.get(String(sessionId || 'unassigned')) ?? null;
3223
+ const destination = workflowDestination(requestedStage, current);
3224
+ actions.setStep(destination.stage);
3225
+ if (destination.compatibility) {
3226
+ actions.setError('页面与 Host 流程版本不一致,当前任务已切换为只读模式;请完整重启并升级 Host。');
3227
+ } else if (destination.access === 'locked') {
3228
+ actions.setError(lockedWorkflowNotice(requestedStage, current, destination.stage));
3229
+ } else {
3230
+ actions.setError(null);
3231
+ }
3232
+ return destination;
3233
+ }
3234
+
2680
3235
  function workflowNavigationIssue(step, task, hasData, profile, qccRun, hasLocalResult = false) {
2681
- if (['upload', 'history'].includes(step)) return null;
3236
+ if (step === 'history') return null;
3237
+ if (task?.flow?.flowVersion === SUPPORTED_FLOW_VERSION) {
3238
+ const access = resolveStageAccess(step, task.flow);
3239
+ return access === 'locked' ? '该步骤尚未开放,请先完成当前流程节点。' : null;
3240
+ }
3241
+ if (step === 'upload') return null;
2682
3242
  if (step === 'download' && task?.artifacts?.length) return null;
2683
3243
  if (!hasData) return '请先在「导入与核验」载入并核对完整名单。';
2684
3244
  if (step === 'rules') return null;
@@ -2957,6 +3517,10 @@ body:has(.dcAgentPromptBackdrop) { overflow: hidden; }
2957
3517
  return rows.map((row) => {
2958
3518
  const name = nameField ? String(row?.[nameField] ?? '').trim() : '';
2959
3519
  const credit = creditField ? String(row?.[creditField] ?? '').trim() : '';
3520
+ if (isKnownCleaningDraft(name)) return '';
3521
+ if (invalidListInstruction([name, credit])) {
3522
+ throw new Error('名单包含占位符、示例或任务说明,请提供实际企业名称或信用代码后再继续。');
3523
+ }
2960
3524
  return [name, credit].filter(Boolean).join(' | ');
2961
3525
  }).filter(Boolean).slice(0, 50);
2962
3526
  }
@@ -3037,6 +3601,10 @@ body:has(.dcAgentPromptBackdrop) { overflow: hidden; }
3037
3601
  });
3038
3602
  const [busy, setBusy] = react.useState(false);
3039
3603
  const [error, setError] = react.useState(null);
3604
+ const [draftPolicy, setDraftPolicy] = react.useState('cancel');
3605
+ const lastWizardDraft = react.useRef(null);
3606
+
3607
+ react.useEffect(() => { setDraftPolicy('cancel'); lastWizardDraft.current = null; }, [sessionId]);
3040
3608
 
3041
3609
  react.useEffect(() => {
3042
3610
  const commandId = imageCommand?.commandId;
@@ -3053,7 +3621,7 @@ body:has(.dcAgentPromptBackdrop) { overflow: hidden; }
3053
3621
  if (command.taskId) {
3054
3622
  const actions = sessionWorkbenchController?.actionsFor(sessionId);
3055
3623
  if (actions) {
3056
- await refreshHostedTask(actions, sessionId, command.taskId, null, true);
3624
+ await refreshHostedTask(actions, sessionId, command.taskId, null, 'update');
3057
3625
  if (command.state === 'running') requestWorkbenchOpen(command.qccCommand ? 'match' : 'upload', sessionId);
3058
3626
  if (command.qccCommand) {
3059
3627
  const status = await requestJson(`/data-cleaning/api/g5/commands/${encodeURIComponent(command.qccCommand.commandId)}`);
@@ -3197,11 +3765,12 @@ body:has(.dcAgentPromptBackdrop) { overflow: hidden; }
3197
3765
  }
3198
3766
  };
3199
3767
  const selectedEntries = () => mode === 'text'
3200
- ? rawText.split(/\r?\n/).map((line) => line.trim()).filter(Boolean)
3768
+ ? wizardListEntries(rawText)
3201
3769
  : entries;
3202
3770
  const validateSource = () => {
3203
3771
  const values = selectedEntries();
3204
3772
  if (mode === 'text' && !values.length) return '请先录入企业名称或统一社会信用代码,每行一条。';
3773
+ if (mode === 'text' && invalidListInstruction(values)) return '请提供实际企业名单,每行一个名称或信用代码;请勿将【企业名单】、示例或任务说明作为企业。';
3205
3774
  if (mode === 'text' && values.some(isImagePathEntry)) return '检测到图片文件路径,不是企业名单。请切换「上传图片识别」,重新选择或粘贴图片。';
3206
3775
  if (mode === 'excel' && !values.length) return '请先上传并解析 Excel / CSV / JSON 文件。';
3207
3776
  if (mode === 'image' && !imageCommand) return '请先选择、粘贴或拖入一张包含企业名单的图片。';
@@ -3233,40 +3802,64 @@ body:has(.dcAgentPromptBackdrop) { overflow: hidden; }
3233
3802
  return;
3234
3803
  }
3235
3804
  const inputSnapshot = getInputSnapshot?.(sessionId);
3236
- if (['adjudicating', 'submitting'].includes(inputSnapshot?.phase)) {
3805
+ if (!inputSnapshot || inputSnapshot.phase !== 'plain' || compositionState.active) {
3237
3806
  setError('当前对话正在提交,请等待完成后再回填。图片与字段选择已保留。');
3238
3807
  return;
3239
3808
  }
3809
+ const unchangedWizard = lastWizardDraft.current?.sessionId === sessionId
3810
+ && lastWizardDraft.current.draft === inputSnapshot.draft;
3811
+ const userDraft = inputSnapshot.draft && !isKnownCleaningDraft(inputSnapshot.draft) && !unchangedWizard;
3812
+ if (userDraft && draftPolicy === 'cancel') {
3813
+ setError('已保留当前草稿。请选择“替换”或“追加”后回填,或关闭向导取消。'); return;
3814
+ }
3815
+ const prefix = unchangedWizard ? lastWizardDraft.current.prefix
3816
+ : userDraft && draftPolicy === 'append' ? inputSnapshot.draft + '\n\n' : '';
3240
3817
  const config = promptConfig();
3241
3818
  if (mode === 'image' && imageCommand?.state === 'prepared' && imageAttachment && typeof removeImage === 'function') {
3242
3819
  // qcc-document-mcp 只读取 Host 暂存文件;发送前释放原生附件,文本模型不会收到图片。
3243
3820
  removeImage(sessionId, imageAttachment);
3244
3821
  setImageAttachment(null);
3245
3822
  }
3246
- if (mode === 'text') {
3247
- requestWorkbenchDataset(entriesToDataset(config.entries), sessionId, { type: 'text', fileName: '' });
3248
- } else if (mode === 'excel' && spreadsheetData) {
3249
- requestWorkbenchDataset(spreadsheetData, sessionId, { type: spreadsheetData.fmt || 'xlsx', fileName: spreadsheetFileName });
3250
- }
3251
- requestWorkbenchDraft({
3252
- title: `企业数据清洗补全任务 · ${config.entryCount || '待解析'} 条`,
3253
- objectives: cleaningKeys,
3254
- fieldSelection: enrichmentKeys,
3255
- matchRules,
3256
- ...(mode === 'excel' ? { mappings: spreadsheetMappings } : {}),
3257
- source: { type: mode === 'excel' ? 'xlsx' : mode, fileName: config.fileName || '', rowCount: config.entryCount || 0 },
3258
- }, sessionId);
3823
+ const attachmentSnapshot = JSON.stringify({imageIds:getInputSnapshot?.(sessionId)?.imageIds,
3824
+ occurrences:getInputSnapshot?.(sessionId)?.occurrences});
3259
3825
  setBusy(true);
3260
3826
  try {
3827
+ if (mode === 'text') {
3828
+ const local = entriesToDataset(config.entries);
3829
+ const verified = await api('/data-cleaning/api/mvp/parse', {
3830
+ filename: 'data.json',
3831
+ content: JSON.stringify(local.rows),
3832
+ });
3833
+ requestWorkbenchDataset({ ...verified, fmt: 'text' }, sessionId, { type: 'text', fileName: '' });
3834
+ } else if (mode === 'excel' && spreadsheetData) {
3835
+ requestWorkbenchDataset(spreadsheetData, sessionId, { type: spreadsheetData.fmt || 'xlsx', fileName: spreadsheetFileName });
3836
+ }
3837
+ requestWorkbenchDraft({
3838
+ title: `企业数据清洗补全任务 · ${config.entryCount || '待解析'} 条`,
3839
+ objectives: cleaningKeys,
3840
+ fieldSelection: enrichmentKeys,
3841
+ matchRules,
3842
+ ...(mode === 'excel' ? { mappings: spreadsheetMappings } : {}),
3843
+ source: { type: mode === 'excel' ? 'xlsx' : mode, fileName: config.fileName || '', rowCount: config.entryCount || 0 },
3844
+ }, sessionId);
3261
3845
  let prompt = buildTaskPrompt(config);
3262
3846
  if (mode !== 'image' || ['prepared', 'completed'].includes(imageCommand?.state)) {
3263
3847
  const command = await requestPreparedWorkflow(sessionId, config);
3264
3848
  prompt = mode === 'image' && imageCommand?.state === 'prepared' ? command.prompt : command.prompt + '\n\n' + buildTaskPrompt(config);
3265
3849
  if (mode === 'image' && imageCommand?.state === 'prepared') setImageCommand(command);
3266
3850
  }
3851
+ const latest = getInputSnapshot?.(sessionId);
3852
+ if (!isCleaningSession(sessionId) || compositionState.active || latest?.phase !== 'plain'
3853
+ || latest.draft !== inputSnapshot.draft || latest.draftRev !== inputSnapshot.draftRev
3854
+ || JSON.stringify({imageIds:latest.imageIds,occurrences:latest.occurrences}) !== attachmentSnapshot) {
3855
+ throw new Error('草稿或会话已变化,未覆盖您的内容;请确认后再次回填。');
3856
+ }
3857
+ prompt = prefix + prompt;
3267
3858
  inputActions.setDraft(prompt);
3268
3859
  const updatedInput = getInputSnapshot?.(sessionId);
3269
3860
  if (updatedInput && updatedInput.draft !== prompt) throw new Error('当前对话尚未接收任务说明,请稍后重试回填。');
3861
+ lastWizardDraft.current = {sessionId,draft:prompt,prefix};
3862
+ setDraftPolicy('cancel');
3270
3863
  } catch (draftError) {
3271
3864
  setError(draftError instanceof Error ? draftError.message : String(draftError));
3272
3865
  return;
@@ -3338,12 +3931,14 @@ body:has(.dcAgentPromptBackdrop) { overflow: hidden; }
3338
3931
  const receiveNewTask = (event) => {
3339
3932
  if (event.detail?.sessionId !== sessionId || busy || imageCommand?.state === 'running') return;
3340
3933
  event.preventDefault();
3934
+ const reused = event.detail?.reuseConfig || null;
3341
3935
  discardCurrentImage();
3342
3936
  setMode('text'); setRawText(''); setEntries([]); setEntryCount(0); setSpreadsheetFileName('');
3343
3937
  setSpreadsheetData(null); setSpreadsheetMappings([]);
3344
- setCleaningKeys(DEFAULT_CLEANING_KEYS); setEnrichmentKeys(DEFAULT_ENRICHMENT_KEYS);
3938
+ setCleaningKeys(reused?.objectives || DEFAULT_CLEANING_KEYS);
3939
+ setEnrichmentKeys(reused?.fieldSelection || DEFAULT_ENRICHMENT_KEYS);
3345
3940
  setAnchorKeys(['company_name', 'credit_no']);
3346
- setPromptMatchRules({ normalizeNames: true, preferCreditNo: true, deduplicate: true, manualReviewAmbiguous: true });
3941
+ setPromptMatchRules(reused?.matchRules || { normalizeNames: true, preferCreditNo: true, deduplicate: true, manualReviewAmbiguous: true });
3347
3942
  setWizardStep(1); setOpen(true); setError(null);
3348
3943
  };
3349
3944
  const receiveImage = (event) => {
@@ -3555,6 +4150,11 @@ body:has(.dcAgentPromptBackdrop) { overflow: hidden; }
3555
4150
  wizardStep === 4 ? h('div', { className: 'dcAgentWizardPane' },
3556
4151
  h('h4', null, '确认任务描述'),
3557
4152
  h('p', null, '回填后仍可在对话框继续编辑;回填本身不会调用企查查 MCP。'),
4153
+ h('label', null, '已有草稿时的回填方式 ',
4154
+ h('select', { 'aria-label':'已有草稿时的回填方式', value:draftPolicy, onChange:event=>setDraftPolicy(event.target.value) },
4155
+ h('option', {value:'cancel'}, '保留草稿(取消回填)'),
4156
+ h('option', {value:'replace'}, '替换已有草稿'),
4157
+ h('option', {value:'append'}, '追加到已有草稿'))),
3558
4158
  h('pre', { className: 'dcAgentPromptPreview' }, buildTaskPrompt(promptConfig())),
3559
4159
  ) : null,
3560
4160
  h('p', { className: 'dcAgentPromptNote' }, '向导只生成任务描述并保存任务设置。只有你在任务中明确确认后,才会使用当前客户自己的企查查 MCP 连接、账号和额度。'),
@@ -3681,7 +4281,7 @@ body:has(.dcAgentPromptBackdrop) { overflow: hidden; }
3681
4281
  }
3682
4282
 
3683
4283
  /** 上传 pane 的处理:文件 → 文本/base64 → parse。 */
3684
- async function parseFile(file) {
4284
+ async function parseFile(file, previousChecksum) {
3685
4285
  const isXlsx = /\.(xlsx|xls)$/i.test(file && file.name ? file.name : '');
3686
4286
  let content;
3687
4287
  if (isXlsx) {
@@ -3693,7 +4293,7 @@ body:has(.dcAgentPromptBackdrop) { overflow: hidden; }
3693
4293
  } else {
3694
4294
  content = await file.text();
3695
4295
  }
3696
- return api('/data-cleaning/api/mvp/parse', { filename: file.name, content });
4296
+ return api('/data-cleaning/api/mvp/parse', { filename: file.name, content, previousChecksum });
3697
4297
  }
3698
4298
 
3699
4299
  /** 文本/JSON 直通 parse(与 web.js 内联页同构,含 JSON 数组直通)。 */
@@ -3708,7 +4308,7 @@ body:has(.dcAgentPromptBackdrop) { overflow: hidden; }
3708
4308
  };
3709
4309
  }
3710
4310
 
3711
- async function parseText(text, actions) {
4311
+ async function parseText(text, actions, previousChecksum) {
3712
4312
  const trimmed = (text ?? '').trim();
3713
4313
  if (!trimmed) {
3714
4314
  actions.setError('请粘贴或上传数据后再解析。');
@@ -3716,18 +4316,26 @@ body:has(.dcAgentPromptBackdrop) { overflow: hidden; }
3716
4316
  }
3717
4317
  if (trimmed.startsWith('[')) {
3718
4318
  try {
3719
- const rows = JSON.parse(trimmed);
3720
- const list = Array.isArray(rows) ? rows : [];
3721
- const headers = list.length ? Object.keys(list[0]) : [];
3722
- return { ok: true, fmt: 'json', headers, rowCount: list.length, rows: list, preview: list.slice(0, 5) };
4319
+ return api('/data-cleaning/api/mvp/parse', {
4320
+ filename: 'data.json',
4321
+ content: trimmed,
4322
+ previousChecksum,
4323
+ });
3723
4324
  } catch (error) {
3724
4325
  actions.setError(`JSON 解析失败:${error instanceof Error ? error.message : String(error)}`);
3725
4326
  }
3726
4327
  return;
3727
4328
  }
3728
4329
  const entityList = plainEntityListDataset(trimmed);
3729
- if (entityList) return entityList;
3730
- return api('/data-cleaning/api/mvp/parse', { filename: 'data.csv', content: trimmed });
4330
+ if (entityList) {
4331
+ const parsed = await api('/data-cleaning/api/mvp/parse', {
4332
+ filename: 'data.json',
4333
+ content: JSON.stringify(entityList.rows),
4334
+ previousChecksum,
4335
+ });
4336
+ return { ...parsed, fmt: 'text' };
4337
+ }
4338
+ return api('/data-cleaning/api/mvp/parse', { filename: 'data.csv', content: trimmed, previousChecksum });
3731
4339
  }
3732
4340
 
3733
4341
  /** 解析成功进入 taskId runtime;不把整表写入 Host store。 */
@@ -3740,7 +4348,9 @@ body:has(.dcAgentPromptBackdrop) { overflow: hidden; }
3740
4348
  type: source.type || result.fmt || 'csv',
3741
4349
  fileName: source.fileName || '',
3742
4350
  sizeBytes: source.sizeBytes || 0,
4351
+ checksum: result.checksum || source.checksum || '',
3743
4352
  };
4353
+ runtime.restoredFromSession = false;
3744
4354
  const mappings = guessMappings(runtime.headers);
3745
4355
  const guessedNameField = mappings.find((mapping) => mapping.targetField === 'company_name')?.sourceField
3746
4356
  ?? mappings.find((mapping) => mapping.targetField === 'credit_no')?.sourceField
@@ -3759,6 +4369,30 @@ body:has(.dcAgentPromptBackdrop) { overflow: hidden; }
3759
4369
  return runtime;
3760
4370
  }
3761
4371
 
4372
+ function restoreParsedRuntime(result, actions, task, source = {}) {
4373
+ const runtime = runtimeFor(task.id);
4374
+ runtime.rows = Array.isArray(result.rows) ? result.rows : [];
4375
+ runtime.sourceRows = runtime.rows;
4376
+ runtime.headers = Array.isArray(result.headers) ? result.headers : [];
4377
+ runtime.source = {
4378
+ type: source.type || result.fmt || task.source?.type || 'csv',
4379
+ fileName: source.fileName || task.source?.fileName || '',
4380
+ sizeBytes: source.sizeBytes || task.source?.sizeBytes || 0,
4381
+ checksum: result.checksum || '',
4382
+ };
4383
+ runtime.restoredFromSession = false;
4384
+ actions.setDataset({
4385
+ fmt: result.fmt ?? task.source?.type ?? 'csv',
4386
+ headers: runtime.headers,
4387
+ rowCount: typeof result.rowCount === 'number' ? result.rowCount : runtime.rows.length,
4388
+ preview: Array.isArray(result.preview) ? result.preview : runtime.rows.slice(0, 5),
4389
+ });
4390
+ const primary = (task.mappings || []).find((mapping) => ['company_name', 'credit_no', 'reg_no'].includes(mapping.targetField));
4391
+ if (primary) actions.setNameField(primary.sourceField);
4392
+ persistRuntimeSession(task.id, runtime);
4393
+ return runtime;
4394
+ }
4395
+
3762
4396
  async function persistParsedWorkflow(actions, sessionId, result, source = {}, task) {
3763
4397
  const importedMappings = guessMappings(result.headers || []);
3764
4398
  const importedDraft = { mappings: importedMappings,
@@ -3772,11 +4406,13 @@ body:has(.dcAgentPromptBackdrop) { overflow: hidden; }
3772
4406
  target.rows = Array.isArray(result.rows) ? result.rows : [];
3773
4407
  target.sourceRows = target.rows;
3774
4408
  target.headers = Array.isArray(result.headers) ? result.headers : [];
3775
- target.source = {
3776
- type: source.type || result.fmt || 'csv',
3777
- fileName: source.fileName || '',
3778
- sizeBytes: source.sizeBytes || 0,
4409
+ target.source = {
4410
+ type: source.type || result.fmt || 'csv',
4411
+ fileName: source.fileName || '',
4412
+ sizeBytes: source.sizeBytes || 0,
4413
+ checksum: result.checksum || source.checksum || '',
3779
4414
  };
4415
+ target.restoredFromSession = false;
3780
4416
  if (['draft', 'uploaded', 'parse_failed'].includes(current.state)) {
3781
4417
  current = await workflowAction(actions, sessionId, current, 'upload', {
3782
4418
  source: {
@@ -3786,10 +4422,12 @@ body:has(.dcAgentPromptBackdrop) { overflow: hidden; }
3786
4422
  columnCount: Array.isArray(result.headers) ? result.headers.length : 0,
3787
4423
  headers: Array.isArray(result.headers) ? result.headers : [],
3788
4424
  sizeBytes: source.sizeBytes || 0,
4425
+ checksum: result.checksum || source.checksum || '',
3789
4426
  },
3790
- });
4427
+ }, 'import');
3791
4428
  }
3792
4429
  if (current.state === 'uploaded') current = await updateWorkflowTask(actions, sessionId, current, importedDraft);
4430
+ persistRuntimeSession(current.id, target, stagingKey);
3793
4431
  return current;
3794
4432
  }
3795
4433
 
@@ -3858,6 +4496,103 @@ body:has(.dcAgentPromptBackdrop) { overflow: hidden; }
3858
4496
  );
3859
4497
  }
3860
4498
 
4499
+ function WorkflowTaskContext(props) {
4500
+ const stageCompleted = props.stageCompleted;
4501
+ const progressVisible = props.commandProgress?.taskId === props.task.id
4502
+ && Number.isFinite(props.commandProgress.totalUnique);
4503
+ const completedUnique = Math.min(
4504
+ props.commandProgress?.totalUnique || 0,
4505
+ Math.max(0, props.commandProgress?.completedUnique || 0),
4506
+ );
4507
+ return h('div', { className: 'dcAgentTaskStatus', 'aria-live': 'polite' },
4508
+ h('div', { className: 'dcAgentTaskStatusRow' },
4509
+ h('div', { className: 'dcAgentTaskStage' },
4510
+ h('span', {
4511
+ className: 'dcAgentTaskDoneMark',
4512
+ 'data-complete': stageCompleted,
4513
+ 'aria-hidden': true,
4514
+ }, stageCompleted ? '✓' : '•'),
4515
+ h('div', { className: 'dcAgentTaskStageCopy' },
4516
+ h('div', { className: 'dcAgentTaskStageTitle' },
4517
+ h('strong', null, props.stageLabel),
4518
+ h('span', {
4519
+ className: 'dcAgentTaskState',
4520
+ 'data-complete': stageCompleted,
4521
+ }, props.statusLabel),
4522
+ ),
4523
+ props.timeLabel || props.sourceLabel ? h('div', { className: 'dcAgentTaskMeta' },
4524
+ props.timeLabel ? h('span', null,
4525
+ h('time', { dateTime: props.timeValue || undefined }, props.timeLabel)) : null,
4526
+ props.sourceLabel ? h('span', null, props.sourceLabel) : null,
4527
+ ) : null,
4528
+ ),
4529
+ ),
4530
+ props.viewAccess === 'read' ? h('div', { className: 'dcAgentTaskCurrent' },
4531
+ h('span', null, '当前节点'),
4532
+ h('strong', null, props.currentStageLabel),
4533
+ ) : null,
4534
+ props.viewAccess === 'read' && props.onReturn ? h('button', {
4535
+ type: 'button',
4536
+ className: 'dcAgentTaskReturn',
4537
+ onClick: props.onReturn,
4538
+ }, '回到当前节点') : null,
4539
+ h('details', { className: 'dcAgentTaskDetails' },
4540
+ h('summary', { 'aria-label': '任务详情', title: '任务详情' }, 'i'),
4541
+ h('div', { className: 'dcAgentTaskDetailsBody' },
4542
+ h('strong', null, `${props.sourceLabel || '当前任务'} · ${props.statusLabel}`),
4543
+ h('p', null, `任务编号:${props.task.id}`),
4544
+ h('p', null, workflowOriginLabel(props.task)),
4545
+ h('p', null, `${props.rowCount} 条企业记录 · ${props.fieldCount} 个补全字段`),
4546
+ ),
4547
+ ),
4548
+ ),
4549
+ ['matching', 'enriching'].includes(props.task.state) ? h('p', { className: 'dcAgentTaskSyncNote' },
4550
+ '这是最近同步的任务记录;若原会话已停止,请先查看确认或错误提示,不要重复启动。') : null,
4551
+ progressVisible ? h('div', { className: 'dcAgentTaskExecution' },
4552
+ h('span', null, `已处理主体 ${completedUnique}/${props.commandProgress.totalUnique}`),
4553
+ h('progress', {
4554
+ max: props.commandProgress.totalUnique,
4555
+ value: completedUnique,
4556
+ 'aria-label': '主体处理进度',
4557
+ }),
4558
+ ) : null,
4559
+ h('div', { className: 'dcAgentTaskMetrics', 'aria-label': '任务摘要' },
4560
+ h('div', { className: 'dcAgentTaskMetricGroup' },
4561
+ h('span', { className: 'dcAgentTaskMetricLabel' }, '任务范围'),
4562
+ h('span', { className: 'dcAgentTaskMetric' }, '企业 ', h('b', null, props.rowCount), ' 家'),
4563
+ h('span', { className: 'dcAgentTaskMetric' }, '字段 ', h('b', null, props.fieldCount), ' 个'),
4564
+ ),
4565
+ props.hasResultSummary ? h('div', { className: 'dcAgentTaskMetricGroup' },
4566
+ h('span', { className: 'dcAgentTaskMetricLabel' }, '处理结果'),
4567
+ h('span', { className: 'dcAgentTaskMetric is-good' }, '已补全 ', h('b', null, props.completedMetric)),
4568
+ h('span', { className: 'dcAgentTaskMetric' }, '待核验 ', h('b', null, props.reviewMetric)),
4569
+ h('span', { className: 'dcAgentTaskMetric' }, '失败 ', h('b', null, props.failedMetric)),
4570
+ ) : null,
4571
+ ),
4572
+ );
4573
+ }
4574
+
4575
+ function WorkflowGuide() {
4576
+ return h('details', { className: 'dcAgentWorkflowGuide' },
4577
+ h('summary', null, '使用说明和任务流程'),
4578
+ h('div', { className: 'dcAgentWorkflowGuideContent' },
4579
+ h('p', { className: 'dcAgentWorkflowGuideIntro' },
4580
+ '导入与核验 → 规则与体检 → 主体匹配 → 字段补全 → 结果下载。菜单只切换视图,不启动调用;按页面主按钮逐步确认。'),
4581
+ h('p', { className: 'dcAgentWorkflowGuideIntro' },
4582
+ '支持文本、Excel、JSON 和图片名单。原值保留、多候选人工核验;企查查调用使用当前用户自己的连接与额度。'),
4583
+ h('div', { className: 'dcAgentWorkflowGuideList' },
4584
+ WORKFLOW_GUIDE_STEPS.map(([title, description], index) => h('div', {
4585
+ key: title,
4586
+ className: 'dcAgentWorkflowGuideStep',
4587
+ },
4588
+ h('span', { className: 'dcAgentWorkflowGuideIndex' }, String(index + 1)),
4589
+ h('div', null, h('strong', null, title), h('p', null, description))))),
4590
+ h('div', { className: 'dcAgentWorkflowGuideBoundary' },
4591
+ '生成任务说明不会查询;发送说明或显式重试时,才使用当前用户连接的企查查账号及额度。'),
4592
+ ),
4593
+ );
4594
+ }
4595
+
3861
4596
  /** Session Tab 业务内容;尺寸、停靠、关闭均由 Better Sidebar 宿主管理。 */
3862
4597
  function WorkbenchContent(props) {
3863
4598
  const { useStore, actions } = props;
@@ -3878,7 +4613,7 @@ body:has(.dcAgentPromptBackdrop) { overflow: hidden; }
3878
4613
  const objectives = useStore((state) => state.objectives);
3879
4614
  const fieldSelection = useStore((state) => state.fieldSelection);
3880
4615
  const matchRules = useStore((state) => state.matchRules);
3881
- const dataset = useStore((state) => state.dataset);
4616
+ const storedDataset = useStore((state) => state.dataset);
3882
4617
  const profile = useStore((state) => state.profile);
3883
4618
  const clean = useStore((state) => state.clean);
3884
4619
  const complete = useStore((state) => state.complete);
@@ -3890,11 +4625,11 @@ body:has(.dcAgentPromptBackdrop) { overflow: hidden; }
3890
4625
  const paidConfirmed = useStore((state) => state.paidConfirmed);
3891
4626
  const activeSessionId = useStore((state) => state.activeSessionId);
3892
4627
  const [fieldQuery, setFieldQuery] = react.useState('');
3893
- const [intakeEditorOpen, setIntakeEditorOpen] = react.useState(() => dataset == null);
4628
+ const [intakeEditorOpen, setIntakeEditorOpen] = react.useState(() => storedDataset == null);
3894
4629
 
3895
4630
  react.useEffect(() => {
3896
- setIntakeEditorOpen(dataset == null);
3897
- }, [dataset]);
4631
+ setIntakeEditorOpen(storedDataset == null);
4632
+ }, [storedDataset]);
3898
4633
 
3899
4634
  react.useEffect(() => {
3900
4635
  if (!open || !visible) return;
@@ -3910,13 +4645,19 @@ body:has(.dcAgentPromptBackdrop) { overflow: hidden; }
3910
4645
  requestJson('/data-cleaning/api/workflow/contract').then((response) => {
3911
4646
  if (!disposed) actions.setWorkflowContract(response.contract);
3912
4647
  }).catch(() => {});
3913
- loadWorkflowTasks(actions);
4648
+ const tasksPromise = loadWorkflowTasks(actions);
3914
4649
  if (activeSessionId) {
3915
4650
  queueWorkflowOperation(activeSessionId, async () => {
3916
4651
  const key = String(activeSessionId);
4652
+ const tasks = await tasksPromise;
3917
4653
  const saved = ownedSessionId(key) ? readTaskBindings()[key] : null;
3918
- const taskId = workflowTaskBySession.get(key)?.id || (/^dcw-[a-zA-Z0-9-]+$/.test(saved || '') ? saved : null);
3919
- if (taskId) await refreshHostedTask(actions, activeSessionId, taskId, null, false);
4654
+ const hosted = tasks
4655
+ .filter((task) => taskBelongsToSession(task, activeSessionId))
4656
+ .sort((left, right) => String(right.createdAt).localeCompare(String(left.createdAt)))[0]?.id;
4657
+ const taskId = workflowTaskBySession.get(key)?.id
4658
+ || hosted
4659
+ || (/^dcw-[a-zA-Z0-9-]+$/.test(saved || '') ? saved : null);
4660
+ if (taskId) await refreshHostedTask(actions, activeSessionId, taskId, null, 'update');
3920
4661
  }).catch((taskError) => {
3921
4662
  if (!disposed) actions.setError(`状态同步异常:${taskError instanceof Error ? taskError.message : String(taskError)}。保留最近任务记录,未重新执行查询。`);
3922
4663
  });
@@ -3931,11 +4672,10 @@ body:has(.dcAgentPromptBackdrop) { overflow: hidden; }
3931
4672
  let disposed = false, timer;
3932
4673
  const poll = async () => {
3933
4674
  try {
3934
- const task = await refreshHostedTask(actions, activeSessionId, workflowTask.id, null, false);
4675
+ const task = await refreshHostedTask(actions, activeSessionId, workflowTask.id, null, 'update');
3935
4676
  if (disposed) return;
3936
4677
  if (!task) return;
3937
4678
  if (['matching', 'enriching'].includes(task.state)) timer = setTimeout(poll, 2000);
3938
- else if (step === 'match' || step === 'enrich') actions.setStep(task.stage || step);
3939
4679
  } catch (error) { if (!disposed) actions.setError(`状态同步异常:${error.message}。保留最近任务记录,未重新执行查询。`); }
3940
4680
  };
3941
4681
  timer = setTimeout(poll, 2000);
@@ -3944,15 +4684,43 @@ body:has(.dcAgentPromptBackdrop) { overflow: hidden; }
3944
4684
 
3945
4685
  // Host 决定 Tab 挂载/可见性;内容不再按私有 open flag 隐藏。
3946
4686
 
3947
- const hasData = dataset !== null && dataset.rowCount > 0;
3948
- const requiresQcc = objectives.includes('validate_identity') || objectives.includes('complete_fields');
3949
4687
  const cachedTask = workflowTaskBySession.get(String(activeSessionId || 'unassigned')) ?? workflowTask;
3950
4688
  const runtimeKey = cachedTask?.id ?? `session:${activeSessionId || 'unassigned'}`;
3951
- const runtime = runtimeFor(runtimeKey);
4689
+ const runtime = runtimeFor(runtimeKey, true, cachedTask?.source?.checksum || '');
3952
4690
  const stagedSource = runtimeFor(`session:${activeSessionId || 'unassigned'}`, false);
3953
4691
  const sourceRows = stagedSource?.sourceRows ?? runtime.sourceRows ?? runtime.rows;
4692
+ const dataset = storedDataset ?? (sourceRows.length ? {
4693
+ fmt: runtime.source?.type || cachedTask?.source?.type || 'csv',
4694
+ headers: runtime.headers.length ? runtime.headers : cachedTask?.source?.headers || [],
4695
+ rowCount: cachedTask?.source?.rowCount ?? sourceRows.length,
4696
+ preview: sourceRows.slice(0, 5),
4697
+ } : null);
4698
+ const hasData = dataset !== null && dataset.rowCount > 0;
4699
+ const requiresQcc = objectives.includes('validate_identity') || objectives.includes('complete_fields');
3954
4700
  const intake = intakeState(dataset, sourceRows, input, runtime.source || stagedSource?.source || cachedTask?.source);
3955
- const showIntakeEditor = !dataset || intakeEditorOpen;
4701
+ const normalizedStage = normalizeViewStage(step);
4702
+ const flowCompatible = cachedTask?.flow?.flowVersion === SUPPORTED_FLOW_VERSION;
4703
+ const viewAccess = step === 'history'
4704
+ ? 'read'
4705
+ : cachedTask
4706
+ ? resolveStageAccess(normalizedStage, cachedTask.flow)
4707
+ : normalizedStage === 'upload' ? 'current' : 'locked';
4708
+ const allowedActions = new Set(flowCompatible ? cachedTask.flow.allowedActions || [] : []);
4709
+ const isCurrentView = viewAccess === 'current';
4710
+ const canAction = (action) => isCurrentView && (
4711
+ cachedTask ? allowedActions.has(action) : action === 'import-data'
4712
+ );
4713
+ const reviewingImportedDataset = normalizedStage === 'upload'
4714
+ && cachedTask?.state === 'uploaded'
4715
+ && cachedTask.flow?.currentStage === 'rules'
4716
+ && viewAccess === 'read';
4717
+ const navigate = (destination) => navigateWorkflow(actions, activeSessionId, destination, cachedTask);
4718
+ const showIntakeEditor = canAction('import-data') && (!dataset || intakeEditorOpen);
4719
+ const runtimeRecoveryNeeded = Boolean(cachedTask?.source?.rowCount) && runtime.rows.length === 0;
4720
+ const runtimeRecoveryAllowed = runtimeRecoveryNeeded
4721
+ && ((isCurrentView && ['uploaded', 'rules_confirmed', 'diagnosed'].includes(cachedTask?.state))
4722
+ || (normalizedStage === 'upload' && viewAccess === 'read'))
4723
+ && cachedTask?.source?.type !== 'image';
3956
4724
  const lastCsv = runtime.lastCsv;
3957
4725
  const fieldByPattern = (pattern) => runtime.headers.find((field) => pattern.test(field)) ?? null;
3958
4726
  const phoneField = fieldByPattern(/^(phone|mobile|tel|telephone|联系电话|手机号码|手机号)$/i);
@@ -3971,7 +4739,7 @@ body:has(.dcAgentPromptBackdrop) { overflow: hidden; }
3971
4739
  };
3972
4740
 
3973
4741
  const handleParse = async () => {
3974
- if (busy) return;
4742
+ if (busy || !canAction('import-data')) return;
3975
4743
  if (!intake.pendingText) {
3976
4744
  if (intake.available) actions.setError(null);
3977
4745
  return;
@@ -3979,7 +4747,7 @@ body:has(.dcAgentPromptBackdrop) { overflow: hidden; }
3979
4747
  actions.setBusy(true);
3980
4748
  actions.setError(null);
3981
4749
  try {
3982
- const result = await parseText(input, actions);
4750
+ const result = await parseText(input, actions, cachedTask?.source?.checksum);
3983
4751
  if (result && result.ok !== false) {
3984
4752
  applyParsed(result, actions, runtimeKey, { type: result.fmt || 'csv' });
3985
4753
  await persistParsedWorkflow(actions, activeSessionId, result, { type: result.fmt || 'csv' }, cachedTask);
@@ -4003,7 +4771,7 @@ body:has(.dcAgentPromptBackdrop) { overflow: hidden; }
4003
4771
  return true;
4004
4772
  };
4005
4773
  const handleFile = async (event) => {
4006
- if (busy) return;
4774
+ if (busy || !canAction('import-data')) return;
4007
4775
  const file = event.target && event.target.files && event.target.files[0];
4008
4776
  if (!file) return;
4009
4777
  if (String(file.type ?? '').startsWith('image/')) {
@@ -4014,7 +4782,7 @@ body:has(.dcAgentPromptBackdrop) { overflow: hidden; }
4014
4782
  actions.setBusy(true);
4015
4783
  actions.setError(null);
4016
4784
  try {
4017
- const result = await parseFile(file);
4785
+ const result = await parseFile(file, cachedTask?.source?.checksum);
4018
4786
  if (result && result.ok !== false) {
4019
4787
  applyParsed(result, actions, runtimeKey, { type: result.fmt || 'csv', fileName: file.name, sizeBytes: file.size || 0 });
4020
4788
  await persistParsedWorkflow(actions, activeSessionId, result, { type: result.fmt || 'csv', fileName: file.name, sizeBytes: file.size || 0 }, cachedTask);
@@ -4029,7 +4797,51 @@ body:has(.dcAgentPromptBackdrop) { overflow: hidden; }
4029
4797
  }
4030
4798
  };
4031
4799
 
4800
+ const acceptRuntimeRecovery = (result, source) => {
4801
+ if (!cachedTask?.source?.checksum) {
4802
+ throw new Error('该旧任务缺少同源校验信息,不能恢复写操作。请新建任务并复用配置。');
4803
+ }
4804
+ if (!result?.checksum || result.checksum !== cachedTask.source.checksum) {
4805
+ throw new Error('重新载入的数据与原任务不一致,未修改当前任务。请新建任务处理其他名单。');
4806
+ }
4807
+ restoreParsedRuntime(result, actions, cachedTask, source);
4808
+ actions.setInput('');
4809
+ actions.setError(null);
4810
+ };
4811
+
4812
+ const recoverRuntimeFile = async (event) => {
4813
+ if (busy) return;
4814
+ const file = event.target?.files?.[0];
4815
+ if (!file) return;
4816
+ actions.setBusy(true);
4817
+ actions.setError(null);
4818
+ try {
4819
+ const result = await parseFile(file, cachedTask?.source?.checksum);
4820
+ acceptRuntimeRecovery(result, { type: result.fmt, fileName: file.name, sizeBytes: file.size || 0 });
4821
+ } catch (err) {
4822
+ actions.setError(err instanceof Error ? err.message : String(err));
4823
+ } finally {
4824
+ event.target.value = '';
4825
+ actions.setBusy(false);
4826
+ }
4827
+ };
4828
+
4829
+ const recoverRuntimeText = async () => {
4830
+ if (busy || !String(input || '').trim()) return;
4831
+ actions.setBusy(true);
4832
+ actions.setError(null);
4833
+ try {
4834
+ const result = await parseText(input, actions, cachedTask?.source?.checksum);
4835
+ acceptRuntimeRecovery(result, { type: result.fmt, fileName: cachedTask?.source?.fileName || '' });
4836
+ } catch (err) {
4837
+ actions.setError(err instanceof Error ? err.message : String(err));
4838
+ } finally {
4839
+ actions.setBusy(false);
4840
+ }
4841
+ };
4842
+
4032
4843
  const performProfile = async (task) => {
4844
+ await verifyRestoredRuntime(actions, activeSessionId, task, runtime);
4033
4845
  const r = await api('/data-cleaning/api/mvp/profile', {
4034
4846
  rows: runtime.rows,
4035
4847
  headers: runtime.headers,
@@ -4042,7 +4854,7 @@ body:has(.dcAgentPromptBackdrop) { overflow: hidden; }
4042
4854
  };
4043
4855
 
4044
4856
  const runProfile = async () => {
4045
- if (busy || !runtime.rows.length) {
4857
+ if (busy || !canAction('run-quality') || !runtime.rows.length) {
4046
4858
  if (!runtime.rows.length) actions.setError('请先上传并解析数据。');
4047
4859
  return;
4048
4860
  }
@@ -4050,7 +4862,6 @@ body:has(.dcAgentPromptBackdrop) { overflow: hidden; }
4050
4862
  actions.setError(null);
4051
4863
  try {
4052
4864
  await performProfile(cachedTask);
4053
- actions.setStep(requiresQcc ? 'match' : 'enrich');
4054
4865
  } catch (err) {
4055
4866
  actions.setError(err instanceof Error ? err.message : String(err));
4056
4867
  } finally {
@@ -4059,13 +4870,14 @@ body:has(.dcAgentPromptBackdrop) { overflow: hidden; }
4059
4870
  };
4060
4871
 
4061
4872
  const runClean = async () => {
4062
- if (busy || !runtime.rows.length) {
4873
+ if (busy || !canAction('run-local-clean') || !runtime.rows.length) {
4063
4874
  if (!runtime.rows.length) actions.setError('请先上传并解析数据。');
4064
4875
  return;
4065
4876
  }
4066
4877
  actions.setBusy(true);
4067
4878
  actions.setError(null);
4068
4879
  try {
4880
+ await verifyRestoredRuntime(actions, activeSessionId, cachedTask, runtime);
4069
4881
  const r = await api('/data-cleaning/api/mvp/clean', {
4070
4882
  rows: runtime.rows,
4071
4883
  headers: runtime.headers,
@@ -4087,13 +4899,14 @@ body:has(.dcAgentPromptBackdrop) { overflow: hidden; }
4087
4899
  };
4088
4900
 
4089
4901
  const runComplete = async () => {
4090
- if (busy || !runtime.rows.length) {
4902
+ if (busy || !canAction('run-local-complete') || !runtime.rows.length) {
4091
4903
  if (!runtime.rows.length) actions.setError('请先上传并解析数据。');
4092
4904
  return;
4093
4905
  }
4094
4906
  actions.setBusy(true);
4095
4907
  actions.setError(null);
4096
4908
  try {
4909
+ await verifyRestoredRuntime(actions, activeSessionId, cachedTask, runtime);
4097
4910
  const r = await api('/data-cleaning/api/mvp/complete', {
4098
4911
  rows: runtime.rows,
4099
4912
  headers: runtime.headers,
@@ -4120,48 +4933,9 @@ body:has(.dcAgentPromptBackdrop) { overflow: hidden; }
4120
4933
  runtime.resultRows.qcc = Array.isArray(run.rows) ? run.rows : null;
4121
4934
  lastCsv.qcc = { csv: run.csv ?? '', name: run.downloadName ?? 'qcc-enriched.csv' };
4122
4935
  if (run.reviewCsv) lastCsv.review = { csv: run.reviewCsv, name: run.reviewDownloadName ?? 'qcc-review.csv' };
4123
- const summary = run.summary ?? {};
4124
- const reviewRequired = Number(summary.ambiguous ?? run.reviewQueue?.length ?? 0);
4125
4936
  const currentTask = workflowTaskBySession.get(String(activeSessionId || 'unassigned')) ?? cachedTask;
4126
- let current = currentTask;
4127
- if (currentTask?.qccRunId === run.runId && ['completed', 'partial', 'review_required', 'export_ready'].includes(currentTask.state)) {
4128
- actions.setStep(currentTask.stage || 'download');
4129
- return currentTask;
4130
- }
4131
- // partial 表示上一轮仍有失败项。重试成功后可直接重进 enrichment,
4132
- // 无需伪造一次新的 match 转换;若重试产生候选,再回到人工核验。
4133
- if (currentTask?.state !== 'partial' || reviewRequired > 0) {
4134
- current = await workflowAction(actions, activeSessionId, currentTask, 'match', {
4135
- qccRunId: run.runId,
4136
- summary: {
4137
- total: Number(summary.totalRows ?? runtime.rows.length),
4138
- exact: Number(summary.enriched ?? 0) + Number(summary.fieldReview ?? 0),
4139
- candidate: reviewRequired,
4140
- confirmed: 0,
4141
- unresolved: Number(summary.unresolved ?? summary.missingName ?? 0),
4142
- failed: Number(summary.failed ?? 0),
4143
- reviewRequired,
4144
- },
4145
- });
4146
- }
4147
- if (reviewRequired > 0) {
4148
- actions.setStep('match');
4149
- return current;
4150
- }
4151
- current = await workflowAction(actions, activeSessionId, current, 'enrich-start', { fieldSelection });
4152
- current = await workflowAction(actions, activeSessionId, current, 'enrichment', {
4153
- qccRunId: run.runId,
4154
- summary: {
4155
- total: Number(summary.totalRows ?? runtime.rows.length),
4156
- completed: Number(summary.enriched ?? 0),
4157
- unchanged: Number(summary.unresolved ?? 0) + Number(summary.missingName ?? 0),
4158
- failed: Number(summary.failed ?? 0),
4159
- reviewRequired: Number(summary.fieldReview ?? 0),
4160
- callsUsed: Array.isArray(run.audit) ? run.audit.length : 0,
4161
- },
4162
- });
4163
- actions.setStep(current.stage === 'download' ? 'download' : 'enrich');
4164
- return current;
4937
+ if (!currentTask?.id) return null;
4938
+ return refreshHostedTask(actions, activeSessionId, currentTask.id, run, 'update');
4165
4939
  };
4166
4940
 
4167
4941
  const executePreparedQccCommand = async (payload) => {
@@ -4201,7 +4975,7 @@ body:has(.dcAgentPromptBackdrop) { overflow: hidden; }
4201
4975
  };
4202
4976
 
4203
4977
  const runQcc = async () => {
4204
- if (busy || !cachedTask?.id || !hasData) return;
4978
+ if (busy || !canAction('prepare-qcc-command') || !cachedTask?.id || !hasData) return;
4205
4979
  actions.setBusy(true);
4206
4980
  actions.setError(null);
4207
4981
  try {
@@ -4223,7 +4997,7 @@ body:has(.dcAgentPromptBackdrop) { overflow: hidden; }
4223
4997
  };
4224
4998
 
4225
4999
  const resolveCandidate = async (item, candidate) => {
4226
- if (busy || !paidConfirmed || !qccRun) return;
5000
+ if (busy || !canAction('resolve-candidate') || !paidConfirmed || !qccRun) return;
4227
5001
  actions.setBusy(true);
4228
5002
  actions.setError(null);
4229
5003
  try {
@@ -4244,7 +5018,7 @@ body:has(.dcAgentPromptBackdrop) { overflow: hidden; }
4244
5018
  };
4245
5019
 
4246
5020
  const retryFailures = async () => {
4247
- if (busy || !paidConfirmed || !qccRun) return;
5021
+ if (busy || !canAction('retry-failed') || !paidConfirmed || !qccRun) return;
4248
5022
  const names = [...new Set((qccRun.errors || []).filter((item) => item.error && item.error.retryable).map((item) => item.companyName))];
4249
5023
  if (!names.length) return;
4250
5024
  actions.setBusy(true);
@@ -4265,7 +5039,21 @@ body:has(.dcAgentPromptBackdrop) { overflow: hidden; }
4265
5039
  }
4266
5040
  };
4267
5041
 
5042
+ const retryDelivery = async () => {
5043
+ if (busy || !canAction('retry-delivery') || !cachedTask?.id) return;
5044
+ actions.setBusy(true);
5045
+ actions.setError(null);
5046
+ try {
5047
+ await workflowAction(actions, activeSessionId, cachedTask, 'retry-delivery');
5048
+ } catch (err) {
5049
+ actions.setError(err instanceof Error ? err.message : String(err));
5050
+ } finally {
5051
+ actions.setBusy(false);
5052
+ }
5053
+ };
5054
+
4268
5055
  const saveTaskSettings = async () => {
5056
+ if (!canAction('edit-rules')) return;
4269
5057
  actions.setBusy(true);
4270
5058
  actions.setError(null);
4271
5059
  try {
@@ -4283,6 +5071,7 @@ body:has(.dcAgentPromptBackdrop) { overflow: hidden; }
4283
5071
  };
4284
5072
 
4285
5073
  const confirmRuleSettings = async () => {
5074
+ if (!canAction('confirm-rules')) return;
4286
5075
  if (!hasData) { actions.setError('请先上传并解析数据。'); return; }
4287
5076
  const anchorMappings = mappings.filter((mapping) => ['company_name', 'credit_no', 'reg_no'].includes(mapping.targetField));
4288
5077
  if (!anchorMappings.length) { actions.setError('请至少映射企业名称、统一社会信用代码或注册号。'); return; }
@@ -4290,6 +5079,7 @@ body:has(.dcAgentPromptBackdrop) { overflow: hidden; }
4290
5079
  actions.setBusy(true);
4291
5080
  actions.setError(null);
4292
5081
  try {
5082
+ await verifyRestoredRuntime(actions, activeSessionId, cachedTask, runtime);
4293
5083
  let current = await ensureWorkflowTask(actions, activeSessionId, {
4294
5084
  title: taskTitle, objectives, fieldSelection, mappings, matchRules,
4295
5085
  });
@@ -4302,6 +5092,7 @@ body:has(.dcAgentPromptBackdrop) { overflow: hidden; }
4302
5092
  columnCount: dataset.headers?.length || 0,
4303
5093
  headers: dataset.headers || [],
4304
5094
  sizeBytes: runtime.source?.sizeBytes || 0,
5095
+ checksum: runtime.source?.checksum || '',
4305
5096
  },
4306
5097
  });
4307
5098
  }
@@ -4316,7 +5107,6 @@ body:has(.dcAgentPromptBackdrop) { overflow: hidden; }
4316
5107
  ?? mappings.find((mapping) => mapping.targetField === 'credit_no')
4317
5108
  ?? mappings.find((mapping) => mapping.targetField === 'reg_no');
4318
5109
  if (primary) actions.setNameField(primary.sourceField);
4319
- actions.setStep('profile');
4320
5110
  } catch (ruleError) {
4321
5111
  actions.setError(ruleError instanceof Error ? ruleError.message : String(ruleError));
4322
5112
  } finally {
@@ -4333,16 +5123,23 @@ body:has(.dcAgentPromptBackdrop) { overflow: hidden; }
4333
5123
  } catch (error) { actions.setError(error.message); return; }
4334
5124
  };
4335
5125
 
4336
- const startNewWorkflow = () => {
5126
+ const startNewWorkflow = (reuseConfig = false) => {
4337
5127
  const event = document.createEvent('CustomEvent');
4338
- event.initCustomEvent('dsh:data-cleaning-prompt-new', false, true, { sessionId: activeSessionId });
5128
+ event.initCustomEvent('dsh:data-cleaning-prompt-new', false, true, {
5129
+ sessionId: activeSessionId,
5130
+ ...(reuseConfig && cachedTask ? { reuseConfig: {
5131
+ objectives: cachedTask.objectives || [],
5132
+ fieldSelection: cachedTask.fieldSelection || [],
5133
+ matchRules: cachedTask.matchRules || {},
5134
+ } } : {}),
5135
+ });
4339
5136
  if (document.dispatchEvent(event)) {
4340
5137
  actions.setError('当前会话的任务向导尚未就绪或正在解析图片,请稍后重试。');
4341
5138
  return;
4342
5139
  }
4343
- if (cachedTask?.id) runtimeTasks.delete(String(cachedTask.id));
4344
- runtimeTasks.delete(`session:${activeSessionId || 'unassigned'}`);
4345
- runtimeTasks.delete('unassigned');
5140
+ if (cachedTask?.id) removeRuntime(cachedTask.id);
5141
+ removeRuntime(`session:${activeSessionId || 'unassigned'}`);
5142
+ removeRuntime('unassigned');
4346
5143
  workflowTaskBySession.delete(String(activeSessionId || 'unassigned'));
4347
5144
  actions.resetWorkflow();
4348
5145
  actions.close();
@@ -4356,9 +5153,11 @@ body:has(.dcAgentPromptBackdrop) { overflow: hidden; }
4356
5153
  const createArtifactBundle = async () => {
4357
5154
  let latest = workflowTaskBySession.get(String(activeSessionId || 'unassigned')) ?? cachedTask;
4358
5155
  if (!latest) throw new Error('请先创建并执行数据清洗补全任务。');
5156
+ if (!canAction('create-local-artifacts')) throw new Error('当前流程节点不能生成本地结果。');
4359
5157
  if (Array.isArray(latest.artifacts) && latest.artifacts.length) return latest;
4360
5158
  const rows = exportRows();
4361
5159
  if (!rows?.length) throw new Error('当前页面没有可导出的明细。若任务来自历史记录,请重新上传源文件并继续处理。');
5160
+ await verifyRestoredRuntime(actions, activeSessionId, latest, runtime);
4362
5161
  const response = await requestJson(`/data-cleaning/api/workflow/tasks/${encodeURIComponent(latest.id)}/artifacts`, 'POST', {
4363
5162
  originSessionId: activeSessionId, originWorkspaceId: latest.originWorkspaceId,
4364
5163
  expectedRevision: latest.revision,
@@ -4436,6 +5235,37 @@ body:has(.dcAgentPromptBackdrop) { overflow: hidden; }
4436
5235
  : cachedTask?.enrichmentSummary?.reviewRequired
4437
5236
  ?? cachedTask?.matchSummary?.reviewRequired;
4438
5237
  const qccFailedCount = qccRun?.summary?.failed ?? cachedTask?.enrichmentSummary?.failed ?? cachedTask?.matchSummary?.failed ?? 0;
5238
+ const viewedStageLabel = workflowStageLabel(normalizedStage);
5239
+ const currentStageLabel = workflowStageLabel(cachedTask?.flow?.currentStage || cachedTask?.stage || 'upload');
5240
+ const taskStatusLabel = viewAccess === 'read'
5241
+ ? '已完成'
5242
+ : cachedTask?.source?.type === 'image' && cachedTask?.state === 'draft'
5243
+ ? '图片已暂存,待识别'
5244
+ : WORKFLOW_STATE_LABELS[cachedTask?.state] || cachedTask?.state || '待开始';
5245
+ const viewedStageCompleted = viewAccess === 'read' || cachedTask?.state === 'completed';
5246
+ const taskTimestamp = cachedTask?.updatedAt || cachedTask?.createdAt;
5247
+ const taskTimeLabel = taskTimestamp && Number.isFinite(new Date(taskTimestamp).getTime())
5248
+ ? new Date(taskTimestamp).toLocaleString('zh-CN', {
5249
+ year: 'numeric', month: 'numeric', day: 'numeric',
5250
+ hour: '2-digit', minute: '2-digit', hour12: false,
5251
+ })
5252
+ : '';
5253
+ const taskFileName = cachedTask?.source?.fileName || '';
5254
+ const taskSourceLabel = taskFileName
5255
+ || (cachedTask?.source?.type === 'image'
5256
+ ? '图片名单'
5257
+ : cachedTask?.source
5258
+ ? '文本粘贴'
5259
+ : '');
5260
+ const taskRowCount = cachedTask?.source?.rowCount ?? dataset?.rowCount ?? 0;
5261
+ const taskFieldCount = cachedTask?.fieldSelection?.length ?? fieldSelection.length;
5262
+ const hasTaskResultSummary = Boolean(qccRun || cachedTask?.enrichmentSummary || cachedTask?.matchSummary);
5263
+ const taskCompletedCount = Number.isFinite(qccEnrichedCount) ? qccEnrichedCount : 0;
5264
+ const taskReviewCount = Number.isFinite(qccReviewCount) ? qccReviewCount : 0;
5265
+ const taskFailedCount = Number.isFinite(qccFailedCount) ? qccFailedCount : 0;
5266
+ const taskCompletedMetric = `${taskCompletedCount}/${taskRowCount || taskCompletedCount} 条`;
5267
+ const taskReviewMetric = `${taskReviewCount} 条`;
5268
+ const taskFailedMetric = `${taskFailedCount} 条`;
4439
5269
  const stat = (label, value, tone) => h('div', { className: 'dcAgentCard' },
4440
5270
  h('span', null, label),
4441
5271
  h('b', { className: tone ? `is-${tone}` : null }, displayStatValue(value))
@@ -4472,8 +5302,13 @@ body:has(.dcAgentPromptBackdrop) { overflow: hidden; }
4472
5302
  h('button', {
4473
5303
  type: 'button',
4474
5304
  className: 'dcAgentButton is-primary',
4475
- onClick: startNewWorkflow,
5305
+ onClick: () => startNewWorkflow(false),
4476
5306
  }, '新建清洗任务'),
5307
+ cachedTask ? h('button', {
5308
+ type: 'button',
5309
+ className: 'dcAgentButton',
5310
+ onClick: () => startNewWorkflow(true),
5311
+ }, '新建任务并复用配置') : null,
4477
5312
  ),
4478
5313
  historyTask ? h('section', { className: 'dcAgentSection', 'aria-label': '历史任务只读详情' },
4479
5314
  h('h3', null, workflowDisplayName(historyTask)),
@@ -4509,8 +5344,48 @@ body:has(.dcAgentPromptBackdrop) { overflow: hidden; }
4509
5344
  ),
4510
5345
  );
4511
5346
  } else if (step === 'rules') {
4512
- pane = h('div', { className: 'dcAgentPane' },
5347
+ if (!canAction('edit-rules') && !canAction('confirm-rules')) {
5348
+ pane = h('div', { className: 'dcAgentPane' },
5349
+ h('p', { className: 'dcAgentHint' }, isCurrentView
5350
+ ? '规则已经确认,当前只等待生成质量体检。'
5351
+ : '这是已确认规则的只读摘要。'),
5352
+ h('section', { className: 'dcAgentSection' },
5353
+ h('h3', null, taskTitle),
5354
+ h('div', { className: 'dcAgentChips' },
5355
+ mappings.map((mapping) => h('span', { key: mapping.sourceField, className: 'dcAgentChip' },
5356
+ `${mapping.sourceField} → ${resultFieldLabel(mapping.targetField)}`))),
5357
+ h('p', { className: 'dcAgentHint' }, `任务目标 ${objectives.length} 项 · 补全字段 ${fieldSelection.length} 项`),
5358
+ ),
5359
+ canAction('run-quality') ? h('button', {
5360
+ type: 'button', className: 'dcAgentButton is-primary', disabled: busy || !runtime.rows.length,
5361
+ onClick: runProfile,
5362
+ }, busy ? '体检中…' : '生成质量体检报告') : null,
5363
+ );
5364
+ } else pane = h('div', { className: 'dcAgentPane' },
4513
5365
  h('p', { className: 'dcAgentHint' }, '确认任务名称、字段映射、匹配规则和输出字段。保存草稿不会调用企查查;确认规则后才可进入质量体检。'),
5366
+ canAction('import-data') ? h('section', { className: 'dcAgentSection' },
5367
+ h('div', { className: 'dcAgentRow' },
5368
+ h('h3', null, '当前来源'),
5369
+ !intakeEditorOpen ? h('button', { type: 'button', className: 'dcAgentButton', disabled: busy,
5370
+ onClick: () => { actions.setError(null); setIntakeEditorOpen(true); } }, '重新导入') : null,
5371
+ ),
5372
+ h('p', { className: 'dcAgentHint' }, `${intake.fileName} · ${dataset?.rowCount || 0} 行`),
5373
+ showIntakeEditor ? h('div', { className: 'dcAgentIntakeEditor' },
5374
+ h('div', { className: 'dcAgentIntakeSourceActions' },
5375
+ h('button', { type: 'button', className: 'dcAgentButton', disabled: busy,
5376
+ onClick: (event) => event.currentTarget.nextElementSibling?.click() }, '选择文件(自动解析)'),
5377
+ h('input', { type: 'file', accept: '.csv,.json,.xlsx,.xls,image/png,image/jpeg,image/webp',
5378
+ style: { display: 'none' }, disabled: busy, 'aria-label': '选择数据文件', onChange: handleFile }),
5379
+ h('button', { type: 'button', className: 'dcAgentButton', disabled: busy,
5380
+ onClick: () => openImageIntake() }, '图片识别(粘贴 / 选择)'),
5381
+ ),
5382
+ h('textarea', { className: 'dcAgentTextarea', value: input, disabled: busy,
5383
+ 'aria-label': '粘贴数据', placeholder: '粘贴 CSV、JSON 或逐行企业名单',
5384
+ onInput: (event) => { actions.setInput(event.target.value); actions.setError(null); } }),
5385
+ h('button', { type: 'button', className: 'dcAgentButton', disabled: busy || !intake.pendingText,
5386
+ 'aria-label': '解析数据', onClick: handleParse }, busy ? '解析中…' : '解析粘贴内容'),
5387
+ ) : null,
5388
+ ) : null,
4514
5389
  h('label', { className: 'dcAgentFormField' },
4515
5390
  h('span', null, '任务名称'),
4516
5391
  h('input', { className: 'dcAgentField', value: taskTitle, maxLength: 120, onChange: (event) => actions.setTaskTitle(event.target.value) }),
@@ -4593,22 +5468,30 @@ body:has(.dcAgentPromptBackdrop) { overflow: hidden; }
4593
5468
  ),
4594
5469
  );
4595
5470
  } else if (step === 'profile') {
5471
+ const persistedQuality = cachedTask?.qualitySummary;
4596
5472
  pane = h('div', { className: 'dcAgentPane dcAgentProfilePane' },
4597
5473
  h('section', { className: 'dcAgentSection dcAgentProfileSurface', 'aria-label': '质量体检报告' },
4598
5474
  h('header', { className: 'dcAgentProfileHeader' },
4599
5475
  h('h3', null, '质量体检报告'),
4600
5476
  h('p', { className: 'dcAgentHint' }, '基于已解析数据生成本地质量画像(缺失率、去重值、金额分布)。本步骤不发起企查查调用。'),
4601
5477
  ),
4602
- profile ? h('div', { className: 'dcAgentProfileReport' },
5478
+ profile || persistedQuality ? h('div', { className: 'dcAgentProfileReport' },
5479
+ !profile ? h('p', { className: 'dcAgentHint', role: 'status' }, '以下为 Host 保存的体检摘要;字段级明细未持久化。') : null,
4603
5480
  h('div', { className: 'dcAgentGrid dcAgentProfileMetrics', 'aria-label': '体检摘要' },
4604
- stat('行数', profile.rowCount ?? '—'),
4605
- stat('列数', profile.columnCount ?? '—'),
4606
- profile.amountStats ? stat('金额 最小', profile.amountStats.min) : null,
4607
- profile.amountStats ? stat('金额 最大', profile.amountStats.max) : null,
4608
- profile.amountStats ? stat('金额 总和', profile.amountStats.sum) : null,
4609
- profile.amountStats ? stat('金额 均值', typeof profile.amountStats.mean === 'number' ? profile.amountStats.mean.toFixed(2) : profile.amountStats.mean) : null,
5481
+ stat('行数', profile?.rowCount ?? persistedQuality?.total ?? cachedTask?.source?.rowCount ?? '—'),
5482
+ stat('列数', profile?.columnCount ?? cachedTask?.source?.columnCount ?? '—'),
5483
+ profile?.amountStats ? stat('金额 最小', profile.amountStats.min) : null,
5484
+ profile?.amountStats ? stat('金额 最大', profile.amountStats.max) : null,
5485
+ profile?.amountStats ? stat('金额 总和', profile.amountStats.sum) : null,
5486
+ profile?.amountStats ? stat('金额 均值', typeof profile.amountStats.mean === 'number' ? profile.amountStats.mean.toFixed(2) : profile.amountStats.mean) : null,
5487
+ !profile && persistedQuality ? stat('有效主体', persistedQuality.valid, persistedQuality.valid > 0 ? 'good' : null) : null,
5488
+ !profile && persistedQuality ? stat('缺少主体标识', persistedQuality.missingAnchor, persistedQuality.missingAnchor > 0 ? 'bad' : null) : null,
5489
+ !profile && persistedQuality ? stat('重复主体', persistedQuality.duplicates, persistedQuality.duplicates > 0 ? 'warn' : null) : null,
5490
+ !profile && persistedQuality ? stat('信用代码异常', persistedQuality.invalidCreditNo, persistedQuality.invalidCreditNo > 0 ? 'warn' : null) : null,
5491
+ !profile && persistedQuality ? stat('联系电话异常', persistedQuality.invalidPhone, persistedQuality.invalidPhone > 0 ? 'warn' : null) : null,
5492
+ !profile && persistedQuality ? stat('空白字段', persistedQuality.emptyFields, persistedQuality.emptyFields > 0 ? 'warn' : null) : null,
4610
5493
  ),
4611
- Array.isArray(profile.columns) && profile.columns.length ? h('section', { className: 'dcAgentProfileTableSection', 'aria-label': '字段质量明细' },
5494
+ Array.isArray(profile?.columns) && profile.columns.length ? h('section', { className: 'dcAgentProfileTableSection', 'aria-label': '字段质量明细' },
4612
5495
  h('div', { className: 'dcAgentProfileTableHead' },
4613
5496
  h('h4', null, '字段质量明细'),
4614
5497
  h('span', { className: 'dcAgentHint' }, `共 ${profile.columns.length} 列`),
@@ -4635,12 +5518,12 @@ body:has(.dcAgentPromptBackdrop) { overflow: hidden; }
4635
5518
  type: 'button',
4636
5519
  className: 'dcAgentButton is-primary',
4637
5520
  disabled: busy,
4638
- onClick: () => actions.setStep(requiresQcc ? 'match' : 'enrich'),
5521
+ onClick: () => navigate(requiresQcc ? 'match' : 'enrich'),
4639
5522
  }, requiresQcc ? '下一步:匹配核验' : '下一步:本地清洗补全'),
4640
5523
  ),
4641
- ) : h('div', { className: 'dcAgentProfileEmpty' },
5524
+ ) : canAction('run-quality') ? h('div', { className: 'dcAgentProfileEmpty' },
4642
5525
  h('button', { type: 'button', className: 'dcAgentButton is-primary', disabled: busy || !hasData, 'aria-label': '生成体检报告', onClick: runProfile }, busy ? '体检中…' : '生成体检报告'),
4643
- ),
5526
+ ) : h('p', { className: 'dcAgentHint' }, '当前没有可恢复的质量体检明细。'),
4644
5527
  ),
4645
5528
  );
4646
5529
  } else if (step === 'match') {
@@ -4663,19 +5546,20 @@ body:has(.dcAgentPromptBackdrop) { overflow: hidden; }
4663
5546
  h('section', { className: 'dcAgentMatchBlock', 'aria-label': '准备任务说明' },
4664
5547
  h('div', { className: 'dcAgentMatchBlockHead' },
4665
5548
  h('h4', null, '准备任务说明'),
4666
- h('span', { className: 'dcAgentMatchScope' }, h('b', null, dataset?.rowCount ?? runtime.rows.length), '条记录'),
5549
+ h('span', { className: 'dcAgentMatchScope' }, h('b', null,
5550
+ dataset?.rowCount ?? cachedTask?.source?.rowCount ?? runtime.rows.length), '条记录'),
4667
5551
  ),
4668
5552
  h('p', { className: 'dcAgentHint' }, `已选择 ${fieldSelection.length} 个补全字段。Host 自动检查所需工具并计算去重主体与调用上界。`),
4669
5553
  h('p', { className: 'dcAgentHint' }, '回填后可修改;点击发送才开始查询,使用你已连接的企查查 MCP 账号及额度。'),
4670
5554
  ),
4671
5555
  ),
4672
- h('div', { className: 'dcAgentMatchActions' },
5556
+ canAction('prepare-qcc-command') ? h('div', { className: 'dcAgentMatchActions' },
4673
5557
  h('button', { type: 'button', className: 'dcAgentButton is-primary', disabled: busy || !hasData || !cachedTask?.id, onClick: runQcc }, busy ? '检查并生成中…' : '生成可编辑任务说明'),
4674
- ),
5558
+ ) : null,
4675
5559
  ),
4676
5560
  qccRun ? h('section', { className: 'dcAgentSection' },
4677
5561
  h('h3', null, `任务 ${qccRun.runId} · ${qccRun.state}`),
4678
- ((qccRun.reviewQueue || []).length || (qccRun.errors || []).some((item) => item.error?.retryable)) ? h('label', { className: 'dcAgentCheck' },
5562
+ canAction('resolve-candidate') && (qccRun.reviewQueue || []).length ? h('label', { className: 'dcAgentCheck' },
4679
5563
  h('input', { type: 'checkbox', checked: paidConfirmed, 'aria-label': '确认候选或重试的企查查调用', onChange: (event) => actions.setPaidConfirmed(event.target.checked) }),
4680
5564
  '确认主体或重试会继续查询,使用当前连接的企查查账号;额度或费用由该账号自行承担。',
4681
5565
  ) : null,
@@ -4695,13 +5579,25 @@ body:has(.dcAgentPromptBackdrop) { overflow: hidden; }
4695
5579
  item.candidates.map((candidate) => h('div', { key: candidate.creditNo, className: 'dcAgentCandidate' },
4696
5580
  h('div', null, h('b', null, candidate.companyName || '未命名候选'), h('small', null, candidate.creditNo)),
4697
5581
  h('small', null, `${candidate.status || '状态未知'} · ${(candidate.legalRep || []).join('、') || '法人未知'}`),
4698
- h('button', { type: 'button', className: 'dcAgentButton', disabled: busy || !paidConfirmed, onClick: () => resolveCandidate(item, candidate) }, '确认此主体'),
5582
+ canAction('resolve-candidate') ? h('button', { type: 'button', className: 'dcAgentButton', disabled: busy || !paidConfirmed, onClick: () => resolveCandidate(item, candidate) }, '确认此主体') : null,
4699
5583
  )),
4700
5584
  )),
4701
- (qccRun.errors || []).some((item) => item.error?.retryable) ? h('button', { type: 'button', className: 'dcAgentButton', disabled: busy || !paidConfirmed, onClick: retryFailures }, '重试可恢复失败项') : null,
5585
+ ) : cachedTask?.matchSummary ? h('section', { className: 'dcAgentSection', 'aria-label': '主体匹配结果摘要' },
5586
+ h('h3', null, '主体匹配结果'),
5587
+ h('p', { className: 'dcAgentHint', role: 'status' }, '以下为 Host 保存的任务摘要;逐行结果和候选详情未持久化。'),
5588
+ h('div', { className: 'dcAgentGrid' },
5589
+ stat('总数', cachedTask.matchSummary.total),
5590
+ stat('精确匹配', (cachedTask.matchSummary.exact ?? 0) + (cachedTask.matchSummary.confirmed ?? 0), 'good'),
5591
+ stat('候选记录', cachedTask.matchSummary.candidate, cachedTask.matchSummary.candidate > 0 ? 'warn' : null),
5592
+ stat('待核验', cachedTask.matchSummary.reviewRequired, cachedTask.matchSummary.reviewRequired > 0 ? 'warn' : null),
5593
+ stat('未匹配', cachedTask.matchSummary.unresolved, cachedTask.matchSummary.unresolved > 0 ? 'warn' : null),
5594
+ stat('失败', cachedTask.matchSummary.failed, cachedTask.matchSummary.failed > 0 ? 'bad' : null),
5595
+ ),
4702
5596
  ) : null,
4703
5597
  );
4704
5598
  } else if (step === 'enrich') {
5599
+ const persistedEnrichment = cachedTask?.enrichmentSummary;
5600
+ const hasRuntimeEnrichment = Boolean(clean || complete || qccRun);
4705
5601
  pane = h('div', { className: 'dcAgentPane' },
4706
5602
  h('p', { className: 'dcAgentHint' }, '执行本地确定性清洗并核对本任务所选补全字段。外部字段以当前用户企查查 MCP 的真实返回为准,缺失值不会被编造。'),
4707
5603
  h('section', { className: 'dcAgentSection' },
@@ -4710,30 +5606,55 @@ body:has(.dcAgentPromptBackdrop) { overflow: hidden; }
4710
5606
  ),
4711
5607
  h('section', { className: 'dcAgentSection' },
4712
5608
  h('h3', null, '本地清洗预处理'),
4713
- h('div', { className: 'dcAgentRow' },
4714
- h('button', { type: 'button', className: 'dcAgentButton', disabled: busy || !hasData, 'aria-label': '执行清洗', onClick: runClean }, busy ? '处理中…' : '执行确定性清洗'),
4715
- h('button', { type: 'button', className: 'dcAgentButton', disabled: busy || !hasData, 'aria-label': '执行补全', onClick: runComplete }, busy ? '处理中…' : '本地规则补全'),
4716
- ),
5609
+ canAction('run-local-clean') || canAction('run-local-complete') ? h('div', { className: 'dcAgentRow' },
5610
+ canAction('run-local-clean') ? h('button', { type: 'button', className: 'dcAgentButton', disabled: busy || !hasData, 'aria-label': '执行清洗', onClick: runClean }, busy ? '处理中…' : '执行确定性清洗') : null,
5611
+ canAction('run-local-complete') ? h('button', { type: 'button', className: 'dcAgentButton', disabled: busy || !hasData, 'aria-label': '执行补全', onClick: runComplete }, busy ? '处理中…' : '本地规则补全') : null,
5612
+ ) : null,
4717
5613
  clean ? h('div', { className: 'dcAgentGrid' },
4718
5614
  stat('总数', clean.total), stat('保留', clean.kept, 'good'), stat('剔除', clean.dropped, 'bad'),
4719
5615
  stat('缺失关键字段', clean.badMissing, clean.badMissing > 0 ? 'bad' : null),
4720
5616
  stat('重复', clean.badDuplicate, clean.badDuplicate > 0 ? 'warn' : null),
4721
5617
  ) : null,
4722
5618
  ),
4723
- h('div', { className: 'dcAgentGrid' },
4724
- stat('输入行数', dataset ? dataset.rowCount : '—'),
4725
- stat('清洗保留', clean ? clean.kept : '—', clean && clean.kept > 0 ? 'good' : null),
4726
- stat('本地补全', complete ? complete.completed : '—', complete && complete.completed > 0 ? 'good' : null),
4727
- stat('QCC 已补全', qccEnrichedCount, qccEnrichedCount > 0 ? 'good' : null),
4728
- stat('待核验', qccReviewCount, qccReviewCount > 0 ? 'warn' : null),
4729
- ),
4730
- h('div', { className: 'dcAgentRow' }, h('button', { type: 'button', className: 'dcAgentButton is-primary', onClick: () => actions.setStep('download') }, '进入下载数据')),
5619
+ persistedEnrichment && !hasRuntimeEnrichment ? h('section', { className: 'dcAgentSection', 'aria-label': '字段补全结果摘要' },
5620
+ h('h3', null, '字段补全结果'),
5621
+ h('p', { className: 'dcAgentHint', role: 'status' }, '以下为 Host 保存的任务摘要;逐行补全结果请从结果下载查看。'),
5622
+ h('div', { className: 'dcAgentGrid' },
5623
+ stat('总数', persistedEnrichment.total),
5624
+ stat('已补全', persistedEnrichment.completed, persistedEnrichment.completed > 0 ? 'good' : null),
5625
+ stat('原值保留', persistedEnrichment.unchanged),
5626
+ stat('待核验', persistedEnrichment.reviewRequired, persistedEnrichment.reviewRequired > 0 ? 'warn' : null),
5627
+ stat('失败', persistedEnrichment.failed, persistedEnrichment.failed > 0 ? 'bad' : null),
5628
+ ),
5629
+ ) : h('div', { className: 'dcAgentGrid' },
5630
+ stat('输入行数', dataset ? dataset.rowCount : '—'),
5631
+ stat('清洗保留', clean ? clean.kept : '—', clean && clean.kept > 0 ? 'good' : null),
5632
+ stat('本地补全', complete ? complete.completed : '—', complete && complete.completed > 0 ? 'good' : null),
5633
+ stat('QCC 已补全', qccEnrichedCount, qccEnrichedCount > 0 ? 'good' : null),
5634
+ stat('待核验', qccReviewCount, qccReviewCount > 0 ? 'warn' : null),
5635
+ ),
5636
+ canAction('create-local-artifacts') ? h('div', { className: 'dcAgentRow' }, h('button', {
5637
+ type: 'button', className: 'dcAgentButton is-primary', disabled: busy || !exportRows()?.length,
5638
+ onClick: () => createAndDownload('complete', 'xlsx'),
5639
+ }, '生成结果')) : null,
4731
5640
  );
4732
5641
  } else if (step === 'download') {
4733
- const availableArtifacts = (cachedTask?.artifacts || []).filter(artifact => artifact.format === 'xlsx' && (artifact.kind !== 'review' || artifact.rowCount > 0));
5642
+ const artifactsByKind = new Map();
5643
+ for (const artifact of cachedTask?.artifacts || []) {
5644
+ if (artifact.format === 'xlsx' && (artifact.kind !== 'review' || artifact.rowCount > 0)) {
5645
+ artifactsByKind.set(artifact.kind, artifact);
5646
+ }
5647
+ }
5648
+ const availableArtifacts = [...artifactsByKind.values()];
4734
5649
  const resultArtifacts = availableArtifacts.filter(artifact => artifact.kind !== 'report');
4735
5650
  const reportArtifact = availableArtifacts.find(artifact => artifact.kind === 'report');
4736
- const canCreateArtifacts = Boolean(exportRows()?.length) && ['rules_confirmed', 'diagnosed', 'export_ready', 'partial'].includes(cachedTask?.state);
5651
+ const canCreateArtifacts = Boolean(exportRows()?.length) && canAction('create-local-artifacts');
5652
+ const retryableErrors = (qccRun?.errors || []).filter((item) => item.error?.retryable);
5653
+ const canRetryFailures = canAction('retry-failed')
5654
+ && cachedTask?.runtimeCapabilities?.liveRunAvailable
5655
+ && retryableErrors.length > 0;
5656
+ const canRetryDelivery = canAction('retry-delivery')
5657
+ && cachedTask?.runtimeCapabilities?.liveRunAvailable;
4737
5658
  pane = h('div', { className: 'dcAgentPane' },
4738
5659
  h('p', { className: 'dcAgentHint' }, '结果文件由 Host 保存;有失败、未匹配或待核验记录时展示异常清单。任务或插件重启后,可从任务历史继续下载;无需再次调用企查查。'),
4739
5660
  h('div', { className: 'dcAgentResultSummary' }, h('div', { className: 'dcAgentGrid' },
@@ -4770,10 +5691,21 @@ body:has(.dcAgentPromptBackdrop) { overflow: hidden; }
4770
5691
  ),
4771
5692
  !canCreateArtifacts ? h('p', { className: 'dcAgentHint' }, '请先完成规则确认与清洗补全;历史任务若尚未生成制品,需要重新上传源文件。') : null,
4772
5693
  ),
5694
+ canRetryFailures ? h('section', { className: 'dcAgentSection' },
5695
+ h('label', { className: 'dcAgentCheck' },
5696
+ h('input', { type: 'checkbox', checked: paidConfirmed, 'aria-label': '确认重试的企查查调用', onChange: (event) => actions.setPaidConfirmed(event.target.checked) }),
5697
+ '重试会继续查询,使用当前连接的企查查账号;额度或费用由该账号自行承担。',
5698
+ ),
5699
+ h('button', { type: 'button', className: 'dcAgentButton', disabled: busy || !paidConfirmed, onClick: retryFailures },
5700
+ `重试可恢复失败项 · ${retryableErrors.length}`),
5701
+ ) : null,
5702
+ canRetryDelivery ? h('button', {
5703
+ type: 'button', className: 'dcAgentButton', disabled: busy, onClick: retryDelivery,
5704
+ }, '重新生成结果文件') : null,
4773
5705
  );
4774
5706
  } else {
4775
5707
  pane = h('div', { className: 'dcAgentPane' },
4776
- dataset ? h('section', { className: 'dcAgentSection dcAgentIntakeSummary', 'aria-label': '当前导入数据', role: 'status' },
5708
+ dataset || cachedTask?.source ? h('section', { className: 'dcAgentSection dcAgentIntakeSummary', 'aria-label': '当前导入数据', role: 'status' },
4777
5709
  h('div', { className: 'dcAgentIntakeSummaryHead' },
4778
5710
  h('div', null,
4779
5711
  h('h3', null, intake.available ? `已解析 ${intake.count} 条数据` : '已保存导入摘要'),
@@ -4781,8 +5713,12 @@ body:has(.dcAgentPromptBackdrop) { overflow: hidden; }
4781
5713
  ),
4782
5714
  ),
4783
5715
  h('p', { className: 'dcAgentHint' }, intake.available
4784
- ? '数据已导入。请核对下方完整名单;需要替换来源时点击“重新导入”。'
4785
- : '当前页面没有完整原始数据。请重新导入原文件;历史下载制品仍可从任务历史获取。'),
5716
+ ? canAction('import-data')
5717
+ ? '数据已导入。请核对下方完整名单;需要替换来源时点击“重新导入”。'
5718
+ : '这是已导入来源的只读摘要。更换名单请从任务历史新建任务并复用配置。'
5719
+ : canAction('import-data')
5720
+ ? '当前页面没有完整原始数据。请重新导入原文件;历史下载制品仍可从任务历史获取。'
5721
+ : '当前页面只保留 Host 来源摘要;已有制品可从结果下载或任务历史获取。'),
4786
5722
  ) : null,
4787
5723
  showIntakeEditor ? h('section', { id: 'dc-agent-intake-editor', className: 'dcAgentSection dcAgentIntakeEditor', 'aria-label': dataset ? '重新导入数据' : '导入数据' },
4788
5724
  h('header', { className: 'dcAgentIntakeEditorHeader' },
@@ -4840,16 +5776,47 @@ body:has(.dcAgentPromptBackdrop) { overflow: hidden; }
4840
5776
  }) : null,
4841
5777
  hasData ? h('p', { className: 'dcAgentHint' }, '请核对完整名单。识别清单不代表已完成企查查匹配。核对后进入「规则与体检」,确认规则生成质量体检;再进入「主体匹配」直接生成可编辑任务说明。Host 自动检查工具和调用范围,回填中央对话框后点击发送才开始查询。') : null,
4842
5778
  h('div', { className: 'dcAgentUploadActions' },
4843
- hasData && !intakeEditorOpen ? h('button', {
5779
+ hasData && !intakeEditorOpen && canAction('import-data') ? h('button', {
4844
5780
  type: 'button', className: 'dcAgentButton', disabled: busy,
4845
5781
  'aria-expanded': false, 'aria-controls': 'dc-agent-intake-editor',
4846
5782
  onClick: () => { actions.setError(null); setIntakeEditorOpen(true); },
4847
5783
  }, '重新导入') : null,
4848
- hasData ? h('button', { type: 'button', className: 'dcAgentButton is-primary', disabled: busy || !intake.available || intake.pendingText, onClick: () => { actions.setError(null); actions.setStep('rules'); } }, '已核对清单,下一步:字段映射与规则') : null,
5784
+ hasData && (canAction('import-data') || reviewingImportedDataset) ? h('button', {
5785
+ type: 'button', className: 'dcAgentButton is-primary',
5786
+ disabled: busy || !intake.available || intake.pendingText,
5787
+ onClick: () => navigate('rules'),
5788
+ }, reviewingImportedDataset ? '确认名单,进入规则与体检' : '已核对清单,下一步:字段映射与规则') : null,
4849
5789
  ),
4850
5790
  );
4851
5791
  }
4852
5792
 
5793
+ const runtimeRecoveryPane = runtimeRecoveryAllowed ? h('div', { className: 'dcAgentPane' },
5794
+ h('section', { className: 'dcAgentSection', role: 'status' },
5795
+ h('h3', null, '重新载入原任务数据'),
5796
+ h('p', { className: 'dcAgentHint' }, cachedTask.source?.checksum
5797
+ ? '原始名单未持久化。请选择或粘贴同一份数据;Host 校验一致后只恢复本页运行时数据,不改变任务状态。'
5798
+ : '该旧任务缺少同源校验信息,不能恢复写操作。'),
5799
+ cachedTask.source?.checksum ? h('div', { className: 'dcAgentRow' },
5800
+ h('button', { type: 'button', className: 'dcAgentButton', disabled: busy,
5801
+ onClick: (event) => event.currentTarget.nextElementSibling?.click() }, '选择原文件'),
5802
+ h('input', { type: 'file', accept: '.csv,.json,.xlsx,.xls', style: { display: 'none' },
5803
+ disabled: busy, 'aria-label': '重新载入原任务文件', onChange: recoverRuntimeFile }),
5804
+ ) : null,
5805
+ cachedTask.source?.checksum ? h('textarea', {
5806
+ className: 'dcAgentTextarea', value: input, disabled: busy,
5807
+ placeholder: '也可以粘贴原 CSV、JSON 或逐行企业名单',
5808
+ 'aria-label': '重新载入原任务文本',
5809
+ onInput: (event) => { actions.setInput(event.target.value); actions.setError(null); },
5810
+ }) : null,
5811
+ cachedTask.source?.checksum ? h('button', {
5812
+ type: 'button', className: 'dcAgentButton', disabled: busy || !String(input || '').trim(),
5813
+ onClick: recoverRuntimeText,
5814
+ }, '校验并恢复') : null,
5815
+ h('button', { type: 'button', className: 'dcAgentButton', onClick: () => startNewWorkflow(true) },
5816
+ '新建任务并复用配置'),
5817
+ ),
5818
+ ) : null;
5819
+
4853
5820
  return h('section', {
4854
5821
  className: 'dcAgentWorkbenchContent',
4855
5822
  'data-dsh-plugin': 'data-cleaning-agent',
@@ -4858,54 +5825,75 @@ body:has(.dcAgentPromptBackdrop) { overflow: hidden; }
4858
5825
  },
4859
5826
  h('nav', { className: 'dcAgentManagement', 'aria-label': '工作台管理' },
4860
5827
  h('button', { type: 'button', className: 'dcAgentButton', 'aria-pressed': step !== 'history',
4861
- onClick: () => actions.setStep(cachedTask?.stage || 'upload') }, '当前任务'),
5828
+ onClick: () => navigate(cachedTask?.flow?.currentStage || cachedTask?.stage || 'upload') }, '当前任务'),
4862
5829
  h('button', { type: 'button', className: 'dcAgentButton', 'aria-pressed': step === 'history',
4863
- onClick: () => actions.setStep('history') }, '任务历史'),
5830
+ onClick: () => navigate('history') }, '任务历史'),
4864
5831
  ),
4865
5832
  step !== 'history' ? h('nav', { className: 'dcAgentStepper', 'aria-label': '清洗流程',
4866
5833
  style: { '--dc-stage-count': STEPS.length } },
4867
- STEPS.map((st) => h('button', {
4868
- key: st.key,
4869
- type: 'button',
4870
- className: `dcAgentStep${(step === 'profile' ? 'rules' : step) === st.key ? ' is-active' : ''}`,
4871
- 'aria-label': st.label,
4872
- title: st.label,
4873
- 'aria-current': (step === 'profile' ? 'rules' : step) === st.key ? 'step' : undefined,
4874
- onClick: () => actions.setStep(st.key),
4875
- }, h('span', { className: 'dcAgentStepIcon' }, h(StageIcon, { kind: st.icon })),
4876
- h('span', { className: 'dcAgentStepLabel' }, st.label))),
5834
+ STEPS.map((st) => {
5835
+ const access = cachedTask
5836
+ ? resolveStageAccess(st.key, cachedTask.flow)
5837
+ : st.key === 'upload' ? 'current' : 'locked';
5838
+ const locked = access === 'locked';
5839
+ const lockHint = locked
5840
+ ? `尚未开放,请先完成“${currentStageLabel}”`
5841
+ : st.label;
5842
+ return h('button', {
5843
+ key: st.key,
5844
+ type: 'button',
5845
+ className: `dcAgentStep${(step === 'profile' ? 'rules' : step) === st.key ? ' is-active' : ''}`,
5846
+ 'aria-label': st.label,
5847
+ title: lockHint,
5848
+ 'aria-current': (step === 'profile' ? 'rules' : step) === st.key ? 'step' : undefined,
5849
+ 'aria-disabled': locked ? 'true' : undefined,
5850
+ 'aria-description': locked ? lockHint : undefined,
5851
+ 'data-access': access,
5852
+ onClick: () => navigate(st.key),
5853
+ }, h('span', { className: 'dcAgentStepIcon' }, h(StageIcon, { kind: st.icon })),
5854
+ h('span', { className: 'dcAgentStepLabel' }, st.label));
5855
+ }),
4877
5856
  ) : null,
4878
5857
  h('div', { className: 'dcAgentWbBody' },
4879
- cachedTask && step !== 'history' ? h('section', { className: 'dcAgentSection', 'aria-label': '当前任务进展', 'aria-live': 'polite' },
4880
- h('b', null, cachedTask.source?.type === 'image' && cachedTask.state === 'draft' ? '图片已暂存,待识别' : `任务记录:${WORKFLOW_STATE_LABELS[cachedTask.state] || '状态未知'}`),
4881
- h('h3', null, workflowDisplayName(cachedTask)),
4882
- ['matching', 'enriching'].includes(cachedTask.state) ? h('p', { className: 'dcAgentHint' }, '以上为最近同步的任务记录,不代表模型仍在运行。若会话已停止,请查看原会话的确认或错误提示;不要重复启动。') : null,
4883
- h('details', null, h('summary', null, '任务详情'), h('p', { className: 'dcAgentHint' }, `任务编号:${cachedTask.id}`)),
4884
- h('p', null, `${cachedTask.source?.type === 'image' ? '图片识别' : cachedTask.source?.type === 'text' ? '粘贴名单' : cachedTask.source ? '上传表格' : '待选择数据来源'}${cachedTask.source?.fileName ? ' · ' + cachedTask.source.fileName : ''} · ${cachedTask.source?.type === 'image' && cachedTask.state === 'draft' ? '条目数待识别' : `${cachedTask.source?.rowCount || 0} 条`} · ${cachedTask.fieldSelection?.length || 0} 个补全字段`),
4885
- commandProgress?.taskId === cachedTask.id && Number.isFinite(commandProgress.totalUnique) ? h('p', null, `已处理主体 ${commandProgress.completedUnique}/${commandProgress.totalUnique}(去重后,含未匹配或失败,不代表补全成功)`) : null,
4886
- commandProgress?.taskId === cachedTask.id && Number.isFinite(commandProgress.totalUnique) && commandProgress.totalUnique > 0 ? h('progress', {
4887
- max: commandProgress.totalUnique, value: Math.min(commandProgress.totalUnique, Math.max(0, commandProgress.completedUnique || 0)),
4888
- 'aria-label': '主体处理进度', style: { width: '100%', accentColor: cachedTask.state === 'completed' ? '#16845b' : cachedTask.state === 'failed' ? '#c62828' : cachedTask.state === 'partial' ? '#a66a00' : '#087dcc' },
5858
+ step !== 'history' ? h('section', { className: 'dcAgentTaskContext', 'aria-label': '任务状态与流程说明' },
5859
+ cachedTask ? h(WorkflowTaskContext, {
5860
+ task: cachedTask,
5861
+ stageLabel: viewedStageLabel,
5862
+ viewAccess,
5863
+ currentStageLabel,
5864
+ statusLabel: taskStatusLabel,
5865
+ stageCompleted: viewedStageCompleted,
5866
+ timeLabel: taskTimeLabel,
5867
+ timeValue: taskTimestamp,
5868
+ sourceLabel: taskSourceLabel,
5869
+ rowCount: taskRowCount,
5870
+ fieldCount: taskFieldCount,
5871
+ hasResultSummary: hasTaskResultSummary,
5872
+ completedMetric: taskCompletedMetric,
5873
+ reviewMetric: taskReviewMetric,
5874
+ failedMetric: taskFailedMetric,
5875
+ commandProgress,
5876
+ onReturn: reviewingImportedDataset
5877
+ ? null
5878
+ : () => navigate(cachedTask?.flow?.currentStage || cachedTask?.stage || 'upload'),
4889
5879
  }) : null,
4890
- h('p', { style: { color: qccFailedCount > 0 ? '#c62828' : Number(qccReviewCount) > 0 ? '#a66a00' : qccEnrichedCount > 0 ? '#16845b' : 'inherit' } },
4891
- cachedTask.source?.type === 'image' && cachedTask.state === 'draft' ? '请发送对话中的任务说明开始图片识别,识别后核验企业名单。' : Number.isFinite(qccEnrichedCount) ? `已处理并补全 ${qccEnrichedCount} 条 · 待核验 ${Number.isFinite(qccReviewCount) ? qccReviewCount : '统计中'} 条 · 失败 ${qccFailedCount} 条(按输入行统计;未披露字段保留空白)` : '结果统计中,处理结束后展示补全与待核验数量'),
4892
- ) : null,
4893
- error ? h('div', { className: 'dcAgentError', role: 'alert' }, error) : null,
4894
- step !== 'history' ? h('details', { className: 'dcAgentSection' },
4895
- h('summary', null, '使用说明与任务流程'),
4896
- h('p', { className: 'dcAgentHint' }, '导入与核验 → 规则与体检 → 主体匹配 → 字段补全 → 结果下载。菜单只切换视图,不启动调用;按页面主按钮逐步确认。'),
4897
- h('p', { className: 'dcAgentHint' }, '支持文本、Excel、JSON 和图片名单。原值保留、多候选人工核验;企查查调用使用当前用户自己的连接与额度。'),
5880
+ h(WorkflowGuide),
4898
5881
  ) : null,
5882
+ error ? h('div', {
5883
+ className: error.startsWith('流程提示:') ? 'dcAgentNavigationNotice' : 'dcAgentError',
5884
+ role: error.startsWith('流程提示:') ? 'status' : 'alert',
5885
+ 'aria-live': 'polite',
5886
+ }, error) : null,
4899
5887
  ['rules', 'profile'].includes(step) ? h('nav', { className: 'dcAgentViewTabs', 'aria-label': '规则与体检视图' },
4900
- h('button', { type: 'button', className: 'dcAgentButton', 'aria-pressed': step === 'rules', onClick: () => actions.setStep('rules') }, '字段映射与规则'),
4901
- h('button', { type: 'button', className: 'dcAgentButton', 'aria-pressed': step === 'profile', onClick: () => actions.setStep('profile') }, '质量体检报告'),
5888
+ h('button', { type: 'button', className: 'dcAgentButton', 'aria-pressed': step === 'rules', onClick: () => navigate('rules') }, '字段映射与规则'),
5889
+ h('button', { type: 'button', className: 'dcAgentButton', 'aria-pressed': step === 'profile', onClick: () => navigate('profile') }, '质量体检报告'),
4902
5890
  ) : null,
4903
- workflowNavigationIssue(step, cachedTask, hasData, profile, qccRun, Boolean((clean || complete) && exportRows()?.length))
5891
+ runtimeRecoveryPane || (workflowNavigationIssue(step, cachedTask, hasData, profile, qccRun, Boolean((clean || complete) && exportRows()?.length))
4904
5892
  ? h('section', { className: 'dcAgentSection', role: 'status' },
4905
5893
  h('p', null, workflowNavigationIssue(step, cachedTask, hasData, profile, qccRun, Boolean((clean || complete) && exportRows()?.length))),
4906
5894
  h('button', { type: 'button', className: 'dcAgentButton',
4907
- onClick: () => actions.setStep(!hasData ? 'upload' : !cachedTask || ['draft', 'uploaded'].includes(cachedTask.state) ? 'rules' : cachedTask.state === 'rules_confirmed' ? 'profile' : step === 'download' ? 'enrich' : 'match') }, '前往前置步骤'))
4908
- : pane,
5895
+ onClick: () => navigate(cachedTask?.flow?.currentStage || cachedTask?.stage || 'upload') }, '返回当前节点'))
5896
+ : pane),
4909
5897
  ),
4910
5898
  );
4911
5899
  }
@@ -5064,7 +6052,12 @@ body:has(.dcAgentPromptBackdrop) { overflow: hidden; }
5064
6052
  react.useEffect(() => {
5065
6053
  instance.actions.openAt(null, props.scope.sessionId);
5066
6054
  return adapter.attach(props.scope, { tabId: props.tab.id, store: props.store,
5067
- navigate: destination => instance.actions.setStep(destination || instance.getSnapshot().step) });
6055
+ navigate: destination => navigateWorkflow(
6056
+ instance.actions,
6057
+ props.scope.sessionId,
6058
+ destination || instance.getSnapshot().step,
6059
+ instance.getSnapshot().workflowTask,
6060
+ ) });
5068
6061
  }, [instance, props.scope.sessionId, props.tab.id, props.store]);
5069
6062
  return h(WorkbenchContent, { useStore, actions: instance.actions, visible: props.visible,
5070
6063
  sendSessionCommand: (sessionId, prompt) => sendQccAgentCommand(ctx, sessionId, prompt),
@@ -5139,13 +6132,29 @@ body:has(.dcAgentPromptBackdrop) { overflow: hidden; }
5139
6132
  open(sessionId, step) {
5140
6133
  if (disposed || !sessionId) return false;
5141
6134
  const instance = instanceFor(sessionId);
5142
- instance.actions.openAt(step, sessionId);
6135
+ const destination = workflowDestination(
6136
+ step,
6137
+ workflowTaskBySession.get(String(sessionId)) ?? instance.getSnapshot().workflowTask,
6138
+ instance.getSnapshot().step,
6139
+ );
6140
+ instance.actions.openAt(destination.stage, sessionId);
5143
6141
  const sessions = typeof ctx.get === 'function' ? ctx.get('sessions') : ctx.sessions;
5144
6142
  const summary = sessions?.list?.getSnapshot?.()?.byId?.[sessionId];
5145
6143
  openingSession = sessionId;
5146
- try { return adapter.open({ sessionId, ...(summary?.cwd ? { cwd: summary.cwd } : {}) },
5147
- step || instance.getSnapshot().step); }
6144
+ let opened;
6145
+ try { opened = adapter.open({ sessionId, ...(summary?.cwd ? { cwd: summary.cwd } : {}) },
6146
+ destination.stage); }
5148
6147
  finally { openingSession = null; }
6148
+ if (destination.compatibility) {
6149
+ instance.actions.setError('页面与 Host 流程版本不一致,当前任务已切换为只读模式;请完整重启并升级 Host。');
6150
+ } else if (destination.access === 'locked') {
6151
+ instance.actions.setError(lockedWorkflowNotice(
6152
+ step,
6153
+ workflowTaskBySession.get(String(sessionId)) ?? instance.getSnapshot().workflowTask,
6154
+ destination.stage,
6155
+ ));
6156
+ }
6157
+ return opened;
5149
6158
  },
5150
6159
  };
5151
6160
  sessionWorkbenchController = controller;
@@ -5154,7 +6163,7 @@ body:has(.dcAgentPromptBackdrop) { overflow: hidden; }
5154
6163
  [WORKBENCH_OPEN_EVENT]: event => {
5155
6164
  const detail = event.detail || {};
5156
6165
  const actions = controller.actionsFor(detail.sessionId);
5157
- if (detail.task?.id) cacheWorkflowTask(actions, detail.sessionId, detail.task);
6166
+ if (detail.task?.id) cacheWorkflowTask(actions, detail.sessionId, detail.task, 'restore');
5158
6167
  controller.open(detail.sessionId, detail.step);
5159
6168
  },
5160
6169
  [WORKBENCH_DATASET_EVENT]: event => {
@@ -5307,12 +6316,16 @@ body:has(.dcAgentPromptBackdrop) { overflow: hidden; }
5307
6316
  visibleCatalogFields,
5308
6317
  installCapabilityMount,
5309
6318
  PromptDialog,
6319
+ normalizeViewStage,
6320
+ resolveStageAccess,
6321
+ workflowDestination,
6322
+ lockedWorkflowNotice,
6323
+ navigateWorkflow,
5310
6324
  workflowNavigationIssue,
5311
6325
  buildTaskPrompt,
5312
6326
  DatasetReview,
5313
6327
  reviewColumns,
5314
6328
  reviewCsv,
5315
- clearCleaningDraft,
5316
6329
  entriesToDataset,
5317
6330
  extractPromptEntries,
5318
6331
  imageFilesFromTransfer,
@@ -5320,6 +6333,9 @@ body:has(.dcAgentPromptBackdrop) { overflow: hidden; }
5320
6333
  requestPromptImage,
5321
6334
  guessMappings,
5322
6335
  intakeState,
6336
+ restoreParsedRuntime,
6337
+ runtimeFor,
6338
+ verifyRestoredRuntime,
5323
6339
  projectCompletionResult,
5324
6340
  mappingRecommendations,
5325
6341
  mappedOutputFields,
@@ -5332,6 +6348,12 @@ body:has(.dcAgentPromptBackdrop) { overflow: hidden; }
5332
6348
  installNewSessionBridge,
5333
6349
  isCleaningSession,
5334
6350
  isKnownCleaningDraft,
6351
+ wizardListEntries,
6352
+ invalidListInstruction,
6353
+ INITIAL_DRAFT_TEMPLATE,
6354
+ initializeCleaningDraft,
6355
+ emptyInitialInput,
6356
+ startCleaningSession,
5335
6357
  markCleaningSession,
5336
6358
  qualitySummaryFor,
5337
6359
  plainEntityListDataset,
@@ -5340,6 +6362,7 @@ body:has(.dcAgentPromptBackdrop) { overflow: hidden; }
5340
6362
  observeHomePhase,
5341
6363
  ensureWorkflowTask,
5342
6364
  ensureEditableWorkflowTask,
6365
+ refreshHostedTask,
5343
6366
  queueWorkflowOperation,
5344
6367
  taskBelongsToSession,
5345
6368
  workflowOriginLabel,