dsh-data-domain-knowledge 0.1.1-alpha.2 → 0.1.1-alpha.4

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 (252) hide show
  1. package/README.md +33 -16
  2. package/lib/client.js +1042 -2097
  3. package/lib/contract-refs.d.ts +0 -1
  4. package/lib/doc-page.js +1 -1
  5. package/lib/pages.js +2489 -1060
  6. package/lib/types/agent-behavior-rules.d.ts.map +1 -1
  7. package/lib/types/agent-behavior-rules.js +3 -2
  8. package/lib/types/agent-behavior-rules.js.map +1 -1
  9. package/lib/types/axis-contract.js +1 -1
  10. package/lib/types/axis-contract.js.map +1 -1
  11. package/lib/types/card-organize.d.ts +2 -1
  12. package/lib/types/card-organize.d.ts.map +1 -1
  13. package/lib/types/card-organize.js +5 -3
  14. package/lib/types/card-organize.js.map +1 -1
  15. package/lib/types/config-schema.d.ts +1 -1
  16. package/lib/types/config-schema.js +1 -1
  17. package/lib/types/convert/host.d.ts +2 -3
  18. package/lib/types/convert/host.d.ts.map +1 -1
  19. package/lib/types/convert/host.js +11 -2
  20. package/lib/types/convert/host.js.map +1 -1
  21. package/lib/types/convert/ingest.d.ts +14 -20
  22. package/lib/types/convert/ingest.d.ts.map +1 -1
  23. package/lib/types/convert/ingest.js +8 -5
  24. package/lib/types/convert/ingest.js.map +1 -1
  25. package/lib/types/diagnosis/store.d.ts +2 -2
  26. package/lib/types/diagnosis/store.d.ts.map +1 -1
  27. package/lib/types/diagnosis/store.js +10 -2
  28. package/lib/types/diagnosis/store.js.map +1 -1
  29. package/lib/types/diagnosis/types.d.ts +1 -1
  30. package/lib/types/diagnosis/types.js +1 -1
  31. package/lib/types/extract-model.d.ts +3 -0
  32. package/lib/types/extract-model.d.ts.map +1 -1
  33. package/lib/types/extract-model.js.map +1 -1
  34. package/lib/types/images.js +1 -1
  35. package/lib/types/index.d.ts +0 -4
  36. package/lib/types/index.d.ts.map +1 -1
  37. package/lib/types/index.js +99 -91
  38. package/lib/types/index.js.map +1 -1
  39. package/lib/types/knowledge-bases.d.ts +3 -0
  40. package/lib/types/knowledge-bases.d.ts.map +1 -1
  41. package/lib/types/knowledge-bases.js +22 -2
  42. package/lib/types/knowledge-bases.js.map +1 -1
  43. package/lib/types/knowledge-contract.d.ts +6 -0
  44. package/lib/types/knowledge-contract.d.ts.map +1 -1
  45. package/lib/types/knowledge-contract.js +6 -2
  46. package/lib/types/knowledge-contract.js.map +1 -1
  47. package/lib/types/layout.d.ts +9 -26
  48. package/lib/types/layout.d.ts.map +1 -1
  49. package/lib/types/layout.js +10 -28
  50. package/lib/types/layout.js.map +1 -1
  51. package/lib/types/materialize-snapshot.d.ts +5 -0
  52. package/lib/types/materialize-snapshot.d.ts.map +1 -1
  53. package/lib/types/migrate.d.ts +3 -1
  54. package/lib/types/migrate.d.ts.map +1 -1
  55. package/lib/types/migrate.js +3 -1
  56. package/lib/types/migrate.js.map +1 -1
  57. package/lib/types/model-text.d.ts +7 -0
  58. package/lib/types/model-text.d.ts.map +1 -1
  59. package/lib/types/model-text.js +31 -9
  60. package/lib/types/model-text.js.map +1 -1
  61. package/lib/types/organize-contract.d.ts.map +1 -1
  62. package/lib/types/organize-contract.js +2 -1
  63. package/lib/types/organize-contract.js.map +1 -1
  64. package/lib/types/pipeline/engine.d.ts +28 -4
  65. package/lib/types/pipeline/engine.d.ts.map +1 -1
  66. package/lib/types/pipeline/engine.js +68 -15
  67. package/lib/types/pipeline/engine.js.map +1 -1
  68. package/lib/types/pipeline/progress.d.ts +12 -0
  69. package/lib/types/pipeline/progress.d.ts.map +1 -0
  70. package/lib/types/pipeline/progress.js +37 -0
  71. package/lib/types/pipeline/progress.js.map +1 -0
  72. package/lib/types/pipeline/resumable-extract.d.ts +45 -0
  73. package/lib/types/pipeline/resumable-extract.d.ts.map +1 -0
  74. package/lib/types/pipeline/resumable-extract.js +262 -0
  75. package/lib/types/pipeline/resumable-extract.js.map +1 -0
  76. package/lib/types/pipeline/store.d.ts +8 -2
  77. package/lib/types/pipeline/store.d.ts.map +1 -1
  78. package/lib/types/pipeline/store.js +27 -5
  79. package/lib/types/pipeline/store.js.map +1 -1
  80. package/lib/types/pipeline/types.d.ts +56 -0
  81. package/lib/types/pipeline/types.d.ts.map +1 -1
  82. package/lib/types/pipeline/types.js +18 -1
  83. package/lib/types/pipeline/types.js.map +1 -1
  84. package/lib/types/present.d.ts +1 -1
  85. package/lib/types/present.js +1 -1
  86. package/lib/types/preset-ids.d.ts +0 -1
  87. package/lib/types/preset-ids.d.ts.map +1 -1
  88. package/lib/types/preset-ids.js +0 -3
  89. package/lib/types/preset-ids.js.map +1 -1
  90. package/lib/types/query-analysis.d.ts +0 -5
  91. package/lib/types/query-analysis.d.ts.map +1 -1
  92. package/lib/types/query-analysis.js +0 -16
  93. package/lib/types/query-analysis.js.map +1 -1
  94. package/lib/types/recall-delivery.d.ts +36 -0
  95. package/lib/types/recall-delivery.d.ts.map +1 -0
  96. package/lib/types/recall-delivery.js +16 -0
  97. package/lib/types/recall-delivery.js.map +1 -0
  98. package/lib/types/retrieval/scope.d.ts +17 -0
  99. package/lib/types/retrieval/scope.d.ts.map +1 -0
  100. package/lib/types/retrieval/scope.js +11 -0
  101. package/lib/types/retrieval/scope.js.map +1 -0
  102. package/lib/types/runtime.d.ts +75 -14
  103. package/lib/types/runtime.d.ts.map +1 -1
  104. package/lib/types/runtime.js +454 -198
  105. package/lib/types/runtime.js.map +1 -1
  106. package/lib/types/settings-volatile.d.ts +0 -7
  107. package/lib/types/settings-volatile.d.ts.map +1 -1
  108. package/lib/types/settings-volatile.js +0 -7
  109. package/lib/types/settings-volatile.js.map +1 -1
  110. package/lib/types/source-identity.d.ts +34 -0
  111. package/lib/types/source-identity.d.ts.map +1 -0
  112. package/lib/types/source-identity.js +85 -0
  113. package/lib/types/source-identity.js.map +1 -0
  114. package/lib/types/source-read.d.ts +18 -0
  115. package/lib/types/source-read.d.ts.map +1 -0
  116. package/lib/types/source-read.js +49 -0
  117. package/lib/types/source-read.js.map +1 -0
  118. package/lib/types/store/axis-references.d.ts.map +1 -1
  119. package/lib/types/store/axis-references.js +5 -3
  120. package/lib/types/store/axis-references.js.map +1 -1
  121. package/lib/types/store/knowledge-db.d.ts +5 -29
  122. package/lib/types/store/knowledge-db.d.ts.map +1 -1
  123. package/lib/types/store/knowledge-db.js +57 -2
  124. package/lib/types/store/knowledge-db.js.map +1 -1
  125. package/lib/types/store/source-identity.d.ts +8 -0
  126. package/lib/types/store/source-identity.d.ts.map +1 -0
  127. package/lib/types/store/source-identity.js +56 -0
  128. package/lib/types/store/source-identity.js.map +1 -0
  129. package/lib/types/types.d.ts +46 -5
  130. package/lib/types/types.d.ts.map +1 -1
  131. package/lib/types/types.js +6 -3
  132. package/lib/types/types.js.map +1 -1
  133. package/lib/types/wiki-availability.d.ts +11 -0
  134. package/lib/types/wiki-availability.d.ts.map +1 -0
  135. package/lib/types/wiki-availability.js +39 -0
  136. package/lib/types/wiki-availability.js.map +1 -0
  137. package/lib/types/wiki-finalize.d.ts +4 -2
  138. package/lib/types/wiki-finalize.d.ts.map +1 -1
  139. package/lib/types/wiki-finalize.js +9 -27
  140. package/lib/types/wiki-finalize.js.map +1 -1
  141. package/lib/types/wiki-links.d.ts +35 -0
  142. package/lib/types/wiki-links.d.ts.map +1 -0
  143. package/lib/types/wiki-links.js +129 -0
  144. package/lib/types/wiki-links.js.map +1 -0
  145. package/lib/types/wiki-trial-types.d.ts +3 -0
  146. package/lib/types/wiki-trial-types.d.ts.map +1 -1
  147. package/lib/types/wiki-trial.d.ts +3 -3
  148. package/lib/types/wiki-trial.d.ts.map +1 -1
  149. package/lib/types/wiki-trial.js +14 -3
  150. package/lib/types/wiki-trial.js.map +1 -1
  151. package/package.json +16 -16
  152. package/scripts/doctor.mjs +0 -7
  153. package/scripts/setup-embedding.sh +0 -0
  154. package/src/agent-behavior-rules.ts +3 -2
  155. package/src/axis-contract.ts +1 -1
  156. package/src/card-organize.ts +5 -3
  157. package/src/client/AttributionEventGuide.tsx +3 -3
  158. package/src/client/AxisMatrix.tsx +86 -19
  159. package/src/client/AxisRegistrySection.tsx +1 -1
  160. package/src/client/CompileSettingsView.tsx +473 -0
  161. package/src/client/DirectoryPicker.tsx +0 -2
  162. package/src/client/ExtractionTasksPage.tsx +557 -423
  163. package/src/client/GuideArchitectureCanvas.tsx +5 -12
  164. package/src/client/ImportFilesDialog.tsx +1 -0
  165. package/src/client/InfoHint.tsx +1 -1
  166. package/src/client/KnowledgeBaseTab.tsx +141 -148
  167. package/src/client/KnowledgePanel.tsx +16 -9
  168. package/src/client/OperationLogTab.tsx +1 -1
  169. package/src/client/PipelineRunView.tsx +173 -0
  170. package/src/client/RecallPanel.tsx +5 -16
  171. package/src/client/TabGuide.tsx +3 -7
  172. package/src/client/WikiFlowPanel.tsx +10 -11
  173. package/src/client/arch/NodeLogicPanel.tsx +3 -3
  174. package/src/client/arch/model.ts +5 -113
  175. package/src/client/architecture.css +1 -1
  176. package/src/client/architecture.tsx +13 -27
  177. package/src/client/attribution/AttributionTab.tsx +141 -10
  178. package/src/client/pages.css +305 -180
  179. package/src/client/pages.tsx +7 -12
  180. package/src/client/styles.css +15 -96
  181. package/src/config-schema.ts +1 -1
  182. package/src/convert/host.ts +13 -5
  183. package/src/convert/ingest.ts +20 -7
  184. package/src/diagnosis/store.ts +7 -2
  185. package/src/diagnosis/types.ts +1 -1
  186. package/src/extract-model.ts +3 -0
  187. package/src/images.ts +1 -1
  188. package/src/index.ts +92 -97
  189. package/src/knowledge-bases.ts +23 -3
  190. package/src/knowledge-contract.ts +7 -2
  191. package/src/layout.ts +10 -32
  192. package/src/materialize-snapshot.ts +1 -0
  193. package/src/migrate.ts +3 -1
  194. package/src/model-text.ts +33 -11
  195. package/src/organize-contract.ts +2 -1
  196. package/src/pipeline/engine.ts +85 -22
  197. package/src/pipeline/progress.ts +40 -0
  198. package/src/pipeline/resumable-extract.ts +233 -0
  199. package/src/pipeline/store.ts +28 -6
  200. package/src/pipeline/types.ts +47 -2
  201. package/src/present.ts +1 -1
  202. package/src/preset-ids.ts +0 -4
  203. package/src/query-analysis.ts +0 -20
  204. package/src/recall-delivery.ts +19 -0
  205. package/src/retrieval/scope.ts +10 -0
  206. package/src/runtime.ts +412 -206
  207. package/src/settings-volatile.ts +0 -7
  208. package/src/source-identity.ts +85 -0
  209. package/src/source-read.ts +47 -0
  210. package/src/store/axis-references.ts +5 -3
  211. package/src/store/knowledge-db.ts +54 -2
  212. package/src/store/source-identity.ts +42 -0
  213. package/src/types.ts +18 -5
  214. package/src/wiki-availability.ts +37 -0
  215. package/src/wiki-finalize.ts +9 -25
  216. package/src/wiki-links.ts +120 -0
  217. package/src/wiki-trial-types.ts +3 -2
  218. package/src/wiki-trial.ts +17 -6
  219. package/lib/types/intent/engine.d.ts +0 -122
  220. package/lib/types/intent/engine.d.ts.map +0 -1
  221. package/lib/types/intent/engine.js +0 -488
  222. package/lib/types/intent/engine.js.map +0 -1
  223. package/lib/types/intent/host.d.ts +0 -68
  224. package/lib/types/intent/host.d.ts.map +0 -1
  225. package/lib/types/intent/host.js +0 -249
  226. package/lib/types/intent/host.js.map +0 -1
  227. package/lib/types/intent/scope.d.ts +0 -25
  228. package/lib/types/intent/scope.d.ts.map +0 -1
  229. package/lib/types/intent/scope.js +0 -116
  230. package/lib/types/intent/scope.js.map +0 -1
  231. package/lib/types/intent/store.d.ts +0 -33
  232. package/lib/types/intent/store.d.ts.map +0 -1
  233. package/lib/types/intent/store.js +0 -239
  234. package/lib/types/intent/store.js.map +0 -1
  235. package/lib/types/intent/tools.d.ts +0 -62
  236. package/lib/types/intent/tools.d.ts.map +0 -1
  237. package/lib/types/intent/tools.js +0 -130
  238. package/lib/types/intent/tools.js.map +0 -1
  239. package/lib/types/intent/types.d.ts +0 -170
  240. package/lib/types/intent/types.d.ts.map +0 -1
  241. package/lib/types/intent/types.js +0 -56
  242. package/lib/types/intent/types.js.map +0 -1
  243. package/src/client/IntentLogic.tsx +0 -192
  244. package/src/client/IntentTab.tsx +0 -343
  245. package/src/client/TypeDefinitionDrawer.tsx +0 -195
  246. package/src/intent/README.md +0 -42
  247. package/src/intent/engine.ts +0 -515
  248. package/src/intent/host.ts +0 -315
  249. package/src/intent/scope.ts +0 -125
  250. package/src/intent/store.ts +0 -218
  251. package/src/intent/tools.ts +0 -154
  252. package/src/intent/types.ts +0 -229
