dsh-data-domain-knowledge 0.1.0-alpha.13 → 0.1.0-alpha.15

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.
Files changed (266) hide show
  1. package/README.md +89 -56
  2. package/cordis.patch.yml +36 -129
  3. package/eval/benchmark-extraction-format.mjs +20 -0
  4. package/lib/client.js +3262 -4142
  5. package/lib/doc-page.js +1 -1
  6. package/lib/pages.js +1147 -364
  7. package/lib/types/axis-registry.d.ts +8 -1
  8. package/lib/types/axis-registry.d.ts.map +1 -1
  9. package/lib/types/axis-registry.js +62 -7
  10. package/lib/types/axis-registry.js.map +1 -1
  11. package/lib/types/convert/host.d.ts.map +1 -1
  12. package/lib/types/convert/host.js +3 -0
  13. package/lib/types/convert/host.js.map +1 -1
  14. package/lib/types/convert/ingest.d.ts +9 -0
  15. package/lib/types/convert/ingest.d.ts.map +1 -1
  16. package/lib/types/convert/ingest.js +68 -9
  17. package/lib/types/convert/ingest.js.map +1 -1
  18. package/lib/types/diagnosis/cards.d.ts +7 -3
  19. package/lib/types/diagnosis/cards.d.ts.map +1 -1
  20. package/lib/types/diagnosis/cards.js +13 -8
  21. package/lib/types/diagnosis/cards.js.map +1 -1
  22. package/lib/types/diagnosis/run.d.ts +4 -13
  23. package/lib/types/diagnosis/run.d.ts.map +1 -1
  24. package/lib/types/diagnosis/run.js +26 -16
  25. package/lib/types/diagnosis/run.js.map +1 -1
  26. package/lib/types/diagnosis/schema.js +2 -2
  27. package/lib/types/diagnosis/schema.js.map +1 -1
  28. package/lib/types/diagnosis/seed-source.d.ts +1 -1
  29. package/lib/types/diagnosis/seed-source.d.ts.map +1 -1
  30. package/lib/types/diagnosis/seed-source.js +5 -4
  31. package/lib/types/diagnosis/seed-source.js.map +1 -1
  32. package/lib/types/diagnosis/seed.d.ts +4 -4
  33. package/lib/types/diagnosis/seed.d.ts.map +1 -1
  34. package/lib/types/diagnosis/seed.js +6 -8
  35. package/lib/types/diagnosis/seed.js.map +1 -1
  36. package/lib/types/diagnosis/store.d.ts +18 -17
  37. package/lib/types/diagnosis/store.d.ts.map +1 -1
  38. package/lib/types/diagnosis/store.js +94 -66
  39. package/lib/types/diagnosis/store.js.map +1 -1
  40. package/lib/types/diagnosis/types.d.ts +10 -16
  41. package/lib/types/diagnosis/types.d.ts.map +1 -1
  42. package/lib/types/diagnosis/types.js.map +1 -1
  43. package/lib/types/environment/dsh-python.d.ts +77 -0
  44. package/lib/types/environment/dsh-python.d.ts.map +1 -0
  45. package/lib/types/environment/dsh-python.js +139 -0
  46. package/lib/types/environment/dsh-python.js.map +1 -0
  47. package/lib/types/environment/install.d.ts +9 -1
  48. package/lib/types/environment/install.d.ts.map +1 -1
  49. package/lib/types/environment/install.js +149 -24
  50. package/lib/types/environment/install.js.map +1 -1
  51. package/lib/types/environment/model-catalog.d.ts +34 -2
  52. package/lib/types/environment/model-catalog.d.ts.map +1 -1
  53. package/lib/types/environment/model-catalog.js +40 -2
  54. package/lib/types/environment/model-catalog.js.map +1 -1
  55. package/lib/types/environment/service.d.ts +13 -3
  56. package/lib/types/environment/service.d.ts.map +1 -1
  57. package/lib/types/environment/service.js +127 -23
  58. package/lib/types/environment/service.js.map +1 -1
  59. package/lib/types/environment/types.d.ts +9 -0
  60. package/lib/types/environment/types.d.ts.map +1 -1
  61. package/lib/types/events.d.ts +2 -0
  62. package/lib/types/events.d.ts.map +1 -1
  63. package/lib/types/events.js +13 -0
  64. package/lib/types/events.js.map +1 -1
  65. package/lib/types/extract-contract.d.ts +1 -1
  66. package/lib/types/extract-contract.d.ts.map +1 -1
  67. package/lib/types/extract-contract.js +34 -31
  68. package/lib/types/extract-contract.js.map +1 -1
  69. package/lib/types/extract-model.d.ts +2 -0
  70. package/lib/types/extract-model.d.ts.map +1 -1
  71. package/lib/types/extract-model.js.map +1 -1
  72. package/lib/types/extract-table-contract.d.ts +3 -0
  73. package/lib/types/extract-table-contract.d.ts.map +1 -0
  74. package/lib/types/extract-table-contract.js +30 -0
  75. package/lib/types/extract-table-contract.js.map +1 -0
  76. package/lib/types/extract-table.d.ts +8 -0
  77. package/lib/types/extract-table.d.ts.map +1 -0
  78. package/lib/types/extract-table.js +151 -0
  79. package/lib/types/extract-table.js.map +1 -0
  80. package/lib/types/extraction-runs.d.ts +30 -6
  81. package/lib/types/extraction-runs.d.ts.map +1 -1
  82. package/lib/types/extraction-runs.js +69 -8
  83. package/lib/types/extraction-runs.js.map +1 -1
  84. package/lib/types/fs-atomic.d.ts +0 -7
  85. package/lib/types/fs-atomic.d.ts.map +1 -1
  86. package/lib/types/fs-atomic.js +17 -2
  87. package/lib/types/fs-atomic.js.map +1 -1
  88. package/lib/types/images.d.ts +1 -1
  89. package/lib/types/images.d.ts.map +1 -1
  90. package/lib/types/images.js +2 -2
  91. package/lib/types/images.js.map +1 -1
  92. package/lib/types/index.d.ts.map +1 -1
  93. package/lib/types/index.js +232 -50
  94. package/lib/types/index.js.map +1 -1
  95. package/lib/types/intent/scope.d.ts.map +1 -1
  96. package/lib/types/intent/scope.js +1 -0
  97. package/lib/types/intent/scope.js.map +1 -1
  98. package/lib/types/intent/tools.d.ts +19 -0
  99. package/lib/types/intent/tools.d.ts.map +1 -1
  100. package/lib/types/intent/tools.js +37 -1
  101. package/lib/types/intent/tools.js.map +1 -1
  102. package/lib/types/knowledge-bases.d.ts.map +1 -1
  103. package/lib/types/knowledge-bases.js +5 -16
  104. package/lib/types/knowledge-bases.js.map +1 -1
  105. package/lib/types/knowledge.d.ts +3 -0
  106. package/lib/types/knowledge.d.ts.map +1 -1
  107. package/lib/types/knowledge.js +21 -5
  108. package/lib/types/knowledge.js.map +1 -1
  109. package/lib/types/materialize-snapshot.d.ts +49 -0
  110. package/lib/types/materialize-snapshot.d.ts.map +1 -0
  111. package/lib/types/materialize-snapshot.js +2 -0
  112. package/lib/types/materialize-snapshot.js.map +1 -0
  113. package/lib/types/model-text.d.ts +1 -1
  114. package/lib/types/model-text.d.ts.map +1 -1
  115. package/lib/types/model-text.js +5 -4
  116. package/lib/types/model-text.js.map +1 -1
  117. package/lib/types/oplog.d.ts.map +1 -1
  118. package/lib/types/oplog.js +7 -0
  119. package/lib/types/oplog.js.map +1 -1
  120. package/lib/types/pipeline/engine.d.ts +60 -0
  121. package/lib/types/pipeline/engine.d.ts.map +1 -0
  122. package/lib/types/pipeline/engine.js +338 -0
  123. package/lib/types/pipeline/engine.js.map +1 -0
  124. package/lib/types/pipeline/store.d.ts +73 -0
  125. package/lib/types/pipeline/store.d.ts.map +1 -0
  126. package/lib/types/pipeline/store.js +143 -0
  127. package/lib/types/pipeline/store.js.map +1 -0
  128. package/lib/types/pipeline/types.d.ts +101 -0
  129. package/lib/types/pipeline/types.d.ts.map +1 -0
  130. package/lib/types/pipeline/types.js +5 -0
  131. package/lib/types/pipeline/types.js.map +1 -0
  132. package/lib/types/present.d.ts +2 -0
  133. package/lib/types/present.d.ts.map +1 -1
  134. package/lib/types/present.js +9 -4
  135. package/lib/types/present.js.map +1 -1
  136. package/lib/types/retention.d.ts +39 -0
  137. package/lib/types/retention.d.ts.map +1 -0
  138. package/lib/types/retention.js +42 -0
  139. package/lib/types/retention.js.map +1 -0
  140. package/lib/types/runtime.d.ts +115 -5
  141. package/lib/types/runtime.d.ts.map +1 -1
  142. package/lib/types/runtime.js +862 -132
  143. package/lib/types/runtime.js.map +1 -1
  144. package/lib/types/source-evidence.d.ts +9 -1
  145. package/lib/types/source-evidence.d.ts.map +1 -1
  146. package/lib/types/source-evidence.js +13 -5
  147. package/lib/types/source-evidence.js.map +1 -1
  148. package/lib/types/store/axis-references.js +3 -3
  149. package/lib/types/store/axis-references.js.map +1 -1
  150. package/lib/types/store/derived.d.ts +1 -1
  151. package/lib/types/store/derived.d.ts.map +1 -1
  152. package/lib/types/store/derived.js +41 -7
  153. package/lib/types/store/derived.js.map +1 -1
  154. package/lib/types/store/knowledge-db.d.ts +18 -0
  155. package/lib/types/store/knowledge-db.d.ts.map +1 -1
  156. package/lib/types/store/knowledge-db.js +111 -6
  157. package/lib/types/store/knowledge-db.js.map +1 -1
  158. package/lib/types/store/migrate.js +1 -1
  159. package/lib/types/store/migrate.js.map +1 -1
  160. package/lib/types/trajectory-types.d.ts +172 -0
  161. package/lib/types/trajectory-types.d.ts.map +1 -0
  162. package/lib/types/trajectory-types.js +36 -0
  163. package/lib/types/trajectory-types.js.map +1 -0
  164. package/lib/types/types.d.ts +3 -4
  165. package/lib/types/types.d.ts.map +1 -1
  166. package/lib/types/types.js.map +1 -1
  167. package/lib/types/wiki-finalize.d.ts +16 -0
  168. package/lib/types/wiki-finalize.d.ts.map +1 -1
  169. package/lib/types/wiki-finalize.js +26 -0
  170. package/lib/types/wiki-finalize.js.map +1 -1
  171. package/lib/types/wiki-materialize.d.ts.map +1 -1
  172. package/lib/types/wiki-materialize.js +3 -15
  173. package/lib/types/wiki-materialize.js.map +1 -1
  174. package/lib/types/wiki-trial-types.d.ts +3 -0
  175. package/lib/types/wiki-trial-types.d.ts.map +1 -1
  176. package/lib/types/wiki-trial.d.ts +2 -0
  177. package/lib/types/wiki-trial.d.ts.map +1 -1
  178. package/lib/types/wiki-trial.js +31 -7
  179. package/lib/types/wiki-trial.js.map +1 -1
  180. package/package.json +6 -3
  181. package/preset/tita-data-agent/agent.cordis.yml +36 -129
  182. package/src/axis-registry.ts +57 -9
  183. package/src/client/AttributionEventGuide.tsx +10 -9
  184. package/src/client/CompileDialogs.tsx +10 -2
  185. package/src/client/EnvironmentSetup.tsx +76 -16
  186. package/src/client/ExpansionGraph.tsx +13 -7
  187. package/src/client/ExtractionTasksPage.tsx +543 -156
  188. package/src/client/ImportFilesDialog.tsx +2 -13
  189. package/src/client/IntentTab.tsx +3 -1
  190. package/src/client/KnowledgeBaseTab.tsx +139 -122
  191. package/src/client/KnowledgeLayerSection.tsx +19 -2
  192. package/src/client/KnowledgePanel.tsx +17 -3
  193. package/src/client/ListPager.tsx +15 -5
  194. package/src/client/MaterializeTrajectoryView.tsx +124 -0
  195. package/src/client/NodeSettings.tsx +90 -25
  196. package/src/client/NormalizeTrajectoryView.tsx +136 -0
  197. package/src/client/PipelineNotice.tsx +26 -0
  198. package/src/client/RecallPanel.tsx +55 -25
  199. package/src/client/TabGuide.tsx +4 -4
  200. package/src/client/TrialReportView.tsx +61 -16
  201. package/src/client/TypeDefinitionDrawer.tsx +33 -9
  202. package/src/client/WikiCardPreview.tsx +43 -10
  203. package/src/client/WikiFlowPanel.tsx +4 -4
  204. package/src/client/architecture.tsx +1 -1
  205. package/src/client/attribution/AttributionTab.tsx +37 -36
  206. package/src/client/attribution/CaseView.tsx +3 -1
  207. package/src/client/attribution/EventView.tsx +14 -4
  208. package/src/client/attribution/RunView.tsx +2 -2
  209. package/src/client/attribution/ViewSwitch.tsx +20 -5
  210. package/src/client/attribution/graph-options.ts +38 -31
  211. package/src/client/attribution/model.ts +16 -9
  212. package/src/client/graph/palette.ts +165 -0
  213. package/src/client/pages.css +224 -28
  214. package/src/client/pages.tsx +33 -11
  215. package/src/client/styles.css +61 -6
  216. package/src/client/wiki-trial.ts +30 -1
  217. package/src/convert/host.ts +3 -0
  218. package/src/convert/ingest.ts +73 -9
  219. package/src/diagnosis/cards.ts +13 -8
  220. package/src/diagnosis/run.ts +27 -17
  221. package/src/diagnosis/schema.ts +2 -2
  222. package/src/diagnosis/seed-source.ts +5 -4
  223. package/src/diagnosis/seed.ts +6 -8
  224. package/src/diagnosis/store.ts +99 -69
  225. package/src/diagnosis/types.ts +10 -16
  226. package/src/environment/dsh-python.ts +156 -0
  227. package/src/environment/install.ts +134 -19
  228. package/src/environment/model-catalog.ts +45 -3
  229. package/src/environment/service.ts +111 -23
  230. package/src/environment/types.ts +6 -0
  231. package/src/events.ts +12 -0
  232. package/src/extract-contract.ts +6 -3
  233. package/src/extract-model.ts +2 -0
  234. package/src/extract-table-contract.ts +29 -0
  235. package/src/extract-table.ts +126 -0
  236. package/src/extraction-runs.ts +70 -11
  237. package/src/fs-atomic.ts +19 -2
  238. package/src/images.ts +2 -2
  239. package/src/index.ts +207 -72
  240. package/src/intent/scope.ts +1 -0
  241. package/src/intent/tools.ts +38 -1
  242. package/src/knowledge-bases.ts +4 -12
  243. package/src/knowledge.ts +24 -4
  244. package/src/materialize-snapshot.ts +46 -0
  245. package/src/model-text.ts +5 -3
  246. package/src/oplog.ts +7 -0
  247. package/src/pipeline/engine.ts +312 -0
  248. package/src/pipeline/store.ts +147 -0
  249. package/src/pipeline/types.ts +92 -0
  250. package/src/present.ts +10 -4
  251. package/src/retention.ts +76 -0
  252. package/src/runtime.ts +853 -131
  253. package/src/source-evidence.ts +12 -5
  254. package/src/store/axis-references.ts +3 -3
  255. package/src/store/derived.ts +40 -7
  256. package/src/store/knowledge-db.ts +111 -5
  257. package/src/store/migrate.ts +1 -1
  258. package/src/trajectory-types.ts +142 -0
  259. package/src/types.ts +3 -4
  260. package/src/wiki-finalize.ts +28 -0
  261. package/src/wiki-materialize.ts +3 -14
  262. package/src/wiki-trial-types.ts +3 -0
  263. package/src/wiki-trial.ts +26 -7
  264. package/src/client/convert/ConvertPanel.tsx +0 -583
  265. package/src/client/convert/styles.css +0 -138
  266. package/src/client/convert/styles.ts +0 -49
