dsh-data-domain-knowledge 0.1.0-alpha.2 → 0.1.0-alpha.3

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 (117) hide show
  1. package/README.md +31 -7
  2. package/cordis.patch.yml +11 -5
  3. package/lib/client.js +5557 -3056
  4. package/lib/doc-page.js +7740 -0
  5. package/lib/types/convert/host.d.ts.map +1 -1
  6. package/lib/types/convert/host.js +36 -9
  7. package/lib/types/convert/host.js.map +1 -1
  8. package/lib/types/diagnosis/cards.d.ts +8 -7
  9. package/lib/types/diagnosis/cards.d.ts.map +1 -1
  10. package/lib/types/diagnosis/cards.js +119 -49
  11. package/lib/types/diagnosis/cards.js.map +1 -1
  12. package/lib/types/diagnosis/run.d.ts +51 -0
  13. package/lib/types/diagnosis/run.d.ts.map +1 -0
  14. package/lib/types/diagnosis/run.js +239 -0
  15. package/lib/types/diagnosis/run.js.map +1 -0
  16. package/lib/types/diagnosis/schema.d.ts +25 -2
  17. package/lib/types/diagnosis/schema.d.ts.map +1 -1
  18. package/lib/types/diagnosis/schema.js +64 -25
  19. package/lib/types/diagnosis/schema.js.map +1 -1
  20. package/lib/types/diagnosis/seed-source.d.ts +17 -2
  21. package/lib/types/diagnosis/seed-source.d.ts.map +1 -1
  22. package/lib/types/diagnosis/seed-source.js +18 -1
  23. package/lib/types/diagnosis/seed-source.js.map +1 -1
  24. package/lib/types/diagnosis/seed.d.ts +32 -42
  25. package/lib/types/diagnosis/seed.d.ts.map +1 -1
  26. package/lib/types/diagnosis/seed.js +187 -51
  27. package/lib/types/diagnosis/seed.js.map +1 -1
  28. package/lib/types/diagnosis/store.d.ts +114 -26
  29. package/lib/types/diagnosis/store.d.ts.map +1 -1
  30. package/lib/types/diagnosis/store.js +336 -167
  31. package/lib/types/diagnosis/store.js.map +1 -1
  32. package/lib/types/diagnosis/types.d.ts +168 -16
  33. package/lib/types/diagnosis/types.d.ts.map +1 -1
  34. package/lib/types/diagnosis/types.js +1 -1
  35. package/lib/types/diagnosis/types.js.map +1 -1
  36. package/lib/types/document-page.d.ts +39 -0
  37. package/lib/types/document-page.d.ts.map +1 -0
  38. package/lib/types/document-page.js +43 -0
  39. package/lib/types/document-page.js.map +1 -0
  40. package/lib/types/fs-atomic.d.ts +12 -0
  41. package/lib/types/fs-atomic.d.ts.map +1 -0
  42. package/lib/types/fs-atomic.js +42 -0
  43. package/lib/types/fs-atomic.js.map +1 -0
  44. package/lib/types/index.d.ts.map +1 -1
  45. package/lib/types/index.js +214 -24
  46. package/lib/types/index.js.map +1 -1
  47. package/lib/types/knowledge-bases.d.ts +10 -2
  48. package/lib/types/knowledge-bases.d.ts.map +1 -1
  49. package/lib/types/knowledge-bases.js +20 -18
  50. package/lib/types/knowledge-bases.js.map +1 -1
  51. package/lib/types/proposals.d.ts +166 -0
  52. package/lib/types/proposals.d.ts.map +1 -0
  53. package/lib/types/proposals.js +238 -0
  54. package/lib/types/proposals.js.map +1 -0
  55. package/lib/types/runtime.d.ts +199 -7
  56. package/lib/types/runtime.d.ts.map +1 -1
  57. package/lib/types/runtime.js +728 -145
  58. package/lib/types/runtime.js.map +1 -1
  59. package/lib/types/settings-schema.d.ts +2 -2
  60. package/lib/types/types.d.ts +61 -10
  61. package/lib/types/types.d.ts.map +1 -1
  62. package/lib/types/types.js +56 -8
  63. package/lib/types/types.js.map +1 -1
  64. package/lib/types/wiki-types.d.ts +8 -1
  65. package/lib/types/wiki-types.d.ts.map +1 -1
  66. package/lib/types/wiki-types.js +16 -25
  67. package/lib/types/wiki-types.js.map +1 -1
  68. package/package.json +4 -3
  69. package/preset/tita-data-agent/agent.cordis.yml +11 -5
  70. package/src/client/DirectoryPicker.tsx +12 -12
  71. package/src/client/InfoHint.tsx +21 -8
  72. package/src/client/IntentTab.tsx +20 -26
  73. package/src/client/KnowledgeBaseTab.tsx +190 -80
  74. package/src/client/KnowledgePanel.tsx +42 -9
  75. package/src/client/KnowledgeStepRail.tsx +3 -0
  76. package/src/client/NodeSettings.tsx +20 -22
  77. package/src/client/PreviewSections.tsx +87 -33
  78. package/src/client/ProposalReview.tsx +313 -0
  79. package/src/client/TypeDefinitionDrawer.tsx +72 -38
  80. package/src/client/WikiTypeEditor.tsx +193 -77
  81. package/src/client/arch/AgentArchitectureGraph.tsx +143 -14
  82. package/src/client/arch/NodeLogicPanel.tsx +6 -3
  83. package/src/client/arch/model.ts +164 -26
  84. package/src/client/architecture.css +29 -9
  85. package/src/client/architecture.tsx +54 -29
  86. package/src/client/attribution/AttributionGraph.tsx +481 -34
  87. package/src/client/attribution/AttributionTab.tsx +507 -106
  88. package/src/client/attribution/CaseView.tsx +9 -14
  89. package/src/client/attribution/GraphStage.tsx +25 -18
  90. package/src/client/attribution/RunView.tsx +27 -9
  91. package/src/client/attribution/StageChrome.tsx +296 -140
  92. package/src/client/attribution/graph-options.ts +611 -47
  93. package/src/client/attribution/model.ts +559 -30
  94. package/src/client/convert/ConvertPanel.tsx +6 -1
  95. package/src/client/doc-page.css +134 -0
  96. package/src/client/doc-page.tsx +263 -0
  97. package/src/client/panel-state.ts +105 -0
  98. package/src/client/request.ts +36 -0
  99. package/src/client/styles.css +317 -43
  100. package/src/client/useFitScale.ts +15 -2
  101. package/src/client/wiki-type-groups.ts +86 -0
  102. package/src/convert/host.ts +41 -7
  103. package/src/diagnosis/cards.ts +120 -50
  104. package/src/diagnosis/run.ts +245 -0
  105. package/src/diagnosis/schema.ts +67 -26
  106. package/src/diagnosis/seed-source.ts +28 -3
  107. package/src/diagnosis/seed.ts +187 -85
  108. package/src/diagnosis/store.ts +358 -174
  109. package/src/diagnosis/types.ts +181 -16
  110. package/src/document-page.ts +51 -0
  111. package/src/fs-atomic.ts +42 -0
  112. package/src/index.ts +209 -22
  113. package/src/knowledge-bases.ts +20 -18
  114. package/src/proposals.ts +337 -0
  115. package/src/runtime.ts +736 -135
  116. package/src/types.ts +113 -19
  117. package/src/wiki-types.ts +17 -27