@@ -1,103 +1,95 @@
1
1
  /**
2
- * 知识处理与运行记录 —— 一整页。
2
+ * 知识处理 —— 一整页。它回答四个问题,按阅读顺序排:
3
3
  *
4
- * ## 这一页回答三个问题,按顺序,只有三层
4
+ * 1. **现在什么状态**:顶部一句实时结论(在跑哪一段、几分之几;或者「没有在跑」)。
5
+ * 2. **五段各走到哪**:一条阶段轨。每格只放三样 —— 段名、最近一次的结论、还有多少待办。
6
+ * 3. **这一段有哪些文档、抽出了什么**:主栏是文档列表(或分组 / 索引),每行是
7
+ * 「它是什么 + 抽到了多少 + 跑过几次」。
8
+ * 4. **这一份的轨迹**:侧栏。轨迹是过程记录,不是主结论;放在旁边,点主栏一行就换成那一份的。
5
9
  *
6
- * 1. **现在什么状态**:顶部一行实时结论(在跑哪一段、跑了几分之几;或者「没有在跑」)。
7
- * 2. **四段各差多少**:一条步骤轨。每格只放三样东西 —— 段名、最近一次的结论、还有多少待办。
8
- * 3. **这一段的细节**:输入范围、结果摘要、抽取报告、原始输出;右边一栏是「处理范围」与「运行记录」。
10
+ * ## 2026-10-28 为什么重排
9
11
  *
10
- * ## 2026-10-18 为什么重排
12
+ * 上一版把「输入范围 / 结果摘要 / 抽取报告 / 原始日志」全塞进主栏,而**真正要处理的对象**
13
+ * (文档)缩在右栏一个 260px 的小列表里。于是这一页看起来像「任务详情」,不像「处理台」:
14
+ * 人找不到「哪些文档还没抽」,也看不出「这份文档抽出了多少内容」。
11
15
  *
12
- * 原来的版式把所有东西压在同一条竖线里,同一件事被说了三遍:段页签上是「部分失败」,
13
- * 段头再说一次,结果摘要的第一行又是「状态:部分失败」,下面再写一遍「完成 0/1 · 失败 1」。
14
- * 而真正要紧的「还剩多少待办」缩成一行 10.5px 的灰字挤在页签上方,模型原始日志却占着一屏。
16
+ * 这一版把主次换回来:
15
17
  *
16
- * 所以这一版守四条:
18
+ * - **主栏是对象**:文档 / 分组 / 索引,按当前阶段给。每行第二、三行就是抽取产出与轨迹次数。
19
+ * - **侧栏是过程**:轨迹、运行记录两个页签。轨迹按选中那一份文档过滤,不选就是这一段的最近记录。
20
+ * - **编译设置**也住这一页(视图切换),模型选择改成对话框里的可选列表:它要容纳
21
+ * provider、模型说明、思考强度,一行一个扁平下拉读不出来。
22
+ * - **解析内核**的入口在这一页:它把文件变成 Markdown,是这条流水线的上游。
17
23
  *
18
- * - **一个事实只出现一次。** 阶段状态只在步骤轨上;段名只在段头;待办计数只在轨上那一格。
19
- * - **步骤轨横贯整页**,不再缩在左栏里 —— 它是这一页的骨架(有方向的流水线),不是左栏的页签头。
20
- * - **原始输出默认收起。** 它是排查用的底料,不是结论;任务在跑时自动展开。
21
- * - **左右两栏各自回答一类问题**:左栏「这一段跑了什么」,右栏「我要跑哪些」+「以前跑过什么」。
22
- *
23
- * 分页在服务端(`limit` / `offset`):每跑一次都会往里加,整份在手迟早会变成整份传一遍。
24
- * 页码状态走 `ListPager`,与知识库其余列表同一个 `PAGE_SIZE`。
24
+ * 分页仍在服务端(`limit` / `offset`):每跑一次都会往任务表里加,整份在手迟早会变成
25
+ * 整份传一遍。页码状态走 `ListPager`,与知识库其余列表同一个 `PAGE_SIZE`。
25
26
  */
26
27
 
27
- import { Fragment, useCallback, useEffect, useRef, useState, type ReactNode } from 'react'
28
+ import { Fragment, useCallback, useEffect, useMemo, useRef, useState, type ReactNode } from 'react'
28
29
  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 { AlertCircle, ArrowRight, Check, ChevronDown, ExternalLink, FileUp, History, Pause, Play, RefreshCw, X } from '@tita-agent/ui/icons'
30
31
  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'
32
+ import { PIPELINE_ACTIVE_STATES, PIPELINE_STAGE_LABEL, type PipelineStage, type PipelineStatus, type PipelineTask } from '../pipeline/types.js'
32
33
  import { formatDateTime } from './format.ts'
33
34
  import { ListPager, PAGE_SIZE } from './ListPager.tsx'
34
35
  import { requestJson } from './request.ts'
35
36
  import { requestExtractionRuns, trajectoryStageOf, type ExtractionRunSummary } from './wiki-trial.ts'
36
- import { TRAJECTORY_STAGE_LABEL, type TrajectoryStage } from '../trajectory-types.js'
37
+ import type { TrajectoryStage } from '../trajectory-types.js'
38
+ import { CompileSettingsView } from './CompileSettingsView.tsx'
39
+ import { PIPELINE_STATE_LABEL as STATES, observeTaskStage, orderActiveTasks } from '../pipeline/progress.js'
40
+ import { PIPELINE_STAGE_ORDER } from '../pipeline/types.js'
41
+ import { RawTaskOutput, RunActivity, RunSummary, useTaskFeed } from './PipelineRunView.tsx'
37
42
 
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
43
  const GROUP_STATES = { new: '待新建', changed: '待更新', current: '已物化', withdrawn: '待停用', blocked: '先归一来源' }
41
- interface History { tasks: PipelineTask[]; total: number }
44
+ const DOC_STATES: Record<PipelineStatus['sources'][number]['state'], { label: string; tone: 'neutral' | 'success' | 'warning' }> = {
45
+ pending: { label: '待抽取', tone: 'neutral' },
46
+ extracted: { label: '待归一', tone: 'neutral' },
47
+ normalized: { label: '已归一', tone: 'success' },
48
+ outdated: { label: '来源已更新', tone: 'warning' },
49
+ }
50
+ interface History { tasks: PipelineTask[]; total: number; activeTasks?: PipelineTask[] }
51
+ interface DocumentMeta { docId: string; title: string; sourceFormat: string; ext: string; bytes: number; updatedAt: string; versionCount: number }
52
+ interface DocumentPage { documents?: DocumentMeta[]; total?: number; extractionRuns?: Record<string, number> }
53
+ interface DocumentRow extends DocumentMeta {
54
+ id: string
55
+ version: number
56
+ state: PipelineStatus['sources'][number]['state']
57
+ runs: ExtractionRunSummary[]
58
+ trailCount: number
59
+ }
42
60
  const toggle = (ids: string[], id: string): string[] => ids.includes(id) ? ids.filter(value => value !== id) : [...ids, id]
43
61
  type StageId = Exclude<PipelineStage, 'all'>
44
62
  interface StageObservation { label: string; tone: 'neutral' | 'success' | 'warning' | 'blue'; detail: string; task?: PipelineTask }
45
63
 
46
- const STAGE_ORDER: readonly StageId[] = ['extract', 'normalize', 'derive', 'materialize', 'build']
64
+ /**
65
+ * 阶段顺序就是流水线的方向。**`derive` 在 `materialize` 之前**:Procedure 是知识,
66
+ * 卡片是按坐标把它们组织起来的产物;先有方法才谈得上组织。
67
+ */
68
+ const STAGE_ORDER = PIPELINE_STAGE_ORDER
47
69
 
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 }
70
+ const STAGE_DETAIL: Record<StageId, string> = {
71
+ extract: '从来源原文抽取 Knowledge、Event 与 Case,保留引文。',
72
+ normalize: '合并同指实体,把对象 / 动作 / 场景统一成坐标。',
73
+ derive: '只从有过程证据的 Case 提炼 Procedure;没有证据就不生成方法。',
74
+ materialize: '按完整坐标把知识与事件组织成 WikiCard。',
75
+ build: '从已物化卡片构建全文、向量与图谱索引,然后统一发布。',
60
76
  }