@@ -1,189 +1,576 @@
1
- import { useEffect, useRef, useState, type ReactNode } from 'react'
2
- import { ChevronDown, CircleX, Clock3, ListOrdered, Pause, Play, RefreshCw } from '@tita-agent/ui/icons'
3
- import { Badge, Button } from '@tita-agent/ui/components'
4
- import { CompileStepList, type CompileStepView } from './CompileDialogs.tsx'
1
+ /**
2
+ * 知识处理与运行记录 —— 一整页。
3
+ *
4
+ * ## 这一页回答三个问题,按顺序,只有三层
5
+ *
6
+ * 1. **现在什么状态**:顶部一行实时结论(在跑哪一段、跑了几分之几;或者「没有在跑」)。
7
+ * 2. **四段各差多少**:一条步骤轨。每格只放三样东西 —— 段名、最近一次的结论、还有多少待办。
8
+ * 3. **这一段的细节**:输入范围、结果摘要、抽取报告、原始输出;右边一栏是「处理范围」与「运行记录」。
9
+ *
10
+ * ## 2026-10-18 为什么重排
11
+ *
12
+ * 原来的版式把所有东西压在同一条竖线里,同一件事被说了三遍:段页签上是「部分失败」,
13
+ * 段头再说一次,结果摘要的第一行又是「状态:部分失败」,下面再写一遍「完成 0/1 · 失败 1」。
14
+ * 而真正要紧的「还剩多少待办」缩成一行 10.5px 的灰字挤在页签上方,模型原始日志却占着一屏。
15
+ *
16
+ * 所以这一版守四条:
17
+ *
18
+ * - **一个事实只出现一次。** 阶段状态只在步骤轨上;段名只在段头;待办计数只在轨上那一格。
19
+ * - **步骤轨横贯整页**,不再缩在左栏里 —— 它是这一页的骨架(有方向的流水线),不是左栏的页签头。
20
+ * - **原始输出默认收起。** 它是排查用的底料,不是结论;任务在跑时自动展开。
21
+ * - **左右两栏各自回答一类问题**:左栏「这一段跑了什么」,右栏「我要跑哪些」+「以前跑过什么」。
22
+ *
23
+ * 分页在服务端(`limit` / `offset`):每跑一次都会往里加,整份在手迟早会变成整份传一遍。
24
+ * 页码状态走 `ListPager`,与知识库其余列表同一个 `PAGE_SIZE`。
25
+ */
26
+
27
+ import { Fragment, useCallback, useEffect, useRef, useState, type ReactNode } from 'react'
28
+ import { Badge, Button, Tabs, TabsContent, TabsList, TabsTrigger } from '@tita-agent/ui/components'
29
+ import { ArrowRight, Check, ChevronDown, ExternalLink, Pause, Play, RefreshCw, X } from '@tita-agent/ui/icons'
30
+ import { openAppPath } from '@tita-agent/ui/overlays'
31
+ import { PIPELINE_ACTIVE_STATES, PIPELINE_STAGE_LABEL, type PipelineEvent, type PipelineStage, type PipelineState, type PipelineStatus, type PipelineTask } from '../pipeline/types.js'
5
32
  import { formatDateTime } from './format.ts'