@@ -1,5 +1,5 @@
1
1
  import { useCallback, useEffect, useRef, useState, useSyncExternalStore, type ReactNode } from 'react'
2
- import { Button, Input, SelectMenu, Switch, cn } from '@tita-data-agent/ui/components'
2
+ import { Button, Field, Input, SelectMenu, Switch, cn } from '@tita-data-agent/ui/components'
3
3
  import { AlertCircle, Check, LoaderCircle, SlidersHorizontal } from '@tita-data-agent/ui/icons'
4
4
  import { DirectoryPickerField } from './DirectoryPicker.tsx'
5
5
  import { InfoHint } from './InfoHint.tsx'
@@ -203,9 +203,13 @@ function needsDebounce(spec: FieldSpec): boolean {
203
203
  }
204
204
 
205
205
  function TextField({ spec, value, onChange, disabled, resolved }: { spec: FieldSpec; value: string; onChange: (value: string) => void; disabled: boolean; resolved?: string }) {
206
- return <label className="tita-settingField">
207
- <span>{spec.label}</span>
206
+ /* 两条说明各自成行:`Field` 的说明位是一个 span,两条挤在一行会读成一句。 */
207
+ const hint = <>
208
+ {spec.hint ? <span className="block">{spec.hint}</span> : null}
208
209
  {/* 留空 = 自动。占位符显示**解析后**的真实路径 —— 否则「自动」等于没说。 */}
210
+ {resolved ? <span className="block">留空 = 自动:<code>{resolved}</code></span> : null}
211
+ </>
212
+ return <Field label={spec.label} hint={hint}>
209
213
  <Input
210
214
  type={spec.kind === 'number' ? 'number' : 'text'}
211
215
  value={value}
@@ -213,23 +217,20 @@ function TextField({ spec, value, onChange, disabled, resolved }: { spec: FieldS
213
217
  {...(resolved ? { placeholder: resolved } : {})}
214
218
  onChange={event => onChange(event.target.value)}
215
219
  />
216
- {/* 说明跟在它解释的那个字段下面,不是攒成一堆放在面板底部 ——
217
- 底部那行「字段名:说明;字段名:说明」是把标签和说明各念一遍。 */}
218
- {spec.hint && <small className="tita-settingHint">{spec.hint}</small>}
219
- {resolved && <small className="tita-settingHint">留空 = 自动:<code>{resolved}</code></small>}
220
- </label>
220
+ </Field>
221
221
  }
222
222
 
223
223
  /**
224
- * A dropdown field. The wrapper is a `<div>`, not the `<label>` the text and
225
- * toggle fields use: the control is a `<button>` now (Radix's trigger), and a
226
- * `<label>` around a button makes the visible field name an ambiguous second
227
- * name. The name travels on the trigger's `aria-label` instead, and the visible
228
- * `<span>` stays the thing a person reads.
224
+ * A dropdown field.
225
+ *
226
+ * `as="div"`, not the `<label>` the text and toggle fields use: the control is a
227
+ * `<button>` now (Radix's trigger), and a `<label>` around a button makes the
228
+ * visible field name an ambiguous second name — clicking the caption would also
229
+ * open the menu. The name travels on the trigger's `aria-label` instead, and the
230
+ * visible caption stays the thing a person reads.
229
231
  */
230
232
  function SelectField({ spec, value, onChange, disabled }: { spec: FieldSpec; value: string; onChange: (value: string) => void; disabled: boolean }) {
231
- return <div className="tita-settingField">
232
- <span>{spec.label}</span>
233
+ return <Field as="div" label={spec.label} hint={spec.hint}>
233
234
  <SelectMenu
234
235
  value={value}
235
236
  disabled={disabled}
@@ -237,25 +238,22 @@ function SelectField({ spec, value, onChange, disabled }: { spec: FieldSpec; val
237
238
  options={spec.options ?? []}
238
239
  aria-label={spec.label}
239
240
  />
240
- {spec.hint && <small className="tita-settingHint">{spec.hint}</small>}
241
- </div>
241
+ </Field>
242
242
  }
243
243
 
244
244
  /**
245
245
  * 开关字段。
246
246
  *
247
- * 它现在和文本 / 下拉 / 目录选择**共用同一套结构**:标签在上、控件在中、说明在下。
247
+ * 它和文本 / 下拉 / 目录选择**共用同一套结构**:标签在上、控件在中、说明在下。
248
248
  *
249
249
  * 原来它是「带边框和底色的整行」,另外三种是无边框的堆叠 —— 四种字段两种长相,而且
250
250
  * 标签用 `<strong>`、说明塞在标签里,对齐方式也对不上。更实际的问题是那个边框:
251
251
  * 小节本身已经是一张卡片,字段再包一层就是**卡片套卡片**。
252
252
  */
253
253
  function ToggleField({ spec, value, onChange, disabled }: { spec: FieldSpec; value: boolean; onChange: (value: boolean) => void; disabled: boolean }) {
254
- return <label className="tita-settingField">
255
- <span>{spec.label}</span>
254
+ return <Field label={spec.label} hint={spec.hint}>
256
255
  <Switch checked={value} disabled={disabled} onCheckedChange={onChange} />
257
- {spec.hint && <small className="tita-settingHint">{spec.hint}</small>}
258
- </label>
256
+ </Field>
259
257
  }
260
258
 
261
259
  /** 写入状态。`saved` 只短暂出现,`error` 留到下一次改动。 */
@@ -1,18 +1,29 @@
1
1
  /**
2
- * 预览抽屉的三个公共分块(0035):**元信息 → 内容 → 版本**。
2
+ * 预览抽屉的公共分块(0035 起):**元信息 → 内容 → 版本**。
3
3
  *
4
4
  * SourceRaw 的原文预览与 wiki 卡片的预览必须是**同一种形态**。两处各写一遍结构,
5
5
  * 就会出现「一个把元信息放最上面、一个放最下面」「一个能给文本预览、另一个不能」
6
6
  * 这类漂移 —— 而它们回答的是同一个问题:这份东西是什么、写了什么、改过几次。
7
7
  *
8
- * 这一层只定义**顺序与分块**(小节标题 + 计数 + 内容),字段仍由调用方给:原文有
8
+ * 这一层只定义**顺序与分块**(小节头 + 计数 + 内容),字段仍由调用方给:原文有
9
9
  * `content_hash`,卡片有 `entity_id`,本来就不一样,硬凑成同一张表只会两处都别扭。
10
10
  * 时间也由调用方格式化(见 `format.ts`)—— 元信息用标准格式,列表行用紧凑格式。
11
+ *
12
+ * 0042 重排了三件事,理由都是「三种层级要一眼分得开」:
13
+ *
14
+ * 1. **小节头换了字号**(`drawerSubHead` 13px/700,上加分隔线)。此前小节头是
15
+ * 12px/700、正文 12px/400 —— 同字号只差一个字重,于是「内容」两个字和它下面的
16
+ * 正文在视觉上是同一段文字的第 0 行与第 1 行。
17
+ * 2. **视图切换离开小节头那一行。** 它被压到 20px 高站在 12px 的小节标题旁边,与
18
+ * 小节名、字符数、(曾经的)出口按钮四种东西共享一行。现在它有自己的「读法工具栏」。
19
+ * 3. **正文块锁高度、自己滚。** 此前整条抽屉体是一根长滚动条:正文有多长,「版本」
20
+ * 就被推到多远,而版本是要动手的地方(回滚)。现在正文块有上限、块内滚动,
21
+ * 下面的分块位置不再随正文长度漂移。
11
22
  */
12
23
 
13
24
  import { useState, type ReactNode } from 'react'
14
- import { FileCode2, FileText, History, Image, ListTree } from '@tita-data-agent/ui/icons'
15
- import { Tabs, TabsContent, TabsList, TabsTrigger } from '@tita-data-agent/ui/components'
25
+ import { ChevronDown, ChevronUp, FileCode2, FileText, History, Image, ListTree } from '@tita-data-agent/ui/icons'
26
+ import { Button, Tabs, TabsContent, TabsList, TabsTrigger, cn } from '@tita-data-agent/ui/components'
16
27
  import { VersionManager, type VersionManagerProps } from '@tita-data-agent/ui/versions'
17
28
  // 平台提供的 Markdown 原子(DSH 的模块表里有它,本插件的 client bundle 也已把它列为
18
29
  // external)。原文与卡片正文本身就是 Markdown,用 `<pre>` 显示等于把标题、表格、链接
@@ -35,8 +46,17 @@ const MARKDOWN_LABELS: MarkdownLabels = {
35
46
  footnotes: '脚注',
36
47
  }
37
48
 
38
- /** 正文只渲染前 2 万字符:更长的原文在抽屉里没有阅读意义,也会拖慢 Markdown 渲染。 */
39
- const PREVIEW_CHARS = 20000
49
+ /**
50
+ * 正文在抽屉里最多渲染这么多字符。**与服务端 `SOURCE_PREVIEW_CHARS` 同值。**
51
+ *
52
+ * 服务端在传输层就把原文封顶了,前端再取同一个数,是为了让「界面上写出来的那个数」
53
+ * 与「服务端封顶的那个数」不可能是两个:0042 之前客户端另有一个 20000,
54
+ * 于是同一份材料有两个上限,而界面上一个都没写。
55
+ *
56
+ * wiki 卡片没有独立整页出口,所以卡片正文超过它时给**本地展开**(见 `PreviewContent`
57
+ * 的「展开全部」),不是只截断不给出路。
58
+ */
59
+ const PREVIEW_CHARS = 4000
40
60
 
41
61
  export interface PreviewRow {
42
62
  label: string
@@ -45,13 +65,21 @@ export interface PreviewRow {
45
65
  code?: boolean
46
66
  }
47
67
 
68
+ /**
69
+ * 抽屉里的小节头 —— 三种层级里的中间那层(标题 15px / 小节头 13px / 正文 12px)。
70
+ * 分隔线在 `.tita-drawerSection` 上(小节与上一块之间),所以这里只管内容。
71
+ */
72
+ function SectionHead({ icon, title, children }: { icon: ReactNode; title: string; children?: ReactNode }): ReactNode {
73
+ return <div className={styles.drawerSubHead}>
74
+ <h4>{icon}{title}</h4>
75
+ {children}
76
+ </div>
77
+ }
78
+
48
79
  /** 元信息:一张两列的事实表。标签列宽固定,所以标签要短且彼此对齐。 */
49
80
  export function PreviewMeta({ rows }: { rows: PreviewRow[] }): ReactNode {
50
- // 每一块都包一层 `drawerSection`:标题与它自己的内容是一块,块与块之间的间距由
51
- // 外层 `drawerStack` 给。不包的话「元信息」标题和它下面那张表是两个同级节点,
52
- // 外层 gap 会把标题和自己的表也拉开。
53
81
  return <section className={styles.drawerSection}>
54
- <div className={styles.kbSubHead}><h4><ListTree size={13} />元信息</h4></div>
82
+ <SectionHead icon={<ListTree size={13} />} title="元信息" />
55
83
  <dl className={styles.kbKv}>
56
84
  {rows.map(row => <div key={row.label}>
57
85
  <dt>{row.label}</dt>
@@ -64,33 +92,55 @@ export function PreviewMeta({ rows }: { rows: PreviewRow[] }): ReactNode {
64
92
  export interface PreviewAsset { rel: string; url: string; bytes: number }
65
93
 
66
94
  /**
67
- * 内容:小节标题 + 视图切换 + 正文。
95
+ * 内容:小节头 + 读法工具栏 + 正文(锁高内滚)。
68
96
  *
69
97
  * 两个视图是**同一份内容**的两种读法:Markdown 是阅读态(默认),文本是源码态,
70
98
  * 用于逐字核对 —— 与版本 diff 是同一种读法。切换不重新取数,也不改内容。
71
99
  * 图片素材排在这一块的末尾:它也是内容,只是渲染器没能就地展开的那部分。
100
+ *
101
+ * `totalChars` 说全文有多少字符(服务端按 `SOURCE_PREVIEW_CHARS` 封顶,
102
+ * `content.length` 已经不是全文长度了)。读全文的出口在**抽屉头**上,不在这一块里:
103
+ * 它是「这份东西还能去哪看」,位置要固定,不该随正文是否被截断而出现或消失。
72
104
  */
73
- export function PreviewContent({ content, assets = [] }: { content: string; assets?: PreviewAsset[] }): ReactNode {
105
+ export function PreviewContent({ content, assets = [], totalChars }: {
106
+ content: string
107
+ assets?: PreviewAsset[]
108
+ /** 全文总字符数。省略时按 `content.length` 算(它本来就是全文)。 */
109
+ totalChars?: number
110
+ }): ReactNode {
74
111
  const [view, setView] = useState<'md' | 'text'>('md')
75
- const shown = content.slice(0, PREVIEW_CHARS)
76
- const clipped = shown.length < content.length
77
- return <section className={styles.drawerSection}><Tabs value={view} onValueChange={next => setView(next as 'md' | 'text')}>
78
- <div className={styles.kbSubHead}>
79
- <h4><FileText size={13} />内容</h4>
80
- <TabsList className={`${styles.tabsList} ${styles.kbViewTabs}`}>
81
- <TabsTrigger value="md">Markdown</TabsTrigger>
82
- <TabsTrigger value="text"><FileCode2 size={11} />文本</TabsTrigger>
83
- </TabsList>
84
- <span>{content.length} 字符{clipped ? ` · 只渲染前 ${shown.length}` : ''}</span>
85
- </div>
86
- <TabsContent value="md">
87
- <div className={styles.kbMarkdown}><MarkdownText text={shown} labels={MARKDOWN_LABELS} /></div>
88
- </TabsContent>
89
- <TabsContent value="text">
90
- <pre className={styles.kbPre}>{shown}</pre>
91
- </TabsContent>
112
+ /** 卡片正文超过上限时的本地展开:卡片没有整页出口,只能在这里给出路。 */
113
+ const [expanded, setExpanded] = useState(false)
114
+ const shown = expanded ? content : content.slice(0, PREVIEW_CHARS)
115
+ const total = totalChars ?? content.length
116
+ const clipped = shown.length < total
117
+ const canExpand = content.length > PREVIEW_CHARS
118
+ return <section className={styles.drawerSection}>
119
+ <SectionHead icon={<FileText size={13} />} title="内容">
120
+ <span className={styles.drawerHeadFact}>{total} 字符{clipped ? ` · 这里显示前 ${shown.length}` : ''}</span>
121
+ </SectionHead>
122
+ {/* 读法工具栏:这个分块的**两种读法**是它自己的事,不再挤进小节标题那一行。 */}
123
+ <Tabs value={view} onValueChange={next => setView(next as 'md' | 'text')}>
124
+ <div className={styles.drawerToolbar}>
125
+ <TabsList className={cn(styles.tabsList, styles.kbViewTabs)}>
126
+ <TabsTrigger value="md">Markdown</TabsTrigger>
127
+ <TabsTrigger value="text"><FileCode2 size={11} />文本</TabsTrigger>
128
+ </TabsList>
129
+ {canExpand && <Button
130
+ variant="ghost" size="sm" className={styles.drawerMore}
131
+ onClick={() => setExpanded(value => !value)}
132
+ >{expanded ? <ChevronUp size={11} /> : <ChevronDown size={11} />}
133
+ {expanded ? '收起' : `展开全部(还有 ${content.length - PREVIEW_CHARS} 字符)`}</Button>}
134
+ </div>
135
+ <TabsContent value="md">
136
+ <div className={styles.drawerBody}><div className={styles.kbMarkdown}><MarkdownText text={shown} labels={MARKDOWN_LABELS} /></div></div>
137
+ </TabsContent>
138
+ <TabsContent value="text">
139
+ <div className={styles.drawerBody}><pre className={styles.kbPre}>{shown}</pre></div>
140
+ </TabsContent>
141
+ </Tabs>
92
142
  {assets.length > 0 && <>
93
- <div className={styles.kbSubHead}><h4><Image size={13} />图片素材</h4><span>{assets.length} 张</span></div>
143
+ <SectionHead icon={<Image size={13} />} title="图片素材"><span className={styles.drawerHeadFact}>{assets.length} 张</span></SectionHead>
94
144
  <div className={styles.kbAssets}>
95
145
  {assets.map(asset => <figure key={asset.rel} className={styles.kbAsset}>
96
146
  <img src={asset.url} alt={asset.rel} loading="lazy" />
@@ -98,18 +148,22 @@ export function PreviewContent({ content, assets = [] }: { content: string; asse
98
148
  </figure>)}
99
149
  </div>
100
150
  </>}
101
- </Tabs></section>
151
+ </section>
102
152
  }
103
153
 
104
154
  /**
105
- * 版本:小节标题 + 计数 + 一句「什么时候会留快照」+ 时间线。
155
+ * 版本:小节头 + 计数 + 一句「什么时候会留快照」+ 时间线。
106
156
  *
107
157
  * `hint` 不是可有可无的装饰:两种产物的快照触发条件不同(原文是内容变化,卡片是
108
158
  * 正文变化 —— 只改状态不造版本),不写清楚,用户会以为「我改过它,为什么没有版本」。
159
+ *
160
+ * 0042 没有把它降级成抽屉头的一枚按钮,理由有两条:它是当前**唯一**的回滚入口
161
+ * (`setRestoreTarget` 只在它这里被调用),而且正文块锁高之后它已经在一屏之内 ——
162
+ * 降级要新造一层状态,收益只是省一块高度。
109
163
  */
110
164
  export function PreviewVersions({ hint, ...manager }: { hint?: string } & VersionManagerProps): ReactNode {
111
165
  return <section className={styles.drawerSection}>
112
- <div className={styles.kbSubHead}><h4><History size={13} />版本</h4><span>{manager.entries?.length ?? 0} 个</span></div>
166
+ <SectionHead icon={<History size={13} />} title="版本"><span className={styles.drawerHeadFact}>{manager.entries?.length ?? 0} 个</span></SectionHead>
113
167
  {hint && <p className={styles.kbHint}>{hint}</p>}
114
168
  <VersionManager {...manager} />
115
169
  </section>
@@ -0,0 +1,313 @@
1
+ /**
2
+ * 「待确认的卡片提议」—— 0021 §6.1 那道门的**人这一侧**。
3
+ *
4
+ * ## 它为什么长在这里
5
+ *
6
+ * review 界面**不在归因图谱上**,而在「知识库 → Wiki」这一屏:它与 Pass 0 的候选草案、
7
+ * lint 待补清单是同一处,不新造第二套 review 界面(PRD §7.1 的明确要求)。
8
+ * 智能归因那边只挂一个「N 条待确认」角标,点它跳过来。
9
+ *
10
+ * ## 这一屏要回答的三个问题
11
+ *
12
+ * 1. **这是什么、从哪来** —— 触发它的那条来源(`source_ref`)与触发方式(编译抽取 /
13
+ * Agent 主动提议 / 一次排查结束)。没有这一行,人只能凭卡片标题猜它该不该存在。
14
+ * 2. **它合不合格** —— 提议阶段跑出来的三层校验问题(缺小节 / 缺字段 / 图断了 /
15
+ * 这条知识没用)逐条列出来。**校验是确认的前置**:不看清就通过,等于把校验器绕过去。
16
+ * 3. **通过之后会变成什么** —— 卡片正文的原文(只读呈现,不是输入框:它是待审内容,
17
+ * 不是可编辑表单)。
18
+ *
19
+ * ## 为什么没有「全部通过」
20
+ *
21
+ * 有的话它一定会被当成默认动作,而这道门的全部价值就在于**逐条看过**。
22
+ * 批量通过等于把「人确认」退化成一个仪式。
23
+ *
24
+ * ## 三个动作的边界
25
+ *
26
+ * | 动作 | 做什么 | 什么时候用 |
27
+ * | --- | --- | --- |
28
+ * | **通过并写入** | 确认 + 立刻落卡 | 默认。这一步之后卡片就进编译、进图、能被召回 |
29
+ * | **仅确认** | 只确认,不落卡 | 想让 Agent 用 `write_knowledge_card` 落卡时(那条路要求一条已确认且尚未落卡的提议) |
30
+ * | **拒绝** | 留原因、不落卡 | 这条不算知识。**原因必须写** —— 它同时是「下次不要再提同一条」的判据 |
31
+ */
32
+
33
+ import { useCallback, useEffect, useState, type ReactNode } from 'react'
34
+ import { AlertCircle, Check, RefreshCw, ShieldCheck, Sparkles, Trash2 } from '@tita-data-agent/ui/icons'
35
+ import { Badge, Button, Input, cn } from '@tita-data-agent/ui/components'
36
+ import { requestJson } from './request.ts'
37
+
38
+ /** 服务端返回的一条提议(`/knowledge/card-proposals`)。字段与 `proposals.ts` 同形。 */
39
+ export interface CardProposalView {
40
+ id: string
41
+ sourceRef: string
42
+ trigger: 'extraction' | 'agent' | 'diagnosis-run'
43
+ action: 'create' | 'update' | 'merge'
44
+ entityType: string
45
+ entityId: string
46
+ title: string
47
+ summary?: string
48
+ content: string
49
+ sourceRefs: string[]
50
+ status: 'pending' | 'confirmed' | 'rejected'
51
+ issues: Array<{ level: string; field?: string; message: string }>
52
+ createdAt: string
53
+ updatedAt: string
54
+ confirmedBy?: string
55
+ writtenAt?: string
56
+ reviewedBy?: string
57
+ reason?: string
58
+ }
59
+
60
+ const TRIGGER_LABEL: Record<CardProposalView['trigger'], string> = {
61
+ extraction: '编译时自动抽取',
62
+ agent: 'Agent 主动提议',
63
+ 'diagnosis-run': '一次排查结束',
64
+ }
65
+
66
+ const ACTION_LABEL: Record<CardProposalView['action'], string> = {
67
+ create: '新建卡片',
68
+ update: '更新已有卡片',
69
+ merge: '并入已有卡片',
70
+ }
71
+
72
+ /**
73
+ * 校验问题的三层标签。
74
+ *
75
+ * 三层分开报不是洁癖(0021 §6.5):**格式错说明卡片没填完,引用错说明图断了,
76
+ * 语义错说明这条知识本身没用**。三者对应的动作完全不同(补字段 / 修边 / 重写),
77
+ * 混成同一个「校验失败」人就学不会怎么写卡片。
78
+ */
79
+ const LEVEL_LABEL: Record<string, { label: string; tone: 'warning' | 'violet' | 'neutral' }> = {
80
+ format: { label: '格式', tone: 'warning' },
81
+ reference: { label: '引用', tone: 'violet' },
82
+ semantic: { label: '语义', tone: 'neutral' },
83
+ }
84
+
85
+ export function ProposalReview({ kbId, onApplied, onOpenWikiTypes }: {
86
+ kbId: string
87
+ /** 落卡之后通知父级刷新卡片列表与统计 —— 卡片数变了。 */
88
+ onApplied?: () => void
89
+ /** 「去类型定义」的入口。提议里的类型问题要在那一屏改。 */
90
+ onOpenWikiTypes?: () => void
91
+ }): ReactNode {
92
+ const [pending, setPending] = useState<CardProposalView[]>([])
93
+ const [rejected, setRejected] = useState<CardProposalView[]>([])
94
+ const [phase, setPhase] = useState<'loading' | 'ready' | 'error'>('loading')
95
+ const [busy, setBusy] = useState<string | null>(null)
96
+ const [notice, setNotice] = useState<{ tone: 'success' | 'error'; text: string } | null>(null)
97
+ /** 拒绝原因:按提议 id 存,因为每一条要写的原因不一样。 */
98
+ const [reasons, setReasons] = useState<Record<string, string>>({})
99
+ /** 展开的正文:默认收起,否则一条提议就吃掉整屏。 */
100
+ const [open, setOpen] = useState<ReadonlySet<string>>(new Set())
101
+
102
+ const load = useCallback(async (): Promise<void> => {
103
+ setPhase('loading')
104
+ try {
105
+ const [waiting, refused] = await Promise.all([
106
+ requestJson<{ proposals: CardProposalView[] }>(`/knowledge/card-proposals?kb=${encodeURIComponent(kbId)}&status=pending`),
107
+ requestJson<{ proposals: CardProposalView[] }>(`/knowledge/card-proposals?kb=${encodeURIComponent(kbId)}&status=rejected`),
108
+ ])
109
+ setPending(waiting.data?.proposals ?? [])
110
+ setRejected(refused.data?.proposals ?? [])
111
+ setPhase('ready')
112
+ } catch {
113
+ setPhase('error')
114
+ }
115
+ }, [kbId])
116
+
117
+ useEffect(() => { void load() }, [load])
118
+
119
+ const review = async (item: CardProposalView, action: 'confirm-and-write' | 'confirm' | 'reject'): Promise<void> => {
120
+ setBusy(item.id)
121
+ setNotice(null)
122
+ try {
123
+ await requestJson('/knowledge/card-proposals/review', {
124
+ method: 'POST',
125
+ headers: { 'content-type': 'application/json' },
126
+ body: JSON.stringify({
127
+ kb: kbId,
128
+ proposal_id: item.id,
129
+ action,
130
+ ...(action === 'reject' ? { reason: reasons[item.id] ?? '' } : {}),
131
+ }),
132
+ })
133
+ setNotice({
134
+ tone: 'success',
135
+ text: action === 'reject'
136
+ ? `已拒绝「${item.title}」,原因留档 —— 内容不变就不会再提同一条`
137
+ : action === 'confirm'
138
+ ? `已确认「${item.title}」。它还没落卡:Agent 可以用 write_knowledge_card 落它`
139
+ : `「${item.title}」已落卡,下一次编译就会进 wikiNotes 与图`,
140
+ })
141
+ if (action !== 'confirm') onApplied?.()
142
+ await load()
143
+ } catch (error) {
144
+ setNotice({ tone: 'error', text: error instanceof Error ? error.message : String(error) })
145
+ } finally {
146
+ setBusy(null)
147
+ }
148
+ }
149
+
150
+ const toggle = (id: string): void => {
151
+ const next = new Set(open)
152
+ if (next.has(id)) next.delete(id); else next.add(id)
153
+ setOpen(next)
154
+ }
155
+
156
+ /**
157
+ * 一条提议都没有时**整块不出现**。
158
+ *
159
+ * 它不是空态该占的位置:这一屏的主体是 wiki 卡片列表,提议是插在它前面的待办。
160
+ * 没有待办时留一块「暂无待办」的框,等于每次打开都先读一句废话。
161
+ */
162
+ if (phase === 'ready' && pending.length === 0 && rejected.length === 0) return null
163
+
164
+ return <section
165
+ aria-labelledby="tita-proposal-title"
166
+ className="flex flex-col gap-1.5 rounded-xl border border-tita-line bg-tita-surface-muted p-2.5"
167
+ >
168
+ <div className="flex flex-wrap items-center gap-2">
169
+ <h3 id="tita-proposal-title" className="flex items-center gap-1.5 text-[12px] font-semibold text-tita-ink">
170
+ <ShieldCheck size={13} />待确认的卡片提议
171
+ </h3>
172
+ <span className="text-[10.5px] text-tita-ink-muted">
173
+ {phase === 'loading' ? '正在读取…' : phase === 'error' ? '读取失败' : `${pending.length} 条待确认`}
174
+ </span>
175
+ <span className="ml-auto flex items-center gap-1">
176
+ <Button
177
+ variant="ghost" size="icon"
178
+ className="h-[26px] w-[26px] min-w-[26px]"
179
+ title="重新读取待办队列" aria-label="重新读取待办队列"
180
+ onClick={() => void load()}
181
+ ><RefreshCw size={13} /></Button>
182
+ </span>
183
+ </div>
184
+
185
+ {/* 这一屏的规矩要写在明面上:**未确认的提议不落 `02_wiki/`**。它不参与编译、
186
+ 不参与召回、不在图上出现 —— 因为它连一张卡片都还不是。 */}
187
+ <p className="text-[10.5px] leading-relaxed text-tita-ink-muted">
188
+ Agent 与编译只会**提议**,不会写卡片。未确认的提议不落 <b>02_wiki/</b>,
189
+ 因此不进编译、不参与召回、不在归因图上出现。通过之后才落卡。
190
+ </p>
191
+
192
+ {notice && <p
193
+ role="status"
194
+ className={cn('flex items-start gap-1.5 text-[10.5px] leading-relaxed', notice.tone === 'error' ? 'text-tita-danger' : 'text-tita-ink-muted')}
195
+ >
196
+ <AlertCircle size={12} className="mt-0.5 shrink-0" />
197
+ <span className="min-w-0 flex-1">{notice.text}</span>
198
+ </p>}
199
+
200
+ {phase === 'error' && <p className="text-[10.5px] leading-relaxed text-tita-danger">
201
+ 读不到这个知识库的待办队列。宿主刚起来、或知识库根目录暂时读不到时会这样。
202
+ </p>}
203
+
204
+ {pending.length > 0 && <ul className="flex flex-col gap-1.5">
205
+ {pending.map(item => <li
206
+ key={item.id}
207
+ className="flex flex-col gap-1.5 rounded-lg border border-tita-line bg-tita-surface p-2"
208
+ >
209
+ <div className="flex flex-wrap items-center gap-1.5">
210
+ <span className="min-w-0 flex-1 truncate text-[11.5px] font-semibold text-tita-ink" title={item.title}>{item.title}</span>
211
+ <code className="shrink-0 font-mono text-[9.5px] text-tita-ink-faint">{item.entityId}</code>
212
+ </div>
213
+
214
+ <div className="flex flex-wrap items-center gap-1">
215
+ <Badge tone="neutral">{item.entityType}</Badge>
216
+ <Badge tone="neutral">{ACTION_LABEL[item.action]}</Badge>
217
+ <Badge tone="violet">{TRIGGER_LABEL[item.trigger]}</Badge>
218
+ </div>
219
+
220
+ {/* 出处:没有它,人只能凭卡片标题猜这条提议该不该存在。 */}
221
+ <p className="truncate text-[10px] text-tita-ink-faint" title={item.sourceRefs.join(' · ')}>
222
+ 出处 {item.sourceRefs.join(' · ') || item.sourceRef}
223
+ </p>
224
+
225
+ {/* 三层校验的问题清单。**校验是确认的前置**:不看清就通过,等于把校验器绕过去。 */}
226
+ {item.issues.length > 0 && <ul className="flex flex-col gap-0.5">
227
+ {item.issues.map((issue, index) => {
228
+ const level = LEVEL_LABEL[issue.level] ?? { label: issue.level, tone: 'neutral' as const }
229
+ return <li key={index} className="flex items-start gap-1.5 text-[10px] leading-relaxed text-tita-ink-muted">
230
+ <Badge tone={level.tone}>{level.label}</Badge>
231
+ <span className="min-w-0 flex-1">{issue.field ? `${issue.field}:` : ''}{issue.message}</span>
232
+ </li>
233
+ })}
234
+ </ul>}
235
+
236
+ <div className="flex items-center gap-1">
237
+ <Button
238
+ variant="ghost" size="sm"
239
+ className="h-[26px] min-h-[26px] px-2 text-[10.5px]"
240
+ aria-expanded={open.has(item.id)}
241
+ onClick={() => toggle(item.id)}
242
+ >{open.has(item.id) ? '收起正文' : '看正文'}</Button>
243
+ {item.issues.length > 0 && onOpenWikiTypes && <Button
244
+ variant="ghost" size="sm"
245
+ className="h-[26px] min-h-[26px] px-2 text-[10.5px]"
246
+ onClick={onOpenWikiTypes}
247
+ >去改类型定义</Button>}
248
+ </div>
249
+
250
+ {/* 正文用**只读呈现**(等宽块),不是 `<textarea>`:它是待审内容,不是可编辑表单。
251
+ 用输入框展示等于告诉人「这里能改」,试两次才发现点不动。 */}
252
+ {open.has(item.id) && <pre className="max-h-[220px] overflow-auto overscroll-contain whitespace-pre-wrap break-words rounded-lg border border-tita-line bg-tita-canvas p-2 font-mono text-[10px] leading-relaxed text-tita-ink-muted">{item.content}</pre>}
253
+
254
+ <div className="flex flex-wrap items-center gap-1.5">
255
+ <Button
256
+ variant="default" size="sm"
257
+ className="h-[26px] min-h-[26px] px-2 text-[10.5px]"
258
+ disabled={busy !== null}
259
+ title="确认并立刻落卡:这一步之后它进编译、进图、能被召回"
260
+ onClick={() => void review(item, 'confirm-and-write')}
261
+ ><Check size={12} />通过并写入</Button>
262
+ <Button
263
+ variant="outline" size="sm"
264
+ className="h-[26px] min-h-[26px] px-2 text-[10.5px]"
265
+ disabled={busy !== null}
266
+ title="只确认、不落卡:交给 Agent 用 write_knowledge_card 落它"
267
+ onClick={() => void review(item, 'confirm')}
268
+ ><ShieldCheck size={12} />仅确认</Button>
269
+ <span className="ml-auto flex min-w-0 flex-1 items-center gap-1">
270
+ <Input
271
+ value={reasons[item.id] ?? ''}
272
+ placeholder="拒绝原因(必写:它决定下次还会不会再提同一条)"
273
+ className="h-[26px] min-h-[26px] text-[10.5px]"
274
+ onChange={event => setReasons({ ...reasons, [item.id]: event.target.value })}
275
+ />
276
+ <Button
277
+ variant="ghost" size="sm"
278
+ className="h-[26px] min-h-[26px] shrink-0 px-2 text-[10.5px]"
279
+ disabled={busy !== null}
280
+ onClick={() => void review(item, 'reject')}
281
+ ><Trash2 size={12} />拒绝</Button>
282
+ </span>
283
+ </div>
284
+ </li>)}
285
+ </ul>}
286
+
287
+ {rejected.length > 0 && <details className="rounded-lg border border-tita-line bg-tita-surface px-2 py-1.5">
288
+ <summary className="cursor-pointer text-[10.5px] text-tita-ink-muted">
289
+ 已拒绝 {rejected.length} 条 —— 原因留档,内容不变就不会再提同一条
290
+ </summary>
291
+ <ul className="mt-1.5 flex flex-col gap-1">
292
+ {rejected.map(item => <li key={item.id} className="flex flex-col gap-0.5 text-[10px] leading-relaxed text-tita-ink-muted">
293
+ <span className="flex items-center gap-1.5">
294
+ <code className="font-mono text-[9.5px] text-tita-ink-faint">{item.entityId}</code>
295
+ <span className="min-w-0 flex-1 truncate">{item.title}</span>
296
+ {item.reviewedBy && <span className="shrink-0 text-tita-ink-faint">{item.reviewedBy}</span>}
297
+ </span>
298
+ <span className="text-tita-danger">{item.reason}</span>
299
+ </li>)}
300
+ </ul>
301
+ </details>}
302
+
303
+ {pending.length === 0 && rejected.length > 0 && phase === 'ready' && <p className="flex items-center gap-1.5 text-[10.5px] text-tita-ink-muted">
304
+ <Sparkles size={12} />待办清空了。
305
+ </p>}
306
+ </section>
307
+ }
308
+
309
+ /** 待确认条数的角标:智能归因那一栏用它,点了跳到这里。 */
310
+ export function ProposalBadge({ count }: { count: number }): ReactNode {
311
+ if (count <= 0) return null
312
+ return <Badge tone="warning">{count} 条待确认</Badge>
313
+ }