61
77
 
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 }
78
-
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
- }
90
-
91
- const completed = tasks.find(task => task.results?.[stage])
92
- const result = completed?.results?.[stage]
93
- if (completed && result) return completedStageObservation(stage, completed, result)
78
+ /** 侧栏叫「轨迹」而不是「报告」:它是过程记录,不是结论。 */
79
+ const TRAJECTORY_TITLE: Record<TrajectoryStage, string> = {
80
+ extract: '抽取轨迹',
81
+ normalize: '归一轨迹',
82
+ materialize: '物化轨迹',
83
+ }
94
84
 
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
- }
85
+ function bytesText(bytes: number): string {
86
+ if (bytes < 1024) return `${bytes} B`
87
+ if (bytes < 1024 * 1024) return `${Math.round(bytes / 1024)} KB`
88
+ return `${(bytes / 1024 / 1024).toFixed(1)} MB`
89
+ }
100
90
 
91
+ function stageObservation(stage: StageId, task?: PipelineTask, status?: PipelineStatus): StageObservation {
92
+ if (task) return { ...observeTaskStage(task, stage), task }
101
93
  if (!status) return { label: '读取中', tone: 'neutral', detail: '正在读取当前处理状态。' }
102
94
  if (stage === 'extract') {
103
95
  const count = status.sources.length
@@ -127,13 +119,9 @@ function stageObservation(stage: StageId, tasks: PipelineTask[], status?: Pipeli
127
119
  }
128
120
 
129
121
  /**
130
- * 这一步**还差多少活** —— 步骤轨上那一格的第二行。
122
+ * 这一步**还差多少活** —— 阶段轨上那一格的第二行。
131
123
  *
132
- * 它与 `stageObservation` 的两个字段刻意分开:`label` 说的是**上一次跑成什么样**
133
- * (已完成 / 部分失败),这一行说的是**现在还剩什么**。原来这两件事被挤在同一行
134
- * 10.5px 的灰字里(「0 张待新建 · 58 张待更新 · …」),既没有主语也没有和阶段对上。
135
- *
136
- * 数字全部来自 `status`,算不出来就说算不出来,不补 0。
124
+ * 仅在没有查看任务时使用;不与任务状态混在同一格。
137
125
  */
138
126
  function stagePending(stage: StageId, status?: PipelineStatus): string {
139
127
  if (!status) return '读取中'
@@ -147,152 +135,281 @@ function stagePending(stage: StageId, status?: PipelineStatus): string {
147
135
  const waiting = status.sources.filter(source => source.state === 'pending' || source.state === 'outdated').length
148
136
  return waiting ? `等待 ${waiting} 份抽取` : '无待归一'
149
137
  }
138
+ // 提炼按 Case 运行,不按文档计数;这里不编一个「可读 Case」的数。
139
+ if (stage === 'derive') return '按 Case 运行'
150
140
  if (stage === 'materialize') {
151
141
  const pending = status.groups.filter(group => group.state !== 'current').length
152
142
  return pending ? `${pending} 个分组待处理` : '无待物化'
153
143
  }
154
- return status.indexPending ? '索引待更新' : '索引已最新'
144
+ return status.indexPending ? '索引有变化' : '已发布'
155
145
  }
156
146
 
157
- function TaskOutput({ kb, id, onTask, onOpenFailed, stageFilter, taskStage }: { kb: string; id: string; onTask: (task: PipelineTask) => void; onOpenFailed: (message: string) => void; stageFilter?: StageId; taskStage?: PipelineStage }) {
158
- const [events, setEvents] = useState<PipelineEvent[]>([])
159
- const [failure, setFailure] = useState('')
160
- const callback = useRef(onTask)
161
- callback.current = onTask
162
- useEffect(() => {
163
- let active = true
164
- let busy = false
165
- let cursor = 0
166
- let terminal = false
167
- setFailure('')
168
- setEvents([])
169
- const read = async (): Promise<void> => {
170
- if (busy || terminal) return
171
- busy = true
172
- try {
173
- let more = true
174
- while (active && more) {
175
- const result = await requestJson<{ task: PipelineTask; events: PipelineEvent[]; next: number; hasMore: boolean }>(`/knowledge/pipeline-task?${new URLSearchParams({ kb, id, after: String(cursor) })}`)
176
- if (!active || !result.data) return
177
- const page = result.data
178
- cursor = page.next
179
- more = page.hasMore
180
- terminal = !more && !PIPELINE_ACTIVE_STATES.includes(page.task.state)
181
- callback.current(page.task)
182
- setEvents(current => [...current, ...page.events].slice(-600))
183
- setFailure('')
184
- }
185
- } catch (cause) { if (active) setFailure(cause instanceof Error ? cause.message : String(cause)) }
186
- finally { busy = false }
187
- }
188
- void read()
189
- const timer = window.setInterval(() => { void read() }, 1200)
190
- return () => { active = false; window.clearInterval(timer) }
191
- }, [kb, id])
192
- const stageEvents = events.filter(event => !stageFilter || event.stage === stageFilter || (!event.stage && taskStage === stageFilter))
193
- const hasUnscopedLegacyEvents = Boolean(stageFilter && taskStage === 'all' && events.some(event => !event.stage))
194
- const output = stageEvents.filter(event => event.kind !== 'report').map(event => event.kind === 'output' ? event.text : `\n${event.text}\n`).join('').slice(-200000)
195
- const reports = events.filter(event => event.kind === 'report' && (!stageFilter || event.stage === stageFilter || (!event.stage && (taskStage === stageFilter || stageFilter === 'extract' && taskStage === 'all'))))
196
- return <div className="extractTasksOutputBody">
197
- <p>{stageFilter ? hasUnscopedLegacyEvents ? '旧版全链路日志没有阶段标记;完整输出仍可在运行记录中查看。' : '这里只显示当前阶段的进度、模型输出和警告。' : '模型正式输出持续返回;刷新后可以补读。预览保留最近内容,完整返回保存在报告中。'}</p>
198
- {failure && <p className="extractTasksError" role="alert">{failure}</p>}
199
- <pre className="extractTasksPre" tabIndex={0} aria-label={stageFilter ? `${PIPELINE_STAGE_LABEL[stageFilter]}运行输出` : '实时输出'}>{output || (hasUnscopedLegacyEvents ? '这条历史记录没有可按阶段筛选的日志。' : '等待本阶段返回内容…')}</pre>
200
- {/*
201
- * 应用内路径不能用裸 `<a href>` 开(见 `KnowledgeBaseTab` 顶栏那条注释):桌面壳里
202
- * `/knowledge/pages/...` 会被解析成 `dsh-app://` 地址,而壳的 `setWindowOpenHandler`
203
- * 只放行 http/https —— 表现是「点了没有任何反应,也不报错」。走 `openAppPath()`
204
- * 才是同一枚按钮在两种外壳下各自成立的那件事。
205
- */}
206
- <div className="extractTasksReportsInline">{reports.map(event => <Button
207
- key={event.seq}
208
- variant="ghost"
209
- size="sm"
210
- className="extractTasksLink"
211
- onClick={() => { void openAppPath(`/knowledge/pages/run?${new URLSearchParams({ kb, run: event.text })}`).then(opened => { if (!opened) onOpenFailed('打不开抽取报告:当前外壳下拿不到它的地址。') }) }}
212
- >查看抽取报告 <ExternalLink size={11} className="inline" /></Button>)}</div>
213
- </div>
147
+ /** Raw output is optional detail; observation stays mounted in the page. */
148
+ function StageOutput({ children }: { children: ReactNode }): ReactNode {
149
+ const [open, setOpen] = useState(false)
150
+ return <section className="ktOutput">
151
+ <Button variant="ghost" size="sm" className="ktOutputToggle" aria-expanded={open} onClick={() => setOpen(current => !current)}>
152
+ <ChevronDown size={13} />原始模型输出<span>{open ? '收起' : '按需展开'}</span>
153
+ </Button>
154
+ {open && children}
155
+ </section>
214
156
  }
215
157
 
216
158
  /**
217
- * 这一段跑过的轨迹,只列**这一段自己的**。
159
+ * 主栏的文档列表。
218
160
  *
219
- * 2026-10-18 之前这一格叫「抽取报告」,而且读的是 `/knowledge/pipeline-tasks` 里那份
220
- * 跟**任务分页共用一个 offset** 的窗口 —— 翻到第 2 页时会空掉,而标题还写着「共 N 份」。
221
- * 现在三段各有自己的轨迹(`stage` 列),这一格改成单取一次列表、按阶段分。
222
- *
223
- * 每段最多露 8 条:这一页的主线是「现在什么状态」,历史该去整页翻(点开就是整页)。
161
+ * 一行三件事,从上到下:**它是谁**(标题)、**它是什么**(格式 / 体积 / 版本 / 更新时间)、
162
+ * **抽出了多少**(最近一次抽取的规模描述)。左侧方框是**下一次运行的范围**,行本身是
163
+ * **查看这一份的轨迹** —— 两件事各有各的命中区,不互相代替。
224
164
  */
225
- const STAGE_TRAJECTORY_CAP = 8
165
+ function itemObservation(task: PipelineTask | undefined, stage: StageId, id: string, kind: 'source' | 'group'): StageObservation | undefined {
166
+ if (!task) return undefined
167
+ if (task.stage !== 'all' && task.stage !== stage) return { label: '本次未运行', tone: 'neutral', detail: '' }
168
+ const scope = kind === 'source' ? task.selection.sourceIds : task.selection.groupKeys
169
+ if (scope && !scope.includes(id)) return { label: '不在本次范围', tone: 'neutral', detail: '' }
170
+ const item = task.items?.[stage]?.[id]
171
+ if (item) {
172
+ if (item.state === 'processing' && !PIPELINE_ACTIVE_STATES.includes(task.state)) return { label: STATES[task.state], tone: 'warning', detail: '此项尚未完成。' }
173
+ const labels = { processing: '正在处理', succeeded: '本次完成', reused: '本次复用', failed: '本次失败' }
174
+ return { label: item.state === 'processing' && task.state === 'paused' ? '已暂停' : labels[item.state], tone: item.state === 'failed' ? 'warning' : item.state === 'processing' ? 'blue' : 'success', detail: item.summary ?? '' }
175
+ }
176
+ const stageState = observeTaskStage(task, stage)
177
+ if (task.results?.[stage]) return { label: task.items?.[stage] ? '本次未处理' : '逐项状态未记录', tone: 'neutral', detail: '' }
178
+ return { label: PIPELINE_ACTIVE_STATES.includes(task.state) ? '等待处理' : stageState.label === '未执行' ? '未执行' : '尚未完成', tone: 'neutral', detail: '' }
179
+ }
180
+
181
+ function DocumentList({ rows, selected, onSelect, selectedIds, onToggle, stage, task, currentSourceId }: {
182
+ rows: DocumentRow[]
183
+ selected: string
184
+ onSelect: (id: string) => void
185
+ selectedIds: string[]
186
+ onToggle: (id: string) => void
187
+ stage: StageId
188
+ task?: PipelineTask
189
+ currentSourceId?: string
190
+ }): ReactNode {
191
+ if (rows.length === 0) return <p className="ktEmpty">这个库还没有来源。先在来源栏导入文档,再回来抽取。</p>
192
+ return <ul className="ktDocs" aria-label="文档列表">
193
+ {rows.map(row => {
194
+ const state = DOC_STATES[row.state]
195
+ const current = task && !task.items && task.currentStage === stage && currentSourceId === row.id && PIPELINE_ACTIVE_STATES.includes(task.state)
196
+ ? { label: task.state === 'running' ? '正在处理' : STATES[task.state], tone: 'blue' as const, detail: '' }
197
+ : itemObservation(task, stage, row.id, 'source')
198
+ const picked = selectedIds.includes(row.id)
199
+ const inspecting = selected === row.id
200
+ const latest = row.runs
201
+ .filter(run => run.stage === stage)
202
+ .sort((left, right) => right.createdAt.localeCompare(left.createdAt))[0]
203
+ return <li key={row.id} className="ktDoc" data-object-id={row.id} data-selected={inspecting || undefined}>
204
+ <button
205
+ type="button"
206
+ className="ktDocPick"
207
+ aria-pressed={picked}
208
+ aria-label={picked ? `把 ${row.title} 移出下次运行范围` : `把 ${row.title} 加入下次运行范围`}
209
+ onClick={() => onToggle(row.id)}
210
+ ><span className="ktDocPickBox" aria-hidden="true">{picked && <Check size={11} />}</span></button>
211
+ <button
212
+ type="button"
213
+ className="ktDocMain"
214
+ aria-current={inspecting ? 'true' : undefined}
215
+ onClick={() => onSelect(row.id)}
216
+ >
217
+ <span className="ktDocTitle">{row.title}</span>
218
+ <span className="ktDocMeta">
219
+ {row.ext !== '' || row.sourceFormat !== ''
220
+ ? (row.ext || row.sourceFormat).replace(/^\./, '').toUpperCase()
221
+ : '来源'}
222
+ {row.bytes > 0 && <><i aria-hidden="true">·</i>{bytesText(row.bytes)}</>}
223
+ <i aria-hidden="true">·</i>v{row.version}
224
+ <i aria-hidden="true">·</i>共 {row.trailCount} 条轨迹
225
+ <i aria-hidden="true">·</i>{latest ? '上次轨迹 ' : '来源更新 '}{formatDateTime(latest?.createdAt ?? row.updatedAt)}
226
+ <i aria-hidden="true">·</i>库内状态:{state.label}
227
+ </span>
228
+ <span className="ktDocFacts">
229
+ {latest
230
+ ? `历史记录:${latest.summary}`
231
+ : stage === 'extract' ? '还没有抽取记录。' : '还没有归一记录。'}
232
+ </span>
233
+ </button>
234
+ <span className="ktDocSide">
235
+ <Badge tone={current?.tone ?? state.tone} title={current?.detail}>{current?.label ?? state.label}</Badge>
236
+ </span>
237
+ </li>
238
+ })}
239
+ </ul>
240
+ }
226
241
 
227
- function StageTrajectories({ kb, stage, runs, onOpenFailed }: { kb: string; stage: TrajectoryStage; runs: ExtractionRunSummary[]; onOpenFailed: (message: string) => void }): ReactNode {
228
- const own = runs.filter(run => trajectoryStageOf(run) === stage)
229
- if (own.length === 0) return null
230
- const shown = own.slice(0, STAGE_TRAJECTORY_CAP)
231
- return <section className="extractTasksReports" aria-labelledby={`trajectory-${stage}`}>
232
- <div className="extractTasksSubhead">
242
+ /** 物化阶段的对象是**分组**,不是文档:同坐标的知识与事件一起成卡。 */
243
+ function GroupList({ groups, selectedKeys, onToggle, task, inspectingKey }: {
244
+ groups: PipelineStatus['groups']
245
+ selectedKeys: string[]
246
+ onToggle: (key: string) => void
247
+ task?: PipelineTask
248
+ inspectingKey?: string
249
+ }): ReactNode {
250
+ if (groups.length === 0) return <p className="ktEmpty">还没有可物化的分组。先抽取并归一文档内容。</p>
251
+ return <ul className="ktDocs" aria-label="卡片分组列表">
252
+ {groups.map(group => {
253
+ const picked = selectedKeys.includes(group.key)
254
+ const current = task?.selection.sourceIds && !group.sourceIds.some(id => task.selection.sourceIds!.includes(id))
255
+ ? { label: '不在本次范围', tone: 'neutral' as const }
256
+ : itemObservation(task, 'materialize', group.key, 'group')
257
+ return <li key={group.key} className="ktDoc" data-object-id={group.key} data-selected={picked || inspectingKey === group.key || undefined}>
258
+ <button type="button" className="ktDocPick" aria-pressed={picked} aria-label={picked ? `取消选择 ${group.title}` : `选择 ${group.title}`} onClick={() => onToggle(group.key)}>
259
+ <span className="ktDocPickBox" aria-hidden="true">{picked && <Check size={11} />}</span>
260
+ </button>
261
+ <div className="ktDocMain ktDocMainStatic">
262
+ <span className="ktDocTitle">{group.title}</span>
263
+ <span className="ktDocMeta">
264
+ {group.knowledgeCount} 条 Knowledge<i aria-hidden="true">·</i>{group.eventCount} 个 Event<i aria-hidden="true">·</i>{group.sourceIds.length} 份来源
265
+ <i aria-hidden="true">·</i>库内:{GROUP_STATES[group.state]}
266
+ </span>
267
+ {group.reason !== undefined && group.reason !== '' && <span className="ktDocFacts ktDocReason">{group.reason}</span>}
268
+ </div>
269
+ <span className="ktDocSide"><Badge tone={current?.tone ?? (group.state === 'current' ? 'success' : group.state === 'blocked' ? 'warning' : 'neutral')}>{current?.label ?? GROUP_STATES[group.state]}</Badge></span>
270
+ </li>
271
+ })}
272
+ </ul>
273
+ }
274
+
275
+ /** 侧栏的轨迹列表。按**当前阶段 + 选中文档**过滤;不选文档时列这一段的全部最近记录。 */
276
+ function TrajectoryPanel({ kb, stage, selectedDoc, docTitle, runs, onClearDoc, onOpenFailed }: {
277
+ kb: string
278
+ stage: StageId
279
+ selectedDoc: string
280
+ docTitle: string
281
+ runs: ExtractionRunSummary[]
282
+ onClearDoc: () => void
283
+ onOpenFailed: (message: string) => void
284
+ }): ReactNode {
285
+ if (stage === 'derive') {
286
+ return <div className="ktSideEmpty">
287
+ <strong>提炼 Procedure 没有独立轨迹</strong>
288
+ <p>它读 Case 的检查与动作,产出记在 Case 的派生结果里;运行详情在「运行记录」里看。</p>
289
+ </div>
290
+ }
291
+ if (stage === 'build') {
292
+ return <div className="ktSideEmpty">
293
+ <strong>构建索引不留轨迹</strong>
294
+ <p>它的产物是可重建的检索索引,不是一次判断;跑成什么样在「运行记录」和索引状态里看。</p>
295
+ </div>
296
+ }
297
+ const own = runs.filter(run => trajectoryStageOf(run) === stage && (selectedDoc === '' || run.docId === selectedDoc))
298
+ return <>
299
+ <div className="ktTrailHead">
233
300
  <div>
234
- <h3 id={`trajectory-${stage}`}>{TRAJECTORY_STAGE_LABEL[stage]}</h3>
235
- <p>每次运行的过程记录;点开在新窗口读完整轨迹。</p>
301
+ <h3>{TRAJECTORY_TITLE[stage as TrajectoryStage]}</h3>
302
+ <p>{selectedDoc === '' ? '这一段最近的运行记录;点开在整页读全文。' : '只看选中这一份。'}</p>
236
303
  </div>
237
- <span className="extractTasksCount">{own.length > shown.length ? `${shown.length} / ${own.length} 条` : `${own.length} 条`}</span>
304
+ <span className="ktTrailCount">{own.length} 条</span>
238
305
  </div>
239
- {shown.map(run => <button
240
- key={run.id}
241
- type="button"
242
- className="extractReportRow"
243
- onClick={() => { void openAppPath(`/knowledge/pages/run?${new URLSearchParams({ kb, run: run.id })}`).then(opened => { if (!opened) onOpenFailed('打不开轨迹:当前外壳下拿不到它的地址。') }) }}
244
- >
245
- <span className="extractReportMain"><strong>{run.title || '(没有标题)'}</strong><span>{run.summary}</span></span>
246
- <span className="extractReportMeta">{formatDateTime(run.createdAt)}<ExternalLink size={13} /></span>
247
- </button>)}
248
- </section>
306
+ {selectedDoc !== '' && <div className="ktTrailFilter">
307
+ <span title={docTitle}>只看:{docTitle || selectedDoc}</span>
308
+ <Button variant="ghost" size="sm" className="ktLink" onClick={onClearDoc}>看全部<X size={11} /></Button>
309
+ </div>}
310
+ {own.length === 0
311
+ ? <p className="ktSideEmptyText">{selectedDoc === '' ? '这一段还没有轨迹。运行一次就有了。' : '这份文档在这一段还没有轨迹。'}</p>
312
+ : <ul className="ktTrailList">{own.slice(0, 20).map(run => <li key={run.id}>
313
+ <button
314
+ type="button"
315
+ className="ktTrailRow"
316
+ onClick={() => { void openAppPath(`/knowledge/pages/run?${new URLSearchParams({ kb, run: run.id })}`).then(opened => { if (!opened) onOpenFailed('打不开轨迹:当前外壳下拿不到它的地址。') }) }}
317
+ >
318
+ <span className="ktTrailRowTitle">{run.title || '(没有标题)'}{!run.modelOk && <Badge tone="warning">模型未接通</Badge>}</span>
319
+ <span className="ktTrailRowSummary">{run.summary}</span>
320
+ <span className="ktTrailRowMeta">{formatDateTime(run.createdAt)}<ExternalLink size={12} /></span>
321
+ </button>
322
+ </li>)}</ul>}
323
+ </>
249
324
  }
250
325
 
251
- /**
252
- * 运行输出**默认收起**。
253
- *
254
- * 它是模型原始返回(几百行),而这一页的主线是「哪一段还剩多少活、跑完是什么结果」。
255
- * 此前它是常驻展开的 `<pre>`:一屏高的灰块压在报告下面,日志和结论挤在同一层,
256
- * 两样都读不成。收起来之后它仍在原地 —— 点一下展开,任务在跑时自动展开(那时日志是唯一在变的东西)。
257
- */
258
- function StageOutput({ active, children }: { active: boolean; children: ReactNode }): ReactNode {
259
- const [open, setOpen] = useState(active)
260
- useEffect(() => { if (active) setOpen(true) }, [active])
261
- return <section className="extractTasksOutput">
262
- <Button variant="ghost" size="sm" className="extractTasksOutputToggle" aria-expanded={open} onClick={() => setOpen(current => !current)}>
263
- <ChevronDown size={13} />运行输出<span>{open ? '收起原始日志' : '展开原始日志'}</span>
264
- </Button>
265
- {open && children}
266
- </section>
326
+ /** 侧栏的运行记录:暂停 / 继续 / 停止与历史输出。 */
327
+ function RunHistoryPanel({ tasks, orderedTasks, total, page, onPage, activeCount, selectedTask, onToggleTask, busy, onAct }: {
328
+ tasks: PipelineTask[]
329
+ orderedTasks: PipelineTask[]
330
+ total: number
331
+ page: number
332
+ onPage: (page: number) => void
333
+ activeCount: number
334
+ selectedTask: string
335
+ onToggleTask: (id: string) => void
336
+ busy: boolean
337
+ onAct: (id: string, action: string) => void
338
+ }): ReactNode {
339
+ const pageCount = Math.max(1, Math.ceil(total / PAGE_SIZE))
340
+ return <div className="ktRuns">
341
+ <p className="ktRunsNote">{activeCount ? '暂停在安全点生效,停止会保留已保存的结果。' : '选择一条任务查看它自己的阶段与活动记录。'}</p>
342
+ {tasks.length === 0 && <p className="ktEmpty">还没有阶段任务。选好范围后运行一段就有了。</p>}
343
+ {orderedTasks.map(task => <article key={task.id} className="ktRunRow">
344
+ <div className="ktRunTitle">
345
+ <strong>{PIPELINE_STAGE_LABEL[task.stage]}</strong>
346
+ <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>
347
+ </div>
348
+ <p className="ktRunSummary">{task.summary}</p>
349
+ <p className="ktRunTime">{formatDateTime(task.queuedAt)}{task.model.model ? ` · ${task.model.provider ?? ''} / ${task.model.model}` : ''}{task.model.reasoningEffort ? ` · ${task.model.reasoningEffort}` : ''}</p>
350
+ <div className="ktRunControls">
351
+ <Button size="sm" variant="ghost" className="ktRunToggle" aria-pressed={selectedTask === task.id} onClick={() => onToggleTask(task.id)}>{selectedTask === task.id ? '正在查看此任务' : '查看此任务'}</Button>
352
+ {PIPELINE_ACTIVE_STATES.includes(task.state) ? <>
353
+ <Button size="sm" variant="outline" disabled={busy || task.state === 'cancelling' || task.state === 'pausing'} onClick={() => onAct(task.id, task.state === 'paused' ? 'resume' : 'pause')}>{task.state === 'paused' ? <Play size={12} /> : <Pause size={12} />}{task.state === 'paused' ? '继续' : '暂停'}</Button>
354
+ <Button size="sm" variant="ghost" disabled={busy || task.state === 'cancelling'} onClick={() => onAct(task.id, 'cancel')}><X size={12} />停止</Button>
355
+ </> : task.state === 'succeeded'
356
+ // 跑完的任务没有「未完成部分」。再跑一次是主栏那个阶段动作的事,
357
+ // 不在这里放一枚读起来像「上次没跑完」的按钮。
358
+ ? null
359
+ : <Button size="sm" variant="outline" disabled={busy} onClick={() => onAct(task.id, 'retry')}>继续未完成部分</Button>}
360
+ </div>
361
+ </article>)}
362
+ <ListPager page={page} pageCount={pageCount} total={total} onChange={onPage} />
363
+ </div>
267
364
  }
268
365
 
269
366
  export function ExtractionTasksPage({ kb }: { kb: string }): ReactNode {
270
367
  const params = new URLSearchParams(typeof window === 'undefined' ? '' : window.location.search)
368
+ const [view, setView] = useState<'run' | 'settings'>(params.get('view') === 'settings' ? 'settings' : 'run')
369
+ const [stage, setStage] = useState<StageId>('extract')
271
370
  const [status, setStatus] = useState<PipelineStatus>()
371
+ const [documents, setDocuments] = useState<DocumentMeta[]>([])
372
+ /** 服务端给的「每份文档有几条轨迹」;比按当前这 100 条轨迹数更准。 */
373
+ const [runCounts, setRunCounts] = useState<Record<string, number>>({})
272
374
  const [history, setHistory] = useState<History>()
273
375
  const [sourceIds, setSourceIds] = useState<string[]>(() => params.get('source') ? [params.get('source')!] : [])
274
376
  const [groupKeys, setGroupKeys] = useState<string[]>([])
377
+ const [selectedDoc, setSelectedDoc] = useState('')
378
+ /** 侧栏的页签:点主栏一行要能自动带出「轨迹」,否则人点了文档却看不到它的轨迹。 */
379
+ const [sideTab, setSideTab] = useState('activity')
380
+ /** 刷新要连编译设置一起重读(它是另一个视图自己的数据),所以用 key 让它重挂一次。 */
381
+ const [settingsKey, setSettingsKey] = useState(0)
275
382
  const [selectedTask, setSelectedTask] = useState(() => params.get('task') ?? '')
276
- const [failure, setFailure] = useState('')
383
+ const selectedTaskRef = useRef(selectedTask)
384
+ selectedTaskRef.current = selectedTask
385
+ const [pinnedTask, setPinnedTask] = useState(Boolean(params.get('task')))
386
+ useEffect(() => {
387
+ const url = new URL(window.location.href)
388
+ if (pinnedTask && selectedTask) url.searchParams.set('task', selectedTask)
389
+ else url.searchParams.delete('task')
390
+ window.history.replaceState(null, '', url)
391
+ }, [pinnedTask, selectedTask])
392
+ const [followRun, setFollowRun] = useState(true)
393
+ const [activeTasks, setActiveTasks] = useState<PipelineTask[]>([])
394
+ const [notice, setNotice] = useState<{ tone: 'success' | 'error'; text: string } | null>(null)
277
395
  const [busy, setBusy] = useState(false)
278
396
  /** 1-based:与 `ListPager` 同一个口径。 */
279
397
  const [page, setPage] = useState(1)
280
398
  const refreshSequence = useRef(0)
281
- /**
282
- * 四段步轨的阶段结论读这里,而不是当前这一页任务。
283
- *
284
- * 任务列表是服务端分页的(一页 `PAGE_SIZE` 条):某个阶段的最新结论一旦翻出第一页,
285
- * 直接搜当前页会让那一段悄悄退回「待运行」—— 屏幕上看起来像从来没跑过。
286
- * 所以按 id 累积见过的状态,只留最近 200 条(这只是阶段结论的缓存,完整历史在服务端)。
287
- */
399
+ /** Cache viewed executions independently of the paginated history. */
288
400
  const [stageTasks, setStageTasks] = useState<PipelineTask[]>([])
289
- /** 三段轨迹的列表(不含报告本体)。与任务分页**无关** —— 它按 stage 分,各自取最近若干条。 */
401
+ /** 三段轨迹的列表(不含报告本体)。与任务分页**无关**。 */
290
402
  const [trajectories, setTrajectories] = useState<ExtractionRunSummary[]>([])
291
403
  const seenTasks = useRef(new Map<string, PipelineTask>())
292
404
  const rememberTasks = useCallback((rows: PipelineTask[]): void => {
293
- for (const task of rows) seenTasks.current.set(task.id, task)
405
+ for (const task of rows) {
406
+ const previous = seenTasks.current.get(task.id)
407
+ const stamp = (value: PipelineTask): string => value.finishedAt ?? value.activity?.at ?? value.startedAt ?? value.queuedAt
408
+ if (!previous || stamp(task) >= stamp(previous)) seenTasks.current.set(task.id, task)
409
+ }
294
410
  if (seenTasks.current.size > 200) {
295
- const ordered = [...seenTasks.current.values()].sort((left, right) => right.queuedAt.localeCompare(left.queuedAt)).slice(0, 200)
411
+ const priority = (task: PipelineTask): number => Number(task.id === selectedTaskRef.current) * 2 + Number(PIPELINE_ACTIVE_STATES.includes(task.state))
412
+ const ordered = [...seenTasks.current.values()].sort((left, right) => priority(right) - priority(left) || right.queuedAt.localeCompare(left.queuedAt)).slice(0, 200)
296
413
  seenTasks.current = new Map(ordered.map(task => [task.id, task]))
297
414
  }
298
415
  setStageTasks([...seenTasks.current.values()].sort((left, right) => right.queuedAt.localeCompare(left.queuedAt)))
@@ -301,278 +418,295 @@ export function ExtractionTasksPage({ kb }: { kb: string }): ReactNode {
301
418
  if (!kb) return
302
419
  const sequence = ++refreshSequence.current
303
420
  try {
304
- const [nextStatus, nextHistory, nextTrajectories] = await Promise.all([
421
+ const [nextStatus, nextHistory, nextTrajectories, nextDocuments] = await Promise.all([
305
422
  requestJson<PipelineStatus>(`/knowledge/pipeline?kb=${encodeURIComponent(kb)}`),
306
423
  requestJson<History>(`/knowledge/pipeline-tasks?${new URLSearchParams({ kb, limit: String(PAGE_SIZE), offset: String((page - 1) * PAGE_SIZE) })}`),
307
424
  // 轨迹库不可用时返回 `available: false` 而不是报错:这一段没有轨迹,不该让整页红一下。
308
- requestExtractionRuns({ kb, limit: 40 }).catch(() => null),
425
+ requestExtractionRuns({ kb, limit: 100 }).catch(() => null),
426
+ requestJson<DocumentPage>(`/knowledge/documents?${new URLSearchParams({ kb, limit: '500' })}`).catch(() => null),
309
427
  ])
310
428
  if (sequence !== refreshSequence.current) return
311
429
  if (nextStatus.data) setStatus(nextStatus.data)
312
- if (nextHistory.data) { setHistory(nextHistory.data); rememberTasks(nextHistory.data.tasks) }
430
+ if (nextHistory.data) {
431
+ setHistory(nextHistory.data)
432
+ const active = nextHistory.data.activeTasks ?? nextHistory.data.tasks.filter(task => PIPELINE_ACTIVE_STATES.includes(task.state))
433
+ setActiveTasks(active)
434
+ rememberTasks([...active, ...nextHistory.data.tasks])
435
+ }
313
436
  if (nextTrajectories) setTrajectories(nextTrajectories.runs)
314
- setFailure('')
315
- } catch (cause) { if (sequence === refreshSequence.current) setFailure(cause instanceof Error ? cause.message : String(cause)) }
437
+ if (nextDocuments?.data?.documents) {
438
+ setDocuments(nextDocuments.data.documents)
439
+ setRunCounts(nextDocuments.data.extractionRuns ?? {})
440
+ }
441
+ } catch (cause) {
442
+ if (sequence === refreshSequence.current) setNotice({ tone: 'error', text: cause instanceof Error ? cause.message : String(cause) })
443
+ }
316
444
  }, [kb, page, rememberTasks])
317
- const activeCount = history?.tasks.filter(task => PIPELINE_ACTIVE_STATES.includes(task.state)).length ?? 0
445
+ const activeCount = activeTasks.length
318
446
  useEffect(() => {
319
447
  void refresh()
320
448
  const focus = (): void => { void refresh() }
321
449
  window.addEventListener('focus', focus)
322
- const timer = activeCount ? window.setInterval(focus, 2500) : undefined
323
- return () => { if (timer) window.clearInterval(timer); window.removeEventListener('focus', focus) }
450
+ const timer = window.setInterval(focus, activeCount ? 2500 : 5000)
451
+ return () => { window.clearInterval(timer); window.removeEventListener('focus', focus) }
324
452
  }, [refresh, activeCount])
325
- const start = async (stage: PipelineStage, force = false): Promise<void> => {
326
- setBusy(true); setFailure('')
453
+
454
+ const start = async (next: PipelineStage, force = false): Promise<void> => {
455
+ setBusy(true)
456
+ setNotice(null)
327
457
  try {
328
- const sourceScope = stage === 'all' || stage === 'extract' || stage === 'normalize' || stage === 'derive'
329
- const groupScope = stage === 'all' || stage === 'materialize'
330
- 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 }) })
331
- if (result.data?.task) { setSelectedTask(result.data.task.id); setPage(1) }
458
+ const sourceScope = next === 'all' || next === 'extract' || next === 'normalize' || next === 'derive'
459
+ const groupScope = next === 'all' || next === 'materialize'
460
+ const result = await requestJson<{ task: PipelineTask }>('/knowledge/pipeline', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ kb, stage: next, ...(sourceScope && sourceIds.length ? { sourceIds } : {}), ...(groupScope && groupKeys.length ? { groupKeys } : {}), force }) })
461
+ if (result.data?.task) {
462
+ rememberTasks([result.data.task]); setSelectedTask(result.data.task.id); setPinnedTask(true); setFollowRun(true)
463
+ setSideTab('activity'); setPage(1); setStage(next === 'all' ? 'extract' : next)
464
+ }
332
465
  await refresh()
333
- } catch (cause) { setFailure(cause instanceof Error ? cause.message : String(cause)) }
334
- finally { setBusy(false) }
466
+ } catch (cause) {
467
+ setNotice({ tone: 'error', text: cause instanceof Error ? cause.message : String(cause) })
468
+ } finally { setBusy(false) }
335
469
  }
336
470
  const act = async (id: string, action: string): Promise<void> => {
337
471
  setBusy(true)
338
472
  try {
339
473
  const result = await requestJson<{ task: PipelineTask }>('/knowledge/pipeline-task', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ kb, id, action }) })