33
+ import { ListPager, PAGE_SIZE } from './ListPager.tsx'
6
34
  import { requestJson } from './request.ts'
35
+ import { requestExtractionRuns, trajectoryStageOf, type ExtractionRunSummary } from './wiki-trial.ts'
36
+ import { TRAJECTORY_STAGE_LABEL, type TrajectoryStage } from '../trajectory-types.js'
7
37
 
8
- type TaskState = 'queued' | 'running' | 'pausing' | 'paused' | 'cancelling' | 'succeeded' | 'failed' | 'cancelled'
9
- type TaskAction = 'pause' | 'resume' | 'cancel'
10
-
11
- interface ExtractionTask {
12
- runId: string
13
- only: string
14
- state: TaskState
15
- queuedAt: string
16
- startedAt: string
17
- finishedAt?: string
18
- queued?: boolean
19
- sourcesTotal: number
20
- sourcesDone: number
21
- steps: CompileStepView[]
22
- outcome?: string
23
- failed?: boolean
24
- }
38
+ const STATES: Record<PipelineState, string> = { queued: '排队中', running: '进行中', pausing: '暂停中', paused: '已暂停', cancelling: '停止中', succeeded: '已完成', partial: '部分完成', failed: '失败', cancelled: '已停止', interrupted: '已中断' }
39
+ const SOURCE_STATES = { pending: '待抽取', extracted: '待归一', normalized: '已归一', outdated: '来源已更新' }
40
+ const GROUP_STATES = { new: '待新建', changed: '待更新', current: '已物化', withdrawn: '待停用', blocked: '先归一来源' }
41
+ interface History { tasks: PipelineTask[]; total: number }
42
+ const toggle = (ids: string[], id: string): string[] => ids.includes(id) ? ids.filter(value => value !== id) : [...ids, id]
43
+ type StageId = Exclude<PipelineStage, 'all'>
44
+ interface StageObservation { label: string; tone: 'neutral' | 'success' | 'warning' | 'blue'; detail: string; task?: PipelineTask }
25
45
 
26
- const STATE_LABEL: Record<TaskState, string> = {
27
- queued: '排队中', running: '进行中', pausing: '暂停中', paused: '已暂停',
28
- cancelling: '停止中', succeeded: '已完成', failed: '失败', cancelled: '已停止',
29
- }
46
+ const STAGE_ORDER: readonly StageId[] = ['extract', 'normalize', 'materialize', 'build']
30
47
 