340
- if (result.data?.task) setSelectedTask(result.data.task.id)
474
+ if (result.data?.task) { rememberTasks([result.data.task]); setSelectedTask(result.data.task.id); setPinnedTask(true); setFollowRun(true); setSideTab('activity') }
341
475
  await refresh()
342
- } catch (cause) { setFailure(cause instanceof Error ? cause.message : String(cause)) }
343
- finally { setBusy(false) }
476
+ } catch (cause) {
477
+ setNotice({ tone: 'error', text: cause instanceof Error ? cause.message : String(cause) })
478
+ } finally { setBusy(false) }
344
479
  }
345
480
  const replaceTask = useCallback((task: PipelineTask) => {
346
481
  setHistory(current => current ? { ...current, tasks: current.tasks.map(row => row.id === task.id ? task : row) } : current)
482
+ setActiveTasks(current => PIPELINE_ACTIVE_STATES.includes(task.state)
483
+ ? [...current.filter(row => row.id !== task.id), task]
484
+ : current.filter(row => row.id !== task.id))
347
485
  rememberTasks([task])
348
486
  }, [rememberTasks])
349
- if (!kb) return <p className="extractTasksError" role="alert">缺少知识库,请从知识库工具栏打开知识处理。</p>
350
- /**
351
- * 分页器分的是**任务**,所以页数只能用任务自己的总数。
352
- *
353
- * 原来取的是 `max(任务数, 抽取报告数)`:抽取报告是**另一个集合**(一次任务可能产出
354
- * 好几份,历史累积到几百份),把它算进分母会让分页器凭空多出十几页,而点「下一页」
355
- * 落到的是一片空白 —— 那个空页看起来像「记录丢了」。抽取报告的数量已经有自己的落点
356
- * (「抽取报告」那一行右侧的「N 份」),不在这里重复。
357
- */
487
+
488
+ const docIndex = useMemo(() => new Map(documents.map(doc => [doc.docId, doc])), [documents])
489
+ const trailCounts = useMemo(() => {
490
+ const counts = new Map<string, number>()
491
+ for (const run of trajectories) counts.set(run.docId, (counts.get(run.docId) ?? 0) + 1)
492
+ return counts
493
+ }, [trajectories])
494
+ /** 主栏的行:文档状态来自 pipeline,格式 / 体积来自文档列表,规模描述来自最近一条轨迹。 */
495
+ const documentRows = useMemo<DocumentRow[]>(() => (status?.sources ?? []).map(source => {
496
+ const meta = docIndex.get(source.id)
497
+ return {
498
+ id: source.id,
499
+ docId: source.id,
500
+ title: source.title,
501
+ version: source.version,
502
+ state: source.state,
503
+ sourceFormat: meta?.sourceFormat ?? '',
504
+ ext: meta?.ext ?? '',
505
+ bytes: meta?.bytes ?? 0,
506
+ updatedAt: meta?.updatedAt ?? '',
507
+ versionCount: meta?.versionCount ?? source.version,
508
+ runs: trajectories.filter(run => run.docId === source.id),
509
+ trailCount: runCounts[source.id] ?? trailCounts.get(source.id) ?? 0,
510
+ }
511
+ }), [status, trajectories, docIndex, trailCounts, runCounts])
512
+
358
513
  const total = history?.total ?? 0
359
- const pageCount = Math.max(1, Math.ceil(total / PAGE_SIZE))
360
514
  const orderedTasks = [...(history?.tasks ?? [])].sort((left, right) => {
361
515
  const activeOrder = Number(PIPELINE_ACTIVE_STATES.includes(right.state)) - Number(PIPELINE_ACTIVE_STATES.includes(left.state))
362
516
  return activeOrder || right.queuedAt.localeCompare(left.queuedAt)
363
517
  })
364
- const stages = [
365
- { id: 'extract', detail: '从 Source Raw 抽取 Fact、Procedure 与 Case。' },
366
- { id: 'normalize', detail: '合并实体,并整理知识坐标。' },
367
- { id: 'derive', detail: '从 Case 的检查与动作提炼可复用 Procedure,保留证据与版本。' },
368
- { id: 'materialize', detail: '按完整坐标生成 WikiCard。' },
369
- { id: 'build', detail: '构建并发布检索索引。' },
370
- ] as const
371
- const inputRows = (stage: StageId, task?: PipelineTask): Array<[string, string]> => {
372
- const selection = task?.selection ?? {}
373
- const sourceSelection = task ? selection.sourceIds : sourceIds.length ? sourceIds : undefined
374
- const groupSelection = task ? selection.groupKeys : groupKeys.length ? groupKeys : undefined
375
- const names = (ids: string[], resolve: (id: string) => string): string => {
376
- const values = ids.map(resolve)
377
- return `${ids.length} 项${values.length <= 3 ? `:${values.join('、')}` : `:${values.slice(0, 3).join('、')} 等`}`
378
- }
379
- const sourceRange = sourceSelection
380
- ? names(sourceSelection, id => status?.sources.find(source => source.id === id)?.title ?? id)
381
- : `全部来源(${status?.sources.length ?? '当前'} 份)`
382
- const rows: Array<[string, string]> = []
383
- if (stage === 'extract' || stage === 'normalize' || stage === 'derive') {
384
- rows.push(['来源范围', sourceRange])
385
- rows.push(['强制处理', selection.force ? '是,忽略可复用结果' : '否,复用未变化结果'])
386
- } else if (stage === 'materialize') {
387
- const groupRange = groupSelection
388
- ? names(groupSelection, id => status?.groups.find(group => group.key === id)?.title ?? id)
389
- : task?.stage === 'all' && selection.sourceIds
390
- ? `全部待办分组中关联所选来源的分组(${sourceSelection?.length ?? 0} 份来源)`
391
- : '全部待处理分组'
392
- rows.push(['分组范围', groupRange])
393
- if (task?.stage === 'all' && selection.sourceIds) rows.push(['来源限制', sourceRange])
394
- rows.push(['强制处理', selection.force ? '是,重新物化所选分组' : '否,只处理有变化的分组'])
395
- } else {
396
- rows.push(['索引范围', '整个知识库;会按当前知识内容构建'])
397
- rows.push(['选择范围', '来源与分组选择不缩小索引范围'])
398
- }
399
- if (stage !== 'build') {
400
- const model = task?.model
401
- rows.push(['模型配置', model?.model ? `${model.provider ? `${model.provider} / ` : ''}${model.model}${model.reasoningEffort ? ` · ${model.reasoningEffort}` : ''}` : '运行时读取当前模型设置'])
402
- }
403
- return rows
404
- }
405
- /**
406
- * 结果摘要**不再重复「状态」**:那枚徽标在步骤轨上,是这一段的身份,不是结果的第二行。
407
- * 这里只放「跑了多少、什么时候跑的」—— 一个问题一个答案。
408
- */
409
- const outputRows = (stage: StageId, observation: StageObservation): Array<[string, string]> => {
410
- const task = observation.task
411
- const result = task?.results?.[stage]
412
- const activeStage = task && PIPELINE_ACTIVE_STATES.includes(task.state) && task.currentStage === stage
413
- const liveResult = activeStage ? task : task?.stage === stage && !result ? task : undefined
414
- const counts = result ?? liveResult
415
- const summary = result?.summary ?? (activeStage ? task?.summary : observation.detail) ?? observation.detail
416
- return [
417
- ['摘要', summary],
418
- ...(counts ? [['已处理', `${counts.done}/${counts.total} 项`] as [string, string]] : []),
419
- ...(counts?.reused ? [['复用', `${counts.reused} 项`] as [string, string]] : []),
420
- ...(counts?.failed ? [['失败', `${counts.failed} 项`] as [string, string]] : []),
421
- ...(task ? [['最近运行', formatDateTime(task.finishedAt ?? task.startedAt ?? task.queuedAt)] as [string, string]] : []),
422
- ]
423
- }
424
- const runningTask = orderedTasks.find(task => PIPELINE_ACTIVE_STATES.includes(task.state))
518
+ const runningTask = orderActiveTasks(activeTasks)[0]
519
+ const focusedTask = pinnedTask ? stageTasks.find(task => task.id === selectedTask) : runningTask ?? stageTasks.find(task => task.id === selectedTask) ?? orderedTasks[0]
520
+ const feed = useTaskFeed(kb, pinnedTask ? selectedTask || undefined : focusedTask?.id, replaceTask)
521
+ const currentSourceId = focusedTask?.activity?.objectId ?? feed.events.filter(event => event.sourceId).at(-1)?.sourceId
522
+ const currentObjectTitle = documentRows.find(row => row.id === currentSourceId)?.title
523
+ useEffect(() => { if (!pinnedTask && focusedTask) setSelectedTask(focusedTask.id) }, [pinnedTask, focusedTask?.id])
524
+ useEffect(() => {
525
+ if (followRun && focusedTask?.currentStage) setStage(focusedTask.currentStage)
526
+ }, [followRun, focusedTask?.id, focusedTask?.currentStage])
527
+ useEffect(() => {
528
+ if (!selectedDoc) return
529
+ const target = [...document.querySelectorAll<HTMLElement>('[data-object-id]')].find(element => element.dataset.objectId === selectedDoc)
530
+ target?.scrollIntoView({ block: 'nearest' })
531
+ }, [selectedDoc, stage])
425
532
  const headline = runningTask