31
- const STATE_TONE: Record<TaskState, 'neutral' | 'success' | 'warning' | 'blue'> = {
32
- queued: 'neutral', running: 'blue', pausing: 'warning', paused: 'warning',
33
- cancelling: 'warning', succeeded: 'success', failed: 'warning', cancelled: 'neutral',
48
+ function completedStageObservation(stage: StageId, task: PipelineTask, result: NonNullable<NonNullable<PipelineTask['results']>[StageId]>): StageObservation {
49
+ if (result.failed > 0) return { label: '部分失败', tone: 'warning', detail: `完成 ${Math.max(0, result.done - result.failed)}/${result.total} · 失败 ${result.failed}${result.reused ? ` · 复用 ${result.reused}` : ''}`, task }
50
+ if (stage === 'build' && result.skipped) return { label: '无变化', tone: 'neutral', detail: result.summary ?? '检索索引未变化,无需重建。', task }
51
+ if (result.total === 0) {
52
+ const detail = stage === 'materialize' ? '没有待物化的卡片分组。' : stage === 'normalize' ? '没有来源需要归一。' : stage === 'extract' ? '没有来源可抽取。' : result.summary ?? '没有需要更新的索引内容。'
53
+ return { label: stage === 'materialize' ? '无待办' : stage === 'build' ? '无变化' : '无来源', tone: 'neutral', detail, task }
54
+ }
55
+ if (stage !== 'build' && result.reused === result.total) return { label: '全部复用', tone: 'success', detail: `复用 ${result.reused}/${result.total} 项已有结果。`, task }
56
+ const detail = result.summary ?? (stage === 'build'
57
+ ? `检索索引已更新。`
58
+ : `处理 ${result.done}/${result.total}${result.reused ? ` · 复用 ${result.reused}` : ''}。`)
59
+ return { label: '已完成', tone: 'success', detail, task }
34
60
  }
35
61
 
36
- const TASK_LABEL: Record<string, string> = {
37
- all: '全量重编', wiki: '增量编译', schema: 'Schema 更新', embeddings: '向量重建',
38
- }
62
+ function stageObservation(stage: StageId, tasks: PipelineTask[], status?: PipelineStatus): StageObservation {
63
+ const active = (task: PipelineTask): boolean => PIPELINE_ACTIVE_STATES.includes(task.state)
64
+ const progress = (task: PipelineTask): StageObservation => ({
65
+ label: STATES[task.state],
66
+ tone: task.state === 'paused' ? 'neutral' : 'blue',
67
+ detail: stage === 'build'
68
+ ? '正在构建并发布检索索引。'
69
+ : task.total > 0
70
+ ? `处理 ${task.done}/${task.total} · 复用 ${task.reused}${task.failed ? ` · 失败 ${task.failed}` : ''}`
71
+ : '正在读取本阶段的待处理项。',
72
+ task,
73
+ })
74
+ const running = tasks.find(task => active(task) && task.currentStage === stage)
75
+ if (running) return progress(running)
76
+ const queued = tasks.find(task => active(task) && task.stage === stage)
77
+ if (queued) return { label: STATES[queued.state], tone: 'blue', detail: queued.summary, task: queued }
39
78
 
40
- async function loadTasks(kb: string): Promise<ExtractionTask[]> {
41
- const params = new URLSearchParams({ kb })
42
- const envelope = await requestJson<{ tasks: ExtractionTask[] }>(`/knowledge/extraction-tasks?${params}`)
43
- return envelope.data?.tasks ?? []
44
- }
79
+ const activeAll = tasks.find(task => active(task) && task.stage === 'all')
80
+ if (activeAll) {
81
+ const result = activeAll.results?.[stage]
82
+ if (result) return completedStageObservation(stage, activeAll, result)
83
+ if (!activeAll.currentStage) return stage === 'extract'
84
+ ? { label: STATES[activeAll.state], tone: 'blue', detail: '全链路排队后将从来源抽取开始。', task: activeAll }
85
+ : { label: '等待执行', tone: 'neutral', detail: '全链路到达此阶段后会自动运行。', task: activeAll }
86
+ const currentIndex = STAGE_ORDER.indexOf(activeAll.currentStage)
87
+ const stageIndex = STAGE_ORDER.indexOf(stage)
88
+ if (stageIndex > currentIndex) return { label: '等待执行', tone: 'neutral', detail: '全链路到达此阶段后会自动运行。', task: activeAll }
89
+ }
45
90
 
46
- async function updateTask(kb: string, runId: string, action: TaskAction): Promise<ExtractionTask> {
47
- const envelope = await requestJson<{ task: ExtractionTask }>('/knowledge/extraction-task', {
48
- method: 'POST',
49
- headers: { 'content-type': 'application/json' },
50
- body: JSON.stringify({ kb, run_id: runId, action }),
51
- })
52
- if (!envelope.data?.task) throw new Error('任务状态没有返回')
53
- return envelope.data.task
54
- }
91
+ const completed = tasks.find(task => task.results?.[stage])
92
+ const result = completed?.results?.[stage]
93
+ if (completed && result) return completedStageObservation(stage, completed, result)
55
94
 
56
- function stateOf(task: ExtractionTask): TaskState {
57
- return task.state ?? (task.finishedAt ? (task.failed ? 'failed' : 'succeeded') : task.queued ? 'queued' : 'running')
95
+ const interrupted = tasks.find(task => !active(task) && (task.currentStage === stage || task.stage === stage))
96
+ if (interrupted) {
97
+ const tone = ['failed', 'partial', 'interrupted'].includes(interrupted.state) ? 'warning' : 'neutral'
98
+ return { label: STATES[interrupted.state], tone, detail: interrupted.summary, task: interrupted }
99
+ }
100
+
101
+ if (!status) return { label: '读取中', tone: 'neutral', detail: '正在读取当前处理状态。' }
102
+ if (stage === 'extract') {
103
+ const count = status.sources.length
104
+ return count
105
+ ? { label: '待运行', tone: 'neutral', detail: `当前 ${count} 份来源;运行时会按内容复用未变化的结果。` }
106
+ : { label: '无来源', tone: 'neutral', detail: '当前没有来源可抽取。' }
107
+ }
108
+ if (stage === 'normalize') {
109
+ const ready = status.sources.filter(source => source.state === 'extracted').length
110
+ const waiting = status.sources.filter(source => source.state === 'pending' || source.state === 'outdated').length
111
+ return ready
112
+ ? { label: '待运行', tone: 'neutral', detail: `待归一 ${ready} 份来源${waiting ? ` · 另有 ${waiting} 份待抽取` : ''}。` }
113
+ : waiting
114
+ ? { label: '等待来源', tone: 'neutral', detail: `${waiting} 份来源尚未完成抽取。` }
115
+ : { label: '无待办', tone: 'neutral', detail: '当前没有来源需要归一。' }
116
+ }
117
+ if (stage === 'materialize') {
118
+ const pending = status.groups.filter(group => group.state !== 'current').length
119
+ return pending
120
+ ? { label: '待运行', tone: status.blocked ? 'warning' : 'neutral', detail: `待处理 ${pending} 个卡片分组${status.blocked ? ` · 暂不可物化 ${status.blocked} 个` : ''}。` }
121
+ : { label: '无待办', tone: 'neutral', detail: '当前没有需要新建、更新或停用的卡片分组。' }
122
+ }
123
+ return status.indexPending
124
+ ? { label: '待更新', tone: 'neutral', detail: '索引内容有变化,等待构建。' }
125
+ : { label: '无变化', tone: 'neutral', detail: '当前索引已是最新,无需重建。' }
58
126
  }
59
127
 
60
- function taskDetail(task: ExtractionTask): string {
61
- const state = stateOf(task)
62
- if (state === 'queued') return '等待前面的任务结束'
63
- const step = task.steps.find(item => item.state === 'running')
64
- const progress = task.sourcesTotal > 0 ? `文档 ${task.sourcesDone}/${task.sourcesTotal}` : ''
65
- if (state === 'running' || state === 'pausing' || state === 'paused' || state === 'cancelling') {
66
- return [step?.facts[0] ?? step?.id, progress].filter(Boolean).join(' · ') || '正在准备编译'
128
+ /**
129
+ * 这一步**还差多少活** —— 步骤轨上那一格的第二行。
130
+ *
131
+ * 它与 `stageObservation` 的两个字段刻意分开:`label` 说的是**上一次跑成什么样**
132
+ * (已完成 / 部分失败),这一行说的是**现在还剩什么**。原来这两件事被挤在同一行
133
+ * 10.5px 的灰字里(「0 张待新建 · 58 张待更新 · …」),既没有主语也没有和阶段对上。
134
+ *
135
+ * 数字全部来自 `status`,算不出来就说算不出来,不补 0。
136
+ */
137
+ function stagePending(stage: StageId, status?: PipelineStatus): string {
138
+ if (!status) return '读取中'
139
+ if (stage === 'extract') {
140
+ const count = status.sources.filter(source => source.state === 'pending' || source.state === 'outdated').length
141
+ return count ? `${count} 份待抽取` : '无待抽取'
142
+ }
143
+ if (stage === 'normalize') {
144
+ const ready = status.sources.filter(source => source.state === 'extracted').length
145
+ if (ready) return `${ready} 份待归一`
146
+ const waiting = status.sources.filter(source => source.state === 'pending' || source.state === 'outdated').length
147
+ return waiting ? `等待 ${waiting} 份抽取` : '无待归一'
67
148
  }
68
- return task.outcome ?? (state === 'succeeded' ? '任务完成' : state === 'cancelled' ? '任务已停止' : '任务失败')
149
+ if (stage === 'materialize') {
150
+ const pending = status.groups.filter(group => group.state !== 'current').length
151
+ return pending ? `${pending} 个分组待处理` : '无待物化'
152
+ }
153
+ return status.indexPending ? '索引待更新' : '索引已最新'
69
154
  }
70
155
 
71
- function TaskActions({ task, kb, onChange }: {
72
- task: ExtractionTask
73
- kb: string
74
- onChange: (task: ExtractionTask) => void
75
- }): ReactNode {
76
- const [busy, setBusy] = useState(false)
156
+ function TaskOutput({ kb, id, onTask, onOpenFailed, stageFilter, taskStage }: { kb: string; id: string; onTask: (task: PipelineTask) => void; onOpenFailed: (message: string) => void; stageFilter?: StageId; taskStage?: PipelineStage }) {
157
+ const [events, setEvents] = useState<PipelineEvent[]>([])
77
158
  const [failure, setFailure] = useState('')
78
- const state = stateOf(task)
79
- const active = ['queued', 'running', 'pausing', 'paused', 'cancelling'].includes(state)
80
- const act = async (action: TaskAction): Promise<void> => {
81
- setBusy(true)
159
+ const callback = useRef(onTask)
160
+ callback.current = onTask
161
+ useEffect(() => {
162
+ let active = true
163
+ let busy = false
164
+ let cursor = 0
165
+ let terminal = false
82
166
  setFailure('')
83
- try { onChange(await updateTask(kb, task.runId, action)) }
84
- catch (cause) { setFailure(cause instanceof Error ? cause.message : String(cause)) }
85
- finally { setBusy(false) }
86
- }
87
- if (!active) return null
88
- return <span className="extractTaskActions">
89
- {failure && <span className="extractTaskActionError" role="alert">{failure}</span>}
90
- {state !== 'cancelling' && <>
91
- {state === 'paused'
92
- ? <Button size="sm" variant="outline" disabled={busy} onClick={() => void act('resume')}><Play size={13} />继续</Button>
93
- : <Button size="sm" variant="outline" disabled={busy || state === 'pausing'} onClick={() => void act('pause')}><Pause size={13} />暂停</Button>}
94
- <Button size="sm" variant="destructive" disabled={busy} onClick={() => void act('cancel')}><CircleX size={13} />停止</Button>
95
- </>}
96
- </span>
167
+ setEvents([])
168
+ const read = async (): Promise<void> => {
169
+ if (busy || terminal) return
170
+ busy = true
171
+ try {
172
+ let more = true
173
+ while (active && more) {
174
+ const result = await requestJson<{ task: PipelineTask; events: PipelineEvent[]; next: number; hasMore: boolean }>(`/knowledge/pipeline-task?${new URLSearchParams({ kb, id, after: String(cursor) })}`)
175
+ if (!active || !result.data) return
176
+ const page = result.data
177
+ cursor = page.next
178
+ more = page.hasMore
179
+ terminal = !more && !PIPELINE_ACTIVE_STATES.includes(page.task.state)
180
+ callback.current(page.task)
181
+ setEvents(current => [...current, ...page.events].slice(-600))
182
+ setFailure('')
183
+ }
184
+ } catch (cause) { if (active) setFailure(cause instanceof Error ? cause.message : String(cause)) }
185
+ finally { busy = false }
186
+ }
187
+ void read()
188
+ const timer = window.setInterval(() => { void read() }, 1200)
189
+ return () => { active = false; window.clearInterval(timer) }
190
+ }, [kb, id])
191
+ const stageEvents = events.filter(event => !stageFilter || event.stage === stageFilter || (!event.stage && taskStage === stageFilter))
192
+ const hasUnscopedLegacyEvents = Boolean(stageFilter && taskStage === 'all' && events.some(event => !event.stage))
193
+ const output = stageEvents.filter(event => event.kind !== 'report').map(event => event.kind === 'output' ? event.text : `\n${event.text}\n`).join('').slice(-200000)
194
+ const reports = events.filter(event => event.kind === 'report' && (!stageFilter || event.stage === stageFilter || (!event.stage && (taskStage === stageFilter || stageFilter === 'extract' && taskStage === 'all'))))
195
+ return <div className="extractTasksOutputBody">
196
+ <p>{stageFilter ? hasUnscopedLegacyEvents ? '旧版全链路日志没有阶段标记;完整输出仍可在运行记录中查看。' : '这里只显示当前阶段的进度、模型输出和警告。' : '模型正式输出持续返回;刷新后可以补读。预览保留最近内容,完整返回保存在报告中。'}</p>
197
+ {failure && <p className="extractTasksError" role="alert">{failure}</p>}
198
+ <pre className="extractTasksPre" tabIndex={0} aria-label={stageFilter ? `${PIPELINE_STAGE_LABEL[stageFilter]}运行输出` : '实时输出'}>{output || (hasUnscopedLegacyEvents ? '这条历史记录没有可按阶段筛选的日志。' : '等待本阶段返回内容…')}</pre>
199
+ {/*
200
+ * 应用内路径不能用裸 `<a href>` 开(见 `KnowledgeBaseTab` 顶栏那条注释):桌面壳里
201
+ * `/knowledge/pages/...` 会被解析成 `dsh-app://` 地址,而壳的 `setWindowOpenHandler`
202
+ * 只放行 http/https —— 表现是「点了没有任何反应,也不报错」。走 `openAppPath()`
203
+ * 才是同一枚按钮在两种外壳下各自成立的那件事。
204
+ */}
205
+ <div className="extractTasksReportsInline">{reports.map(event => <Button
206
+ key={event.seq}
207
+ variant="ghost"
208
+ size="sm"
209
+ className="extractTasksLink"
210
+ onClick={() => { void openAppPath(`/knowledge/pages/run?${new URLSearchParams({ kb, run: event.text })}`).then(opened => { if (!opened) onOpenFailed('打不开抽取报告:当前外壳下拿不到它的地址。') }) }}
211
+ >查看抽取报告 <ExternalLink size={11} className="inline" /></Button>)}</div>
212
+ </div>
97
213
  }
98
214
 
99
- function TaskRow({ task, kb, onChange }: {
100
- task: ExtractionTask
101
- kb: string
102
- onChange: (task: ExtractionTask) => void
103
- }): ReactNode {
104
- const [stepsOpen, setStepsOpen] = useState(() => ['queued', 'running', 'pausing', 'paused', 'cancelling'].includes(stateOf(task)))
105
- const state = stateOf(task)
106
- return <article className="extractTaskRow">
107
- <div className="extractTaskMain">
108
- <div className="extractTaskTitle">
109
- <strong>{TASK_LABEL[task.only] ?? '编译任务'}</strong>
110
- <Badge tone={STATE_TONE[state]}>{STATE_LABEL[state]}</Badge>
215
+ /**
216
+ * 这一段跑过的轨迹,只列**这一段自己的**。
217
+ *
218
+ * 2026-10-18 之前这一格叫「抽取报告」,而且读的是 `/knowledge/pipeline-tasks` 里那份
219
+ * 跟**任务分页共用一个 offset** 的窗口 —— 翻到第 2 页时会空掉,而标题还写着「共 N 份」。
220
+ * 现在三段各有自己的轨迹(`stage` 列),这一格改成单取一次列表、按阶段分。
221
+ *
222
+ * 每段最多露 8 条:这一页的主线是「现在什么状态」,历史该去整页翻(点开就是整页)。
223
+ */
224
+ const STAGE_TRAJECTORY_CAP = 8
225
+
226
+ function StageTrajectories({ kb, stage, runs, onOpenFailed }: { kb: string; stage: TrajectoryStage; runs: ExtractionRunSummary[]; onOpenFailed: (message: string) => void }): ReactNode {
227
+ const own = runs.filter(run => trajectoryStageOf(run) === stage)
228
+ if (own.length === 0) return null
229
+ const shown = own.slice(0, STAGE_TRAJECTORY_CAP)
230
+ return <section className="extractTasksReports" aria-labelledby={`trajectory-${stage}`}>
231
+ <div className="extractTasksSubhead">
232
+ <div>
233
+ <h3 id={`trajectory-${stage}`}>{TRAJECTORY_STAGE_LABEL[stage]}</h3>
234
+ <p>每次运行的过程记录;点开在新窗口读完整轨迹。</p>
111
235
  </div>
112
- <p>{taskDetail(task)}</p>
113
- <span className="extractTaskTime"><Clock3 size={12} />{formatDateTime(task.startedAt || task.queuedAt)}</span>
114
- </div>
115
- <div className="extractTaskControls">
116
- <Button size="sm" variant="ghost" className="extractTaskStepToggle" aria-expanded={stepsOpen}
117
- onClick={() => setStepsOpen(value => !value)}>
118
- {stepsOpen ? '收起步骤' : '查看步骤'}<ChevronDown size={13} />
119
- </Button>
120
- <TaskActions task={task} kb={kb} onChange={onChange} />
236
+ <span className="extractTasksCount">{own.length > shown.length ? `${shown.length} / ${own.length} 条` : `${own.length} 条`}</span>
121
237
  </div>
122
- {stepsOpen && <div className="extractTaskSteps"><CompileStepList steps={task.steps} /></div>}
123
- </article>
238
+ {shown.map(run => <button
239
+ key={run.id}
240
+ type="button"
241
+ className="extractReportRow"
242
+ onClick={() => { void openAppPath(`/knowledge/pages/run?${new URLSearchParams({ kb, run: run.id })}`).then(opened => { if (!opened) onOpenFailed('打不开轨迹:当前外壳下拿不到它的地址。') }) }}
243
+ >
244
+ <span className="extractReportMain"><strong>{run.title || '(没有标题)'}</strong><span>{run.summary}</span></span>
245
+ <span className="extractReportMeta">{formatDateTime(run.createdAt)}<ExternalLink size={13} /></span>
246
+ </button>)}
247
+ </section>
248
+ }
249
+
250
+ /**
251
+ * 运行输出**默认收起**。
252
+ *
253
+ * 它是模型原始返回(几百行),而这一页的主线是「哪一段还剩多少活、跑完是什么结果」。
254
+ * 此前它是常驻展开的 `<pre>`:一屏高的灰块压在报告下面,日志和结论挤在同一层,
255
+ * 两样都读不成。收起来之后它仍在原地 —— 点一下展开,任务在跑时自动展开(那时日志是唯一在变的东西)。
256
+ */
257
+ function StageOutput({ active, children }: { active: boolean; children: ReactNode }): ReactNode {
258
+ const [open, setOpen] = useState(active)
259
+ useEffect(() => { if (active) setOpen(true) }, [active])
260
+ return <section className="extractTasksOutput">
261
+ <Button variant="ghost" size="sm" className="extractTasksOutputToggle" aria-expanded={open} onClick={() => setOpen(current => !current)}>
262
+ <ChevronDown size={13} />运行输出<span>{open ? '收起原始日志' : '展开原始日志'}</span>
263
+ </Button>
264
+ {open && children}
265
+ </section>
124
266
  }
125
267
 
126
268
  export function ExtractionTasksPage({ kb }: { kb: string }): ReactNode {
127
- const [tasks, setTasks] = useState<ExtractionTask[] | null>(null)
128
- const [kbName, setKbName] = useState('')
269
+ const params = new URLSearchParams(typeof window === 'undefined' ? '' : window.location.search)
270
+ const [status, setStatus] = useState<PipelineStatus>()
271
+ const [history, setHistory] = useState<History>()
272
+ const [sourceIds, setSourceIds] = useState<string[]>(() => params.get('source') ? [params.get('source')!] : [])
273
+ const [groupKeys, setGroupKeys] = useState<string[]>([])
274
+ const [selectedTask, setSelectedTask] = useState(() => params.get('task') ?? '')
129
275
  const [failure, setFailure] = useState('')
130
- const [refreshing, setRefreshing] = useState(false)
131
- const busyRef = useRef(false)
132
-
276
+ const [busy, setBusy] = useState(false)
277
+ /** 1-based:与 `ListPager` 同一个口径。 */
278
+ const [page, setPage] = useState(1)
279
+ const refreshSequence = useRef(0)
280
+ /**
281
+ * 四段步轨的阶段结论读这里,而不是当前这一页任务。
282
+ *
283
+ * 任务列表是服务端分页的(一页 `PAGE_SIZE` 条):某个阶段的最新结论一旦翻出第一页,
284
+ * 直接搜当前页会让那一段悄悄退回「待运行」—— 屏幕上看起来像从来没跑过。
285
+ * 所以按 id 累积见过的状态,只留最近 200 条(这只是阶段结论的缓存,完整历史在服务端)。
286
+ */
287
+ const [stageTasks, setStageTasks] = useState<PipelineTask[]>([])
288
+ /** 三段轨迹的列表(不含报告本体)。与任务分页**无关** —— 它按 stage 分,各自取最近若干条。 */
289
+ const [trajectories, setTrajectories] = useState<ExtractionRunSummary[]>([])
290
+ const seenTasks = useRef(new Map<string, PipelineTask>())
291
+ const rememberTasks = useCallback((rows: PipelineTask[]): void => {
292
+ for (const task of rows) seenTasks.current.set(task.id, task)
293
+ if (seenTasks.current.size > 200) {
294
+ const ordered = [...seenTasks.current.values()].sort((left, right) => right.queuedAt.localeCompare(left.queuedAt)).slice(0, 200)
295
+ seenTasks.current = new Map(ordered.map(task => [task.id, task]))
296
+ }
297
+ setStageTasks([...seenTasks.current.values()].sort((left, right) => right.queuedAt.localeCompare(left.queuedAt)))
298
+ }, [])
299
+ const refresh = useCallback(async (): Promise<void> => {
300
+ if (!kb) return
301
+ const sequence = ++refreshSequence.current
302
+ try {
303
+ const [nextStatus, nextHistory, nextTrajectories] = await Promise.all([
304
+ requestJson<PipelineStatus>(`/knowledge/pipeline?kb=${encodeURIComponent(kb)}`),
305
+ requestJson<History>(`/knowledge/pipeline-tasks?${new URLSearchParams({ kb, limit: String(PAGE_SIZE), offset: String((page - 1) * PAGE_SIZE) })}`),
306
+ // 轨迹库不可用时返回 `available: false` 而不是报错:这一段没有轨迹,不该让整页红一下。
307
+ requestExtractionRuns({ kb, limit: 40 }).catch(() => null),
308
+ ])
309
+ if (sequence !== refreshSequence.current) return
310
+ if (nextStatus.data) setStatus(nextStatus.data)
311
+ if (nextHistory.data) { setHistory(nextHistory.data); rememberTasks(nextHistory.data.tasks) }
312
+ if (nextTrajectories) setTrajectories(nextTrajectories.runs)
313
+ setFailure('')
314
+ } catch (cause) { if (sequence === refreshSequence.current) setFailure(cause instanceof Error ? cause.message : String(cause)) }
315
+ }, [kb, page, rememberTasks])
316
+ const activeCount = history?.tasks.filter(task => PIPELINE_ACTIVE_STATES.includes(task.state)).length ?? 0
133
317
  useEffect(() => {
134
- if (!kb) return undefined
135
- let active = true
136
- const load = async (): Promise<void> => {
137
- if (busyRef.current) return
138
- busyRef.current = true
139
- try {
140
- const [nextTasks, basesResponse] = await Promise.all([
141
- loadTasks(kb),
142
- requestJson<{ bases?: Array<{ id: string; name: string }> }>('/knowledge/bases'),
143
- ])
144
- if (!active) return
145
- setTasks(nextTasks)
146
- setKbName(basesResponse.data?.bases?.find(base => base.id === kb)?.name ?? kb)
147
- setFailure('')
148
- } catch (cause) {
149
- if (active) setFailure(cause instanceof Error ? cause.message : String(cause))
150
- } finally {
151
- busyRef.current = false
152
- if (active) setRefreshing(false)
153
- }
318
+ void refresh()
319
+ const focus = (): void => { void refresh() }
320
+ window.addEventListener('focus', focus)
321
+ const timer = activeCount ? window.setInterval(focus, 2500) : undefined
322
+ return () => { if (timer) window.clearInterval(timer); window.removeEventListener('focus', focus) }
323
+ }, [refresh, activeCount])
324
+ const start = async (stage: PipelineStage, force = false): Promise<void> => {
325
+ setBusy(true); setFailure('')
326
+ try {
327
+ const sourceScope = stage === 'all' || stage === 'extract' || stage === 'normalize'
328
+ const groupScope = stage === 'all' || stage === 'materialize'
329
+ const result = await requestJson<{ task: PipelineTask }>('/knowledge/pipeline', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ kb, stage, ...(sourceScope && sourceIds.length ? { sourceIds } : {}), ...(groupScope && groupKeys.length ? { groupKeys } : {}), force }) })
330
+ if (result.data?.task) { setSelectedTask(result.data.task.id); setPage(1) }
331
+ await refresh()
332
+ } catch (cause) { setFailure(cause instanceof Error ? cause.message : String(cause)) }
333
+ finally { setBusy(false) }
334
+ }
335
+ const act = async (id: string, action: string): Promise<void> => {
336
+ setBusy(true)
337
+ try {
338
+ const result = await requestJson<{ task: PipelineTask }>('/knowledge/pipeline-task', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ kb, id, action }) })
339
+ if (result.data?.task) setSelectedTask(result.data.task.id)
340
+ await refresh()
341
+ } catch (cause) { setFailure(cause instanceof Error ? cause.message : String(cause)) }
342
+ finally { setBusy(false) }
343
+ }
344
+ const replaceTask = useCallback((task: PipelineTask) => {
345
+ setHistory(current => current ? { ...current, tasks: current.tasks.map(row => row.id === task.id ? task : row) } : current)
346
+ rememberTasks([task])
347
+ }, [rememberTasks])
348
+ if (!kb) return <p className="extractTasksError" role="alert">缺少知识库,请从知识库工具栏打开知识处理。</p>
349
+ /**
350
+ * 分页器分的是**任务**,所以页数只能用任务自己的总数。
351
+ *
352
+ * 原来取的是 `max(任务数, 抽取报告数)`:抽取报告是**另一个集合**(一次任务可能产出
353
+ * 好几份,历史累积到几百份),把它算进分母会让分页器凭空多出十几页,而点「下一页」
354
+ * 落到的是一片空白 —— 那个空页看起来像「记录丢了」。抽取报告的数量已经有自己的落点
355
+ * (「抽取报告」那一行右侧的「N 份」),不在这里重复。
356
+ */
357
+ const total = history?.total ?? 0
358
+ const pageCount = Math.max(1, Math.ceil(total / PAGE_SIZE))
359
+ const orderedTasks = [...(history?.tasks ?? [])].sort((left, right) => {
360
+ const activeOrder = Number(PIPELINE_ACTIVE_STATES.includes(right.state)) - Number(PIPELINE_ACTIVE_STATES.includes(left.state))
361
+ return activeOrder || right.queuedAt.localeCompare(left.queuedAt)
362
+ })
363
+ const stages = [
364
+ { id: 'extract', detail: '从来源生成 Knowledge 与 Event。' },
365
+ { id: 'normalize', detail: '合并实体,并整理知识坐标。' },
366
+ { id: 'materialize', detail: '按完整坐标生成 WikiCard。' },
367
+ { id: 'build', detail: '构建并发布检索索引。' },
368
+ ] as const
369
+ const inputRows = (stage: StageId, task?: PipelineTask): Array<[string, string]> => {
370
+ const selection = task?.selection ?? {}
371
+ const sourceSelection = task ? selection.sourceIds : sourceIds.length ? sourceIds : undefined
372
+ const groupSelection = task ? selection.groupKeys : groupKeys.length ? groupKeys : undefined
373
+ const names = (ids: string[], resolve: (id: string) => string): string => {
374
+ const values = ids.map(resolve)
375
+ return `${ids.length} 项${values.length <= 3 ? `:${values.join('、')}` : `:${values.slice(0, 3).join('、')} 等`}`
154
376
  }
155
- void load()
156
- const timer = window.setInterval(() => { void load() }, 2000)
157
- return () => { active = false; window.clearInterval(timer) }
158
- }, [kb])
159
-
160
- const replaceTask = (task: ExtractionTask): void => {
161
- setTasks(current => current?.map(item => item.runId === task.runId ? task : item) ?? [task])
377
+ const sourceRange = sourceSelection
378
+ ? names(sourceSelection, id => status?.sources.find(source => source.id === id)?.title ?? id)
379
+ : `全部来源(${status?.sources.length ?? '当前'} 份)`
380
+ const rows: Array<[string, string]> = []
381
+ if (stage === 'extract' || stage === 'normalize') {
382
+ rows.push(['来源范围', sourceRange])
383
+ rows.push(['强制处理', selection.force ? '是,忽略可复用结果' : '否,复用未变化结果'])
384
+ } else if (stage === 'materialize') {
385
+ const groupRange = groupSelection
386
+ ? names(groupSelection, id => status?.groups.find(group => group.key === id)?.title ?? id)
387
+ : task?.stage === 'all' && selection.sourceIds
388
+ ? `全部待办分组中关联所选来源的分组(${sourceSelection?.length ?? 0} 份来源)`
389
+ : '全部待处理分组'
390
+ rows.push(['分组范围', groupRange])
391
+ if (task?.stage === 'all' && selection.sourceIds) rows.push(['来源限制', sourceRange])
392
+ rows.push(['强制处理', selection.force ? '是,重新物化所选分组' : '否,只处理有变化的分组'])
393
+ } else {
394
+ rows.push(['索引范围', '整个知识库;会按当前知识内容构建'])
395
+ rows.push(['选择范围', '来源与分组选择不缩小索引范围'])
396
+ }
397
+ if (stage !== 'build') {
398
+ const model = task?.model
399
+ rows.push(['模型配置', model?.model ? `${model.provider ? `${model.provider} / ` : ''}${model.model}${model.reasoningEffort ? ` · ${model.reasoningEffort}` : ''}` : '运行时读取当前模型设置'])
400
+ }
401
+ return rows
162
402
  }
163
-
164
- if (!kb) return <section className="page-state"><h2>缺少知识库</h2><p>从知识库工具栏打开抽取任务页。</p></section>
165
-
166
- const activeCount = tasks?.filter(task => ['queued', 'running', 'pausing', 'paused', 'cancelling'].includes(stateOf(task))).length ?? 0
167
- return <>
403
+ /**
404
+ * 结果摘要**不再重复「状态」**:那枚徽标在步骤轨上,是这一段的身份,不是结果的第二行。
405
+ * 这里只放「跑了多少、什么时候跑的」—— 一个问题一个答案。
406
+ */
407
+ const outputRows = (stage: StageId, observation: StageObservation): Array<[string, string]> => {
408
+ const task = observation.task
409
+ const result = task?.results?.[stage]
410
+ const activeStage = task && PIPELINE_ACTIVE_STATES.includes(task.state) && task.currentStage === stage
411
+ const liveResult = activeStage ? task : task?.stage === stage && !result ? task : undefined
412
+ const counts = result ?? liveResult
413
+ const summary = result?.summary ?? (activeStage ? task?.summary : observation.detail) ?? observation.detail
414
+ return [
415
+ ['摘要', summary],
416
+ ...(counts ? [['已处理', `${counts.done}/${counts.total} 项`] as [string, string]] : []),
417
+ ...(counts?.reused ? [['复用', `${counts.reused} 项`] as [string, string]] : []),
418
+ ...(counts?.failed ? [['失败', `${counts.failed} 项`] as [string, string]] : []),
419
+ ...(task ? [['最近运行', formatDateTime(task.finishedAt ?? task.startedAt ?? task.queuedAt)] as [string, string]] : []),
420
+ ]
421
+ }
422
+ const runningTask = orderedTasks.find(task => PIPELINE_ACTIVE_STATES.includes(task.state))
423
+ const headline = runningTask
424
+ ? `正在运行「${PIPELINE_STAGE_LABEL[runningTask.stage]}」${runningTask.total > 0 ? ` · ${runningTask.done}/${runningTask.total}` : ''}${runningTask.failed ? ` · 失败 ${runningTask.failed}` : ''}。暂停与停止在当前文档处理完后生效。`
425
+ : history
426
+ ? '当前没有运行中的任务。选好处理范围后按阶段运行;未变化的结果会自动复用。'
427
+ : '正在读取这个库的处理状态…'
428
+ return <div className="extractTasksPage">
168
429
  <div className="extractTasksHead">
169
- <div>
170
- <h2>抽取任务</h2>
171
- <p>{kbName || kb} · {activeCount > 0 ? `${activeCount} 项未结束` : '当前没有进行中的任务'}</p>
430
+ <p className="extractTasksHeadline" role="status">{headline}</p>
431
+ <div className="extractTasksHeadActions">
432
+ <Button variant="ghost" size="sm" onClick={() => { void refresh() }}><RefreshCw size={13} />刷新</Button>
433
+ <Button size="sm" title="按顺序运行全部四个阶段:抽取 → 归一 → 物化 → 构建索引" disabled={busy || !status?.sources.length} onClick={() => { void start('all') }}><Play size={13} />全链路运行</Button>
172
434
  </div>
173
- <Button size="sm" variant="outline" disabled={refreshing} onClick={() => {
174
- setRefreshing(true)
175
- void loadTasks(kb).then(setTasks).catch(cause => setFailure(cause instanceof Error ? cause.message : String(cause))).finally(() => setRefreshing(false))
176
- }}><RefreshCw size={13} className={refreshing ? 'extractTaskSpin' : undefined} />刷新</Button>
177
435
  </div>
436
+ {failure && <p className="extractTasksError" role="alert">{failure}</p>}
437
+
438
+ <Tabs defaultValue="extract" className="extractTasksWork">
439
+ {/* 步骤轨横贯整页:它是这一页的骨架(有方向的流水线),所以不能被左边那一栏框住。
440
+ 每格两行 —— 名字、以及「最近结论 + 还剩多少」;两件事不同色,因为它们是两个问题。 */}
441
+ <TabsList aria-label="知识处理阶段" className="extractTasksRailList">
442
+ {stages.map((stage, index) => {
443
+ const observation = stageObservation(stage.id, stageTasks, status)
444
+ return <Fragment key={stage.id}>
445
+ {index > 0 && <span className="extractTasksRailArrow" aria-hidden="true"><ArrowRight size={16} /></span>}
446
+ <TabsTrigger value={stage.id} className="extractTasksRailStep" title={observation.detail}>
447
+ <span className="extractTasksRailName">{PIPELINE_STAGE_LABEL[stage.id]}</span>
448
+ <span className="extractTasksRailFacts">
449
+ <Badge tone={observation.tone}>{observation.label}</Badge>
450
+ <span className="extractTasksRailPending">{stagePending(stage.id, status)}</span>
451
+ </span>
452
+ </TabsTrigger>
453
+ </Fragment>
454
+ })}
455
+ </TabsList>
178
456
 
179
- <p className="extractTasksNote">暂停会在当前文档或阶段处理完成后生效。停止不会回滚已写入的内容。</p>
180
- {failure && <p className="page-notice" role="alert">读取任务失败:{failure}</p>}
181
- {tasks === null
182
- ? <p className="page-loading" role="status">正在读取任务…</p>
183
- : tasks.length === 0
184
- ? <div className="extractTasksEmpty"><ListOrdered size={17} /><span>还没有编译任务。导入文档或重新编译后,任务会显示在这里。</span></div>
185
- : <div className="extractTaskList">
186
- {tasks.map(task => <TaskRow key={task.runId} task={task} kb={kb} onChange={replaceTask} />)}
187
- </div>}
188
- </>
457
+ <div className="extractTasksBody">
458
+ <div className="extractTasksMain">
459
+ {stages.map(stage => {
460
+ const observation = stageObservation(stage.id, stageTasks, status)
461
+ const live = Boolean(observation.task && PIPELINE_ACTIVE_STATES.includes(observation.task.state) && observation.task.currentStage === stage.id)
462
+ return <TabsContent key={stage.id} value={stage.id} className="extractTasksStage">
463
+ <div className="extractTasksStageIntro">
464
+ <div>
465
+ <h2>{PIPELINE_STAGE_LABEL[stage.id]}</h2>
466
+ <p>{stage.detail}</p>
467
+ </div>
468
+ <Button size="sm" variant="outline" disabled={busy || !status || ((stage.id === 'extract' || stage.id === 'normalize') && !status.sources.length)} onClick={() => { void start(stage.id) }}>运行此阶段</Button>
469
+ </div>
470
+ <div className="extractTasksFacts">
471
+ <section aria-labelledby={`stage-input-${stage.id}`}>
472
+ <h3 id={`stage-input-${stage.id}`}>输入范围</h3>
473
+ <dl>{inputRows(stage.id, observation.task).map(([label, value]) => <div key={label}><dt>{label}</dt><dd>{value}</dd></div>)}</dl>
474
+ </section>
475
+ <section aria-labelledby={`stage-output-${stage.id}`}>
476
+ <h3 id={`stage-output-${stage.id}`}>结果摘要</h3>
477
+ <dl>{outputRows(stage.id, observation).map(([label, value]) => <div key={label}><dt>{label}</dt><dd>{value}</dd></div>)}</dl>
478
+ </section>
479
+ </div>
480
+ {/* 构建索引那一段没有轨迹:它的产出是检索产物(可重建),不是「一次判断」。
481
+ 所以这里只在前三段渲染;`build` 留空而不是硬塞一份假记录。 */}
482
+ {stage.id !== 'build' && <StageTrajectories kb={kb} stage={stage.id as TrajectoryStage} runs={trajectories} onOpenFailed={setFailure} />}
483
+ {observation.task && <StageOutput active={live}><TaskOutput kb={kb} id={observation.task.id} onTask={replaceTask} onOpenFailed={setFailure} stageFilter={stage.id} taskStage={observation.task.stage} /></StageOutput>}
484
+ </TabsContent>
485
+ })}
486
+ </div>
487
+
488
+ <aside className="extractTasksSide">
489
+ <section className="extractTasksPanel" aria-labelledby="scope-title">
490
+ <div className="extractTasksPanelHead">
491
+ <div>
492
+ <h2 id="scope-title">处理范围</h2>
493
+ <p>不选就是全部;点一行选中它。</p>
494
+ </div>
495
+ </div>
496
+ <div className="extractTasksPanelBody">
497
+ <Tabs defaultValue="sources">
498
+ <TabsList><TabsTrigger value="sources">来源</TabsTrigger><TabsTrigger value="groups">卡片分组</TabsTrigger></TabsList>
499
+ <TabsContent value="sources" className="extractTasksScopeTab">
500
+ <p className="extractTasksScopeNote">{sourceIds.length ? `已选 ${sourceIds.length} 份文档。` : '未选择时处理全部来源,未变化的结果自动复用。'}</p>
501
+ {!status && <p className="extractTasksEmpty">正在读取来源…</p>}
502
+ {status?.sources.length === 0 && <p className="extractTasksEmpty">当前没有来源。添加来源后即可开始处理。</p>}
503
+ <ul className="extractTasksChoiceList" aria-label="来源选择">{status?.sources.map(source => {
504
+ const on = sourceIds.includes(source.id)
505
+ return <li key={source.id}>
506
+ <button type="button" className="extractTasksPick" aria-pressed={on} onClick={() => setSourceIds(current => toggle(current, source.id))}>
507
+ <span className="extractTasksPickBox" aria-hidden="true">{on && <Check size={11} />}</span>
508
+ <span className="extractTasksChoiceName">{source.title}<span>v{source.version}</span></span>
509
+ <Badge tone="neutral">{SOURCE_STATES[source.state]}</Badge>
510
+ </button>
511
+ </li>
512
+ })}</ul>
513
+ {sourceIds.length > 0 && <div className="extractTasksScopeActions">
514
+ <Button size="sm" variant="outline" disabled={busy} onClick={() => { void start('extract', true) }}>强制重抽 {sourceIds.length} 份</Button>
515
+ <Button size="sm" variant="ghost" onClick={() => setSourceIds([])}>清除选择</Button>
516
+ </div>}
517
+ </TabsContent>
518
+ <TabsContent value="groups" className="extractTasksScopeTab">
519
+ <p className="extractTasksScopeNote">{groupKeys.length ? `已选 ${groupKeys.length} 个完整卡片分组。` : '未选择时物化全部待办;同坐标的知识与事件一起处理。'}</p>
520
+ {!status && <p className="extractTasksEmpty">正在读取分组…</p>}
521
+ {status?.groups.length === 0 && <p className="extractTasksEmpty">还没有可物化分组。先抽取并归一文档内容。</p>}
522
+ <ul className="extractTasksChoiceList extractTasksGroupList" aria-label="卡片分组选择">{status?.groups.map(group => {
523
+ const on = groupKeys.includes(group.key)
524
+ return <li key={group.key}>
525
+ <button type="button" className="extractTasksPick" aria-pressed={on} onClick={() => setGroupKeys(current => toggle(current, group.key))}>
526
+ <span className="extractTasksPickBox" aria-hidden="true">{on && <Check size={11} />}</span>
527
+ <span className="extractTasksChoiceName"><strong>{group.title}</strong><span>{group.knowledgeCount} 条 Knowledge · {group.eventCount} 个 Event · {group.sourceIds.length} 份来源</span>{group.reason && <span className="extractTasksGroupReason">{group.reason}</span>}</span>
528
+ <Badge tone={group.state === 'current' ? 'success' : 'neutral'}>{GROUP_STATES[group.state]}</Badge>
529
+ </button>
530
+ </li>
531
+ })}</ul>
532
+ {groupKeys.length > 0 && <div className="extractTasksScopeActions">
533
+ <Button size="sm" variant="outline" disabled={busy} onClick={() => { void start('materialize') }}>物化选中的 {groupKeys.length} 个分组</Button>
534
+ <Button size="sm" variant="ghost" onClick={() => setGroupKeys([])}>清除选择</Button>
535
+ </div>}
536
+ </TabsContent>
537
+ </Tabs>
538
+ </div>
539
+ </section>
540
+
541
+ {/* `id="history"` 保留:旧链接(`…#history`)还能落到这一块上。 */}
542
+ <section id="history" className="extractTasksPanel" aria-labelledby="pipeline-history">
543
+ <div className="extractTasksPanelHead">
544
+ <div>
545
+ <h2 id="pipeline-history">运行记录</h2>
546
+ <p>{activeCount ? '暂停与停止会在当前文档或卡片处理完成后生效。' : '运行状态会自动更新。'}</p>
547
+ </div>
548
+ <Badge tone={activeCount ? 'blue' : 'neutral'}>{activeCount ? `进行中 ${activeCount}` : '无运行任务'}</Badge>
549
+ </div>
550
+ <div className="extractTasksPanelBody">
551
+ {!history && <p className="extractTasksEmpty">正在读取任务记录…</p>}
552
+ {history?.tasks.length === 0 && <p className="extractTasksEmpty">还没有阶段任务。抽取报告保留在抽取阶段。</p>}
553
+ {orderedTasks.map(task => <article key={task.id} className="extractTaskRow">
554
+ <div className="extractTaskTitle">
555
+ <strong>{PIPELINE_STAGE_LABEL[task.stage]}</strong>
556
+ <Badge tone={task.state === 'succeeded' ? 'success' : ['failed', 'partial', 'interrupted'].includes(task.state) ? 'warning' : PIPELINE_ACTIVE_STATES.includes(task.state) ? 'blue' : 'neutral'}>{STATES[task.state]}</Badge>
557
+ </div>
558
+ <p className="extractTaskSummary">{task.summary}</p>
559
+ <p className="extractTaskTime">{formatDateTime(task.queuedAt)}{task.model.model ? ` · ${task.model.provider ?? ''} / ${task.model.model}` : ''}{task.model.reasoningEffort ? ` · ${task.model.reasoningEffort}` : ''}</p>
560
+ <div className="extractTaskControls">
561
+ <Button size="sm" variant="ghost" className="extractTaskStepToggle" aria-expanded={selectedTask === task.id} onClick={() => setSelectedTask(current => current === task.id ? '' : task.id)}>{selectedTask === task.id ? '收起输出' : '查看输出'}<ChevronDown size={13} /></Button>
562
+ {PIPELINE_ACTIVE_STATES.includes(task.state) ? <>
563
+ <Button size="sm" variant="outline" disabled={busy || task.state === 'cancelling' || task.state === 'pausing'} onClick={() => { void act(task.id, task.state === 'paused' ? 'resume' : 'pause') }}>{task.state === 'paused' ? <Play size={12} /> : <Pause size={12} />}{task.state === 'paused' ? '继续' : '暂停'}</Button>
564
+ <Button size="sm" variant="ghost" disabled={busy || task.state === 'cancelling'} onClick={() => { void act(task.id, 'cancel') }}><X size={12} />停止</Button>
565
+ </> : <Button size="sm" variant="outline" disabled={busy} onClick={() => { void act(task.id, 'retry') }}>重新运行</Button>}
566
+ </div>
567
+ {selectedTask === task.id && <div className="extractTaskSteps"><TaskOutput kb={kb} id={task.id} onTask={replaceTask} onOpenFailed={setFailure} /></div>}
568
+ </article>)}
569
+ <ListPager page={page} pageCount={pageCount} total={total} onChange={setPage} />
570
+ </div>
571
+ </section>
572
+ </aside>
573
+ </div>
574
+ </Tabs>
575
+ </div>
189
576
  }