426
- ? `正在运行「${PIPELINE_STAGE_LABEL[runningTask.stage]}」${runningTask.total > 0 ? ` · ${runningTask.done}/${runningTask.total}` : ''}${runningTask.failed ? ` · 失败 ${runningTask.failed}` : ''}。暂停与停止在当前文档处理完后生效。`
533
+ ? `当前任务${STATES[runningTask.state]};本次进度与控制在下方。`
427
534
  : history
428
- ? '当前没有运行中的任务。选好处理范围后按阶段运行;未变化的结果会自动复用。'
535
+ ? '当前没有运行中的任务。选好范围后按阶段运行;未变化的结果会自动复用。'
429
536
  : '正在读取这个库的处理状态…'
430
- return <div className="extractTasksPage">
431
- <div className="extractTasksHead">
432
- <p className="extractTasksHeadline" role="status">{headline}</p>
433
- <div className="extractTasksHeadActions">
434
- <Button variant="ghost" size="sm" onClick={() => { void refresh() }}><RefreshCw size={13} />刷新</Button>
435
- <Button size="sm" title="按顺序运行全部四个阶段:抽取 → 归一 → 物化 → 构建索引" disabled={busy || !status?.sources.length} onClick={() => { void start('all') }}><Play size={13} />全链路运行</Button>
537
+ const stageObservationOf = (id: StageId): StageObservation => stageObservation(id, focusedTask, status)
538
+ const selectedDocTitle = documentRows.find(row => row.id === selectedDoc)?.title ?? ''
539
+ const chooseTask = (id: string): void => { setSelectedTask(id); setPinnedTask(true); setFollowRun(true); setSideTab('activity') }
540
+ const followCurrent = (): void => { setFollowRun(true); if (focusedTask?.currentStage) setStage(focusedTask.currentStage) }
541
+ if (!kb) return <p className="ktError" role="alert"><AlertCircle size={13} />缺少知识库,请从知识库工具栏打开知识处理。</p>
542
+
543
+ const openKernel = (): void => {
544
+ void openAppPath('/convert/').then(opened => {
545
+ if (!opened) setNotice({ tone: 'error', text: '打不开解析内核控制台:当前外壳下拿不到它的地址。' })
546
+ })
547
+ }
548
+
549
+ const renderStageBody = (id: StageId): ReactNode => {
550
+ const observation = stageObservationOf(id)
551
+ const result = observation.task?.results?.[id]
552
+ const scopeText = id === 'materialize'
553
+ ? groupKeys.length ? `已选 ${groupKeys.length} 个分组` : `全部待处理分组(${status?.groups.length ?? 0} 个)`
554
+ : sourceIds.length ? `已选 ${sourceIds.length} 份文档` : `全部来源(${status?.sources.length ?? 0} 份)`
555
+ const model = observation.task?.model
556
+ const modelText = model?.model
557
+ ? `${model.provider ? `${model.provider} / ` : ''}${model.model}${model.reasoningEffort ? ` · ${model.reasoningEffort}` : ''}`
558
+ : '运行时读取编译设置'
559
+ const runLabel = activeCount ? '加入队列' : id === 'materialize' && groupKeys.length > 0
560
+ ? `物化选中 ${groupKeys.length} 个分组`
561
+ : sourceIds.length > 0 && id !== 'materialize' && id !== 'build'
562
+ ? `运行选中 ${sourceIds.length} 份`
563
+ : '运行此阶段'
564
+ return <div className="ktStage">
565
+ <div className="ktStageHead">
566
+ <div className="ktStageIntro">
567
+ <h2>{PIPELINE_STAGE_LABEL[id]}</h2>
568
+ <p>{STAGE_DETAIL[id]}</p>
569
+ </div>
570
+ <div className="ktStageActions">
571
+ {sourceIds.length > 0 && <Button size="sm" variant="ghost" disabled={busy} onClick={() => setSourceIds([])}>清除选择</Button>}
572
+ {groupKeys.length > 0 && <Button size="sm" variant="ghost" disabled={busy} onClick={() => setGroupKeys([])}>清除选择</Button>}
573
+ {(id === 'extract' || id === 'normalize') && sourceIds.length > 0 && <Button size="sm" variant="ghost" disabled={busy} onClick={() => { void start(id, true) }}>强制重跑</Button>}
574
+ <Button size="sm" variant="outline" disabled={busy || !status || ((id === 'extract' || id === 'normalize') && !status.sources.length)} onClick={() => { void start(id) }}>{runLabel}</Button>
575
+ </div>
436
576
  </div>
577
+ <p className="ktStageMeta">
578
+ <span>下次运行范围:{scopeText}</span>
579
+ <i aria-hidden="true">·</i><span>模型:{modelText}</span>
580
+ {result && <><i aria-hidden="true">·</i><span>已处理 {result.done}/{result.total}{result.reused ? ` · 复用 ${result.reused}` : ''}{result.failed ? ` · 失败 ${result.failed}` : ''}</span></>}
581
+ {observation.task && <><i aria-hidden="true">·</i><span>查看任务 {formatDateTime(observation.task.finishedAt ?? observation.task.startedAt ?? observation.task.queuedAt)}</span></>}
582
+ </p>
583
+
584
+ {id === 'extract' || id === 'normalize'
585
+ ? <DocumentList
586
+ rows={documentRows}
587
+ selected={selectedDoc}
588
+ onSelect={next => { setSelectedDoc(current => current === next ? '' : next); setSideTab('trail') }}
589
+ selectedIds={sourceIds}
590
+ onToggle={next => setSourceIds(current => toggle(current, next))}
591
+ stage={id}
592
+ task={focusedTask}
593
+ currentSourceId={currentSourceId}
594
+ />
595
+ : null}
596
+ {id === 'derive' && <div className="ktDerive">
597
+ <div>
598
+ <h3>从 Case 提炼可复用的方法</h3>
599
+ <p>这一段的对象是 Case,不是文档:只有带检查与动作证据的 Case 才会产出 Procedure。没有证据就不生成,这是刻意的。</p>
600
+ </div>
601
+ <dl>
602
+ <div><dt>处理对象</dt><dd>Case(带检查与动作证据的那些),不是文档。</dd></div>
603
+ <div><dt>{focusedTask ? '本次结论' : '库内状态'}</dt><dd>{observation.label} · {observation.detail}</dd></div>
604
+ </dl>
605
+ </div>}
606
+ {id === 'materialize' && <GroupList groups={status?.groups ?? []} selectedKeys={groupKeys} onToggle={key => setGroupKeys(current => toggle(current, key))} task={focusedTask} inspectingKey={selectedDoc} />}
607
+ {id === 'build' && <div className="ktIndex">
608
+ <div className="ktIndexState">
609
+ {status?.indexPending ? <Badge tone="warning">索引待更新</Badge> : <Badge tone="success">索引已最新</Badge>}
610
+ <p>{observation.detail}</p>
611
+ </div>
612
+ <dl>
613
+ <div><dt>索引范围</dt><dd>整个知识库。构建会读已物化卡片,不重新抽取文档。</dd></div>
614
+ <div><dt>换向量模型</dt><dd>只需重新构建索引,文档不用再抽一遍。</dd></div>
615
+ </dl>
616
+ </div>}
617
+
618
+ {focusedTask && <StageOutput key={`${focusedTask.id}:${id}`}><RawTaskOutput kb={kb} task={focusedTask} events={feed.events} stage={id} onOpenFailed={message => setNotice({ tone: 'error', text: message })} /></StageOutput>}
437
619
  </div>
438
- {failure && <p className="extractTasksError" role="alert">{failure}</p>}
620
+ }
439
621
 
440
- <Tabs defaultValue="extract" className="extractTasksWork">
441
- {/* 步骤轨横贯整页:它是这一页的骨架(有方向的流水线),所以不能被左边那一栏框住。
442
- 每格两行 —— 名字、以及「最近结论 + 还剩多少」;两件事不同色,因为它们是两个问题。 */}
443
- <TabsList aria-label="知识处理阶段" className="extractTasksRailList">
444
- {stages.map((stage, index) => {
445
- const observation = stageObservation(stage.id, stageTasks, status)
446
- return <Fragment key={stage.id}>
447
- {index > 0 && <span className="extractTasksRailArrow" aria-hidden="true"><ArrowRight size={16} /></span>}
448
- <TabsTrigger value={stage.id} className="extractTasksRailStep" title={observation.detail}>
449
- <span className="extractTasksRailName">{PIPELINE_STAGE_LABEL[stage.id]}</span>
450
- <span className="extractTasksRailFacts">
451
- <Badge tone={observation.tone}>{observation.label}</Badge>
452
- <span className="extractTasksRailPending">{stagePending(stage.id, status)}</span>
453
- </span>
454
- </TabsTrigger>
455
- </Fragment>
456
- })}
457
- </TabsList>
622
+ return <div className="ktPage">
623
+ <div className="ktTop">
624
+ <div className="ktTopLead">
625
+ <div className="ktViews" role="group" aria-label="知识处理视图">
626
+ <Button variant="ghost" aria-pressed={view === 'run'} className="ktView" onClick={() => setView('run')}>处理</Button>
627
+ <Button variant="ghost" aria-pressed={view === 'settings'} className="ktView" onClick={() => setView('settings')}>编译设置</Button>
628
+ </div>
629
+ <p className="ktHeadline" role="status">{view === 'run' ? headline : '编译模型、提取备注与坐标实体决定这个库怎么把文档变成知识。'}</p>
630
+ </div>
631
+ <div className="ktTopActions">
632
+ <Button variant="ghost" size="sm" onClick={() => { void refresh(); setSettingsKey(current => current + 1) }}><RefreshCw size={13} />刷新</Button>
633
+ <Button variant="outline" size="sm" title="打开解析内核控制台:文件 / 链接 → 知识就绪 Markdown" onClick={openKernel}><FileUp size={13} />解析内核</Button>
634
+ {view === 'run' && <Button size="sm" title="按顺序运行全部阶段:抽取 → 归一 → 提炼 → 物化 → 构建索引" disabled={busy || !status?.sources.length} onClick={() => { void start('all') }}><Play size={13} />{activeCount ? '全链路加入队列' : '全链路运行'}</Button>}
635
+ </div>
636
+ </div>
458
637
 
459
- <div className="extractTasksBody">
460
- <div className="extractTasksMain">
461
- {stages.map(stage => {
462
- const observation = stageObservation(stage.id, stageTasks, status)
463
- const live = Boolean(observation.task && PIPELINE_ACTIVE_STATES.includes(observation.task.state) && observation.task.currentStage === stage.id)
464
- return <TabsContent key={stage.id} value={stage.id} className="extractTasksStage">
465
- <div className="extractTasksStageIntro">
466
- <div>
467
- <h2>{PIPELINE_STAGE_LABEL[stage.id]}</h2>
468
- <p>{stage.detail}</p>
469
- </div>
470
- <Button size="sm" variant="outline" disabled={busy || !status || ((stage.id === 'extract' || stage.id === 'normalize') && !status.sources.length)} onClick={() => { void start(stage.id) }}>运行此阶段</Button>
471
- </div>
472
- <div className="extractTasksFacts">
473
- <section aria-labelledby={`stage-input-${stage.id}`}>
474
- <h3 id={`stage-input-${stage.id}`}>输入范围</h3>
475
- <dl>{inputRows(stage.id, observation.task).map(([label, value]) => <div key={label}><dt>{label}</dt><dd>{value}</dd></div>)}</dl>
476
- </section>
477
- <section aria-labelledby={`stage-output-${stage.id}`}>
478
- <h3 id={`stage-output-${stage.id}`}>结果摘要</h3>
479
- <dl>{outputRows(stage.id, observation).map(([label, value]) => <div key={label}><dt>{label}</dt><dd>{value}</dd></div>)}</dl>
480
- </section>
481
- </div>
482
- {/* 构建索引那一段没有轨迹:它的产出是检索产物(可重建),不是「一次判断」。
483
- 所以这里只在前三段渲染;`build` 留空而不是硬塞一份假记录。 */}
484
- {stage.id !== 'build' && <StageTrajectories kb={kb} stage={stage.id as TrajectoryStage} runs={trajectories} onOpenFailed={setFailure} />}
485
- {observation.task && <StageOutput active={live}><TaskOutput kb={kb} id={observation.task.id} onTask={replaceTask} onOpenFailed={setFailure} stageFilter={stage.id} taskStage={observation.task.stage} /></StageOutput>}
486
- </TabsContent>
638
+ {notice && <p className={notice.tone === 'error' ? 'ktError' : 'ktNotice'} role={notice.tone === 'error' ? 'alert' : 'status'}>
639
+ {notice.tone === 'error' ? <AlertCircle size={13} /> : <Check size={13} />}
640
+ <span>{notice.text}</span>
641
+ <Button variant="ghost" size="icon" className="ktNoticeClose" aria-label="关闭提示" onClick={() => setNotice(null)}><X size={13} /></Button>
642
+ </p>}
643
+
644
+ {view === 'settings'
645
+ ? <CompileSettingsView key={settingsKey} kb={kb} onNotice={(tone, text) => setNotice({ tone, text })} />
646
+ : <Tabs value={stage} onValueChange={next => { setStage(next as StageId); setSelectedDoc(''); setFollowRun(false) }} className="ktWork">
647
+ {focusedTask && <RunSummary task={focusedTask} events={feed.events} failure={feed.failure} busy={busy} onAct={(id, action) => { void act(id, action) }} onFollow={followCurrent} onRecovery={chooseTask} following={followRun && stage === focusedTask.currentStage} queueCount={activeTasks.filter(task => task.state === 'queued' && task.id !== focusedTask.id).length} queuePosition={orderActiveTasks(activeTasks).filter(task => task.state === 'queued').findIndex(task => task.id === focusedTask.id) + 1} objectTitle={currentObjectTitle} />}
648
+ {runningTask && focusedTask?.id !== runningTask.id && <p className="ktNotice"><span>正在查看历史或排队任务;另有任务{STATES[runningTask.state]}。</span><Button onClick={() => { setPinnedTask(false); setFollowRun(true); setSideTab('activity') }}>查看当前运行</Button></p>}
649
+ {pinnedTask && selectedTask && !focusedTask && <p className="ktHint">{feed.failure ? `指定任务读取失败:${feed.failure}` : '正在读取指定任务…'}{feed.failure && <Button variant="ghost" onClick={() => setPinnedTask(false)}>查看最近任务</Button>}</p>}
650
+ <div className="ktRailHeading"><strong>{focusedTask ? '本次任务阶段' : '知识库待处理状态'}</strong><span>点击阶段查看详情;带「进行中」标记的阶段正在执行。</span></div>
651
+ <TabsList aria-label="知识处理阶段" className="ktRail">
652
+ {STAGE_ORDER.map((id, index) => {
653
+ const observation = stageObservationOf(id)
654
+ return <Fragment key={id}>
655
+ {index > 0 && <span className="ktRailArrow" aria-hidden="true"><ArrowRight size={15} /></span>}
656
+ <TabsTrigger value={id} className="ktRailStep" data-running={focusedTask?.currentStage === id && PIPELINE_ACTIVE_STATES.includes(focusedTask.state) || undefined} title={observation.detail}>
657
+ <span className="ktRailTop"><span className="ktRailIndex" aria-hidden="true">{index + 1}</span><span className="ktRailName">{PIPELINE_STAGE_LABEL[id]}</span></span>
658
+ <span className="ktRailFacts">
659
+ <Badge tone={observation.tone}>{observation.label}</Badge>
660
+ <span className="ktRailPending">{focusedTask ? focusedTask.results?.[id] ? `${focusedTask.results[id]!.done}/${focusedTask.results[id]!.total} 已处理` : focusedTask.currentStage === id && focusedTask.total ? `${focusedTask.done}/${focusedTask.total} 已处理` : '' : stagePending(id, status)}</span>
661
+ </span>
662
+ </TabsTrigger>
663
+ </Fragment>
487
664
  })}
488
- </div>
665
+ </TabsList>
489
666
 
490
- <aside className="extractTasksSide">
491
- <section className="extractTasksPanel" aria-labelledby="scope-title">
492
- <div className="extractTasksPanelHead">
493
- <div>
494
- <h2 id="scope-title">处理范围</h2>
495
- <p>不选就是全部;点一行选中它。</p>
496
- </div>
497
- </div>
498
- <div className="extractTasksPanelBody">
499
- <Tabs defaultValue="sources">
500
- <TabsList><TabsTrigger value="sources">来源</TabsTrigger><TabsTrigger value="groups">卡片分组</TabsTrigger></TabsList>
501
- <TabsContent value="sources" className="extractTasksScopeTab">
502
- <p className="extractTasksScopeNote">{sourceIds.length ? `已选 ${sourceIds.length} 份文档。` : '未选择时处理全部来源,未变化的结果自动复用。'}</p>
503
- {!status && <p className="extractTasksEmpty">正在读取来源…</p>}
504
- {status?.sources.length === 0 && <p className="extractTasksEmpty">当前没有来源。添加来源后即可开始处理。</p>}
505
- <ul className="extractTasksChoiceList" aria-label="来源选择">{status?.sources.map(source => {
506
- const on = sourceIds.includes(source.id)
507
- return <li key={source.id}>
508
- <button type="button" className="extractTasksPick" aria-pressed={on} onClick={() => setSourceIds(current => toggle(current, source.id))}>
509
- <span className="extractTasksPickBox" aria-hidden="true">{on && <Check size={11} />}</span>
510
- <span className="extractTasksChoiceName">{source.title}<span>v{source.version}</span></span>
511
- <Badge tone="neutral">{SOURCE_STATES[source.state]}</Badge>
512
- </button>
513
- </li>
514
- })}</ul>
515
- {sourceIds.length > 0 && <div className="extractTasksScopeActions">
516
- <Button size="sm" variant="outline" disabled={busy} onClick={() => { void start('extract', true) }}>强制重抽 {sourceIds.length} 份</Button>
517
- <Button size="sm" variant="ghost" onClick={() => setSourceIds([])}>清除选择</Button>
518
- </div>}
519
- </TabsContent>
520
- <TabsContent value="groups" className="extractTasksScopeTab">
521
- <p className="extractTasksScopeNote">{groupKeys.length ? `已选 ${groupKeys.length} 个完整卡片分组。` : '未选择时物化全部待办;同坐标的知识与事件一起处理。'}</p>
522
- {!status && <p className="extractTasksEmpty">正在读取分组…</p>}
523
- {status?.groups.length === 0 && <p className="extractTasksEmpty">还没有可物化分组。先抽取并归一文档内容。</p>}
524
- <ul className="extractTasksChoiceList extractTasksGroupList" aria-label="卡片分组选择">{status?.groups.map(group => {
525
- const on = groupKeys.includes(group.key)
526
- return <li key={group.key}>
527
- <button type="button" className="extractTasksPick" aria-pressed={on} onClick={() => setGroupKeys(current => toggle(current, group.key))}>
528
- <span className="extractTasksPickBox" aria-hidden="true">{on && <Check size={11} />}</span>
529
- <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>
530
- <Badge tone={group.state === 'current' ? 'success' : 'neutral'}>{GROUP_STATES[group.state]}</Badge>
531
- </button>
532
- </li>
533
- })}</ul>
534
- {groupKeys.length > 0 && <div className="extractTasksScopeActions">
535
- <Button size="sm" variant="outline" disabled={busy} onClick={() => { void start('materialize') }}>物化选中的 {groupKeys.length} 个分组</Button>
536
- <Button size="sm" variant="ghost" onClick={() => setGroupKeys([])}>清除选择</Button>
537
- </div>}
538
- </TabsContent>
539
- </Tabs>
540
- </div>
541
- </section>
667
+ <div className="ktBody">
668
+ <div className="ktMain">
669
+ {STAGE_ORDER.map(id => <TabsContent key={id} value={id} className="ktStageTab">{renderStageBody(id)}</TabsContent>)}
670
+ </div>
542
671
 
543
- {/* `id="history"` 保留:旧链接(`…#history`)还能落到这一块上。 */}
544
- <section id="history" className="extractTasksPanel" aria-labelledby="pipeline-history">
545
- <div className="extractTasksPanelHead">
546
- <div>
547
- <h2 id="pipeline-history">运行记录</h2>
548
- <p>{activeCount ? '暂停与停止会在当前文档或卡片处理完成后生效。' : '运行状态会自动更新。'}</p>
549
- </div>
550
- <Badge tone={activeCount ? 'blue' : 'neutral'}>{activeCount ? `进行中 ${activeCount}` : '无运行任务'}</Badge>
551
- </div>
552
- <div className="extractTasksPanelBody">
553
- {!history && <p className="extractTasksEmpty">正在读取任务记录…</p>}
554
- {history?.tasks.length === 0 && <p className="extractTasksEmpty">还没有阶段任务。抽取报告保留在抽取阶段。</p>}
555
- {orderedTasks.map(task => <article key={task.id} className="extractTaskRow">
556
- <div className="extractTaskTitle">
557
- <strong>{PIPELINE_STAGE_LABEL[task.stage]}</strong>
558
- <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>
559
- </div>
560
- <p className="extractTaskSummary">{task.summary}</p>
561
- <p className="extractTaskTime">{formatDateTime(task.queuedAt)}{task.model.model ? ` · ${task.model.provider ?? ''} / ${task.model.model}` : ''}{task.model.reasoningEffort ? ` · ${task.model.reasoningEffort}` : ''}</p>
562
- <div className="extractTaskControls">
563
- <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>
564
- {PIPELINE_ACTIVE_STATES.includes(task.state) ? <>
565
- <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>
566
- <Button size="sm" variant="ghost" disabled={busy || task.state === 'cancelling'} onClick={() => { void act(task.id, 'cancel') }}><X size={12} />停止</Button>
567
- </> : <Button size="sm" variant="outline" disabled={busy} onClick={() => { void act(task.id, 'retry') }}>重新运行</Button>}
568
- </div>
569
- {selectedTask === task.id && <div className="extractTaskSteps"><TaskOutput kb={kb} id={task.id} onTask={replaceTask} onOpenFailed={setFailure} /></div>}
570
- </article>)}
571
- <ListPager page={page} pageCount={pageCount} total={total} onChange={setPage} />
572
- </div>
573
- </section>
574
- </aside>
575
- </div>
576
- </Tabs>
672
+ <aside className="ktSide" aria-label="这一段的轨迹与运行记录">
673
+ <Tabs value={sideTab} onValueChange={setSideTab} className="ktSideTabs">
674
+ <TabsList className="ktSideTabsList">
675
+ <TabsTrigger value="activity">本次活动</TabsTrigger>
676
+ <TabsTrigger value="trail"><History size={12} />历史轨迹</TabsTrigger>
677
+ <TabsTrigger value="history">历史任务{total ? <span className="ktSideBadge">{total}</span> : null}</TabsTrigger>
678
+ </TabsList>
679
+ <TabsContent value="activity" className="ktSidePanel">
680
+ {focusedTask ? <RunActivity key={focusedTask.id} task={focusedTask} events={feed.events} onInspect={(id, nextStage) => { setSelectedDoc(id); if (nextStage) setStage(nextStage); setFollowRun(false) }} /> : <p className="ktSideEmptyText">运行任务后,在这里查看保存、重试和完成记录。</p>}
681
+ </TabsContent>
682
+ <TabsContent value="trail" className="ktSidePanel">
683
+ <TrajectoryPanel
684
+ kb={kb}
685
+ stage={stage}
686
+ selectedDoc={selectedDoc}
687
+ docTitle={selectedDocTitle}
688
+ runs={trajectories}
689
+ onClearDoc={() => setSelectedDoc('')}
690
+ onOpenFailed={message => setNotice({ tone: 'error', text: message })}
691
+ />
692
+ </TabsContent>
693
+ <TabsContent value="history" className="ktSidePanel">
694
+ <RunHistoryPanel
695
+ tasks={history?.tasks ?? []}
696
+ orderedTasks={orderedTasks}
697
+ total={total}
698
+ page={page}
699
+ onPage={setPage}
700
+ activeCount={activeCount}
701
+ selectedTask={focusedTask?.id ?? ''}
702
+ onToggleTask={chooseTask}
703
+ busy={busy}
704
+ onAct={(id, action) => { void act(id, action) }}
705
+ />
706
+ </TabsContent>
707
+ </Tabs>
708
+ </aside>
709
+ </div>
710
+ </Tabs>}
577
711
  </div>
578
712
  }