dsh-data-domain-knowledge 0.1.0-alpha.2 → 0.1.0-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 (180) hide show
  1. package/README.md +147 -19
  2. package/cordis.patch.yml +25 -10
  3. package/lib/client.js +6400 -3326
  4. package/lib/doc-page.js +7761 -0
  5. package/lib/types/convert/host.d.ts.map +1 -1
  6. package/lib/types/convert/host.js +63 -11
  7. package/lib/types/convert/host.js.map +1 -1
  8. package/lib/types/convert/ingest.d.ts +7 -4
  9. package/lib/types/convert/ingest.d.ts.map +1 -1
  10. package/lib/types/convert/ingest.js +52 -14
  11. package/lib/types/convert/ingest.js.map +1 -1
  12. package/lib/types/convert/types.d.ts +2 -2
  13. package/lib/types/convert/types.d.ts.map +1 -1
  14. package/lib/types/convert/types.js +2 -2
  15. package/lib/types/convert/types.js.map +1 -1
  16. package/lib/types/diagnosis/cards.d.ts +8 -7
  17. package/lib/types/diagnosis/cards.d.ts.map +1 -1
  18. package/lib/types/diagnosis/cards.js +119 -49
  19. package/lib/types/diagnosis/cards.js.map +1 -1
  20. package/lib/types/diagnosis/run.d.ts +57 -0
  21. package/lib/types/diagnosis/run.d.ts.map +1 -0
  22. package/lib/types/diagnosis/run.js +245 -0
  23. package/lib/types/diagnosis/run.js.map +1 -0
  24. package/lib/types/diagnosis/schema.d.ts +25 -2
  25. package/lib/types/diagnosis/schema.d.ts.map +1 -1
  26. package/lib/types/diagnosis/schema.js +64 -25
  27. package/lib/types/diagnosis/schema.js.map +1 -1
  28. package/lib/types/diagnosis/seed-source.d.ts +17 -2
  29. package/lib/types/diagnosis/seed-source.d.ts.map +1 -1
  30. package/lib/types/diagnosis/seed-source.js +19 -2
  31. package/lib/types/diagnosis/seed-source.js.map +1 -1
  32. package/lib/types/diagnosis/seed.d.ts +32 -42
  33. package/lib/types/diagnosis/seed.d.ts.map +1 -1
  34. package/lib/types/diagnosis/seed.js +187 -51
  35. package/lib/types/diagnosis/seed.js.map +1 -1
  36. package/lib/types/diagnosis/store.d.ts +95 -25
  37. package/lib/types/diagnosis/store.d.ts.map +1 -1
  38. package/lib/types/diagnosis/store.js +297 -166
  39. package/lib/types/diagnosis/store.js.map +1 -1
  40. package/lib/types/diagnosis/types.d.ts +168 -16
  41. package/lib/types/diagnosis/types.d.ts.map +1 -1
  42. package/lib/types/diagnosis/types.js +1 -1
  43. package/lib/types/diagnosis/types.js.map +1 -1
  44. package/lib/types/document-page.d.ts +39 -0
  45. package/lib/types/document-page.d.ts.map +1 -0
  46. package/lib/types/document-page.js +43 -0
  47. package/lib/types/document-page.js.map +1 -0
  48. package/lib/types/extract-model.d.ts +67 -27
  49. package/lib/types/extract-model.d.ts.map +1 -1
  50. package/lib/types/extract-model.js +225 -97
  51. package/lib/types/extract-model.js.map +1 -1
  52. package/lib/types/extract.d.ts +16 -0
  53. package/lib/types/extract.d.ts.map +1 -1
  54. package/lib/types/extract.js +19 -2
  55. package/lib/types/extract.js.map +1 -1
  56. package/lib/types/fs-atomic.d.ts +11 -0
  57. package/lib/types/fs-atomic.d.ts.map +1 -0
  58. package/lib/types/fs-atomic.js +41 -0
  59. package/lib/types/fs-atomic.js.map +1 -0
  60. package/lib/types/index.d.ts.map +1 -1
  61. package/lib/types/index.js +466 -63
  62. package/lib/types/index.js.map +1 -1
  63. package/lib/types/intent/types.js +1 -1
  64. package/lib/types/intent/types.js.map +1 -1
  65. package/lib/types/knowledge-bases.d.ts +23 -12
  66. package/lib/types/knowledge-bases.d.ts.map +1 -1
  67. package/lib/types/knowledge-bases.js +37 -44
  68. package/lib/types/knowledge-bases.js.map +1 -1
  69. package/lib/types/layout.d.ts +3 -3
  70. package/lib/types/layout.d.ts.map +1 -1
  71. package/lib/types/layout.js +4 -4
  72. package/lib/types/layout.js.map +1 -1
  73. package/lib/types/migrate.d.ts +1 -1
  74. package/lib/types/migrate.js +2 -2
  75. package/lib/types/migrate.js.map +1 -1
  76. package/lib/types/model-text.d.ts +99 -0
  77. package/lib/types/model-text.d.ts.map +1 -0
  78. package/lib/types/model-text.js +103 -0
  79. package/lib/types/model-text.js.map +1 -0
  80. package/lib/types/present.d.ts +239 -0
  81. package/lib/types/present.d.ts.map +1 -0
  82. package/lib/types/present.js +255 -0
  83. package/lib/types/present.js.map +1 -0
  84. package/lib/types/recall-rank.d.ts +37 -0
  85. package/lib/types/recall-rank.d.ts.map +1 -1
  86. package/lib/types/recall-rank.js +41 -1
  87. package/lib/types/recall-rank.js.map +1 -1
  88. package/lib/types/runtime.d.ts +564 -31
  89. package/lib/types/runtime.d.ts.map +1 -1
  90. package/lib/types/runtime.js +2645 -292
  91. package/lib/types/runtime.js.map +1 -1
  92. package/lib/types/settings-schema.d.ts +4 -2
  93. package/lib/types/settings-schema.d.ts.map +1 -1
  94. package/lib/types/settings-schema.js +2 -0
  95. package/lib/types/settings-schema.js.map +1 -1
  96. package/lib/types/source-images.d.ts +118 -0
  97. package/lib/types/source-images.d.ts.map +1 -0
  98. package/lib/types/source-images.js +288 -0
  99. package/lib/types/source-images.js.map +1 -0
  100. package/lib/types/types.d.ts +356 -28
  101. package/lib/types/types.d.ts.map +1 -1
  102. package/lib/types/types.js +112 -18
  103. package/lib/types/types.js.map +1 -1
  104. package/lib/types/wiki-finalize.d.ts +140 -0
  105. package/lib/types/wiki-finalize.d.ts.map +1 -0
  106. package/lib/types/wiki-finalize.js +269 -0
  107. package/lib/types/wiki-finalize.js.map +1 -0
  108. package/lib/types/wiki-types.d.ts +10 -2
  109. package/lib/types/wiki-types.d.ts.map +1 -1
  110. package/lib/types/wiki-types.js +27 -30
  111. package/lib/types/wiki-types.js.map +1 -1
  112. package/package.json +4 -3
  113. package/preset/tita-data-agent/agent.cordis.yml +25 -10
  114. package/src/client/DirectoryPicker.tsx +12 -12
  115. package/src/client/DrawerSection.tsx +18 -0
  116. package/src/client/InfoHint.tsx +21 -8
  117. package/src/client/IntentTab.tsx +20 -26
  118. package/src/client/KnowledgeBaseTab.tsx +458 -158
  119. package/src/client/KnowledgePanel.tsx +44 -10
  120. package/src/client/KnowledgeStepRail.tsx +3 -0
  121. package/src/client/KnowledgeToolRow.tsx +331 -0
  122. package/src/client/NodeSettings.tsx +20 -22
  123. package/src/client/PreviewSections.tsx +178 -43
  124. package/src/client/RecallPanel.tsx +418 -127
  125. package/src/client/TypeDefinitionDrawer.tsx +154 -54
  126. package/src/client/WikiCardReview.tsx +266 -0
  127. package/src/client/WikiFlowCard.tsx +57 -95
  128. package/src/client/WikiTypeEditor.tsx +197 -95
  129. package/src/client/arch/AgentArchitectureGraph.tsx +30 -14
  130. package/src/client/arch/NodeLogicPanel.tsx +6 -3
  131. package/src/client/arch/model.ts +164 -26
  132. package/src/client/architecture.css +25 -10
  133. package/src/client/architecture.tsx +66 -41
  134. package/src/client/asset-images.ts +125 -0
  135. package/src/client/attribution/AttributionGraph.tsx +481 -34
  136. package/src/client/attribution/AttributionTab.tsx +501 -106
  137. package/src/client/attribution/CaseView.tsx +9 -14
  138. package/src/client/attribution/GraphStage.tsx +25 -18
  139. package/src/client/attribution/RunView.tsx +27 -9
  140. package/src/client/attribution/StageChrome.tsx +280 -140
  141. package/src/client/attribution/graph-options.ts +611 -47
  142. package/src/client/attribution/model.ts +559 -30
  143. package/src/client/convert/ConvertPanel.tsx +36 -4
  144. package/src/client/doc-page.css +134 -0
  145. package/src/client/doc-page.tsx +242 -0
  146. package/src/client/index.ts +18 -0
  147. package/src/client/panel-state.ts +105 -0
  148. package/src/client/problem-domains.ts +1 -1
  149. package/src/client/request.ts +36 -0
  150. package/src/client/styles.css +397 -54
  151. package/src/client/useFitScale.ts +15 -2
  152. package/src/client/wiki-type-groups.ts +86 -0
  153. package/src/convert/host.ts +68 -9
  154. package/src/convert/ingest.ts +61 -18
  155. package/src/convert/types.ts +2 -2
  156. package/src/diagnosis/cards.ts +120 -50
  157. package/src/diagnosis/run.ts +251 -0
  158. package/src/diagnosis/schema.ts +67 -26
  159. package/src/diagnosis/seed-source.ts +29 -4
  160. package/src/diagnosis/seed.ts +187 -85
  161. package/src/diagnosis/store.ts +310 -173
  162. package/src/diagnosis/types.ts +181 -16
  163. package/src/document-page.ts +51 -0
  164. package/src/extract-model.ts +254 -99
  165. package/src/extract.ts +35 -3
  166. package/src/fs-atomic.ts +41 -0
  167. package/src/index.ts +499 -62
  168. package/src/intent/types.ts +1 -1
  169. package/src/knowledge-bases.ts +50 -49
  170. package/src/layout.ts +4 -4
  171. package/src/migrate.ts +2 -2
  172. package/src/model-text.ts +126 -0
  173. package/src/present.ts +367 -0
  174. package/src/recall-rank.ts +57 -2
  175. package/src/runtime.ts +2802 -293
  176. package/src/settings-schema.ts +2 -0
  177. package/src/source-images.ts +326 -0
  178. package/src/types.ts +446 -45
  179. package/src/wiki-finalize.ts +310 -0
  180. package/src/wiki-types.ts +28 -32
@@ -35,21 +35,26 @@
35
35
 
36
36
  import { useCallback, useEffect, useMemo, useRef, useState, type ChangeEvent, type ReactNode } from 'react'
37
37
  import {
38
- AlertCircle, Check, Eye, FileText, FileUp, FolderOpen,
39
- Layers3, RefreshCw, Search, ShieldCheck, Tags, Trash2, Upload, X,
38
+ AlertCircle, Check, ChevronDown, Eye, ExternalLink, FileText, FileUp, FolderOpen,
39
+ Layers3, RefreshCw, Search, Tags, Trash2, Upload, X,
40
40
  } from '@tita-data-agent/ui/icons'
41
41
  import type { LucideIcon } from '@tita-data-agent/ui/icons'
42
- import { Badge, Button, Card, Input, Label, SelectMenu, cn } from '@tita-data-agent/ui/components'
42
+ import {
43
+ Badge, Button, Card, DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger,
44
+ Field, Input, SelectMenu, Tabs, TabsContent, TabsList, TabsTrigger, cn,
45
+ } from '@tita-data-agent/ui/components'
43
46
  import {
44
47
  Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle,
45
48
  Drawer, DrawerBody, DrawerContent, DrawerDescription, DrawerHeader, DrawerTitle,
46
49
  } from '@tita-data-agent/ui/overlays'
47
50
  import { ObjectPicker } from '@tita-data-agent/ui/workspace'
48
51
  import { RestoreDialog, type VersionManagerEntry } from '@tita-data-agent/ui/versions'
52
+ import { readRemembered, readRememberedId, remember } from './panel-state.ts'
49
53
  import { styles } from './styles.ts'
50
54
  import { formatBytes, formatDateTime, formatTime } from './format.ts'
51
- import { PreviewContent, PreviewMeta, PreviewVersions } from './PreviewSections.tsx'
52
- import type { KnowledgeTab } from './architecture.tsx'
55
+ import { PreviewContent, PreviewMeta, PreviewRelated, PreviewVersions, type PreviewRow } from './PreviewSections.tsx'
56
+ import type { AssetRef } from './asset-images.ts'
57
+ import { KNOWLEDGE_TABS, type KnowledgeTab } from './architecture.tsx'
53
58
  import { KnowledgeStepRail } from './KnowledgeStepRail.tsx'
54
59
  import { NodeSettingsPanel, SettingsChip, useScope, type SettingsNode, type SettingsScopeLike } from './NodeSettings.tsx'
55
60
  import { RecallPanel } from './RecallPanel.tsx'
@@ -57,7 +62,8 @@ import { KnowledgeGraphView } from './KnowledgeGraphView.tsx'
57
62
  import type { GraphEdgeView, GraphNodeView } from './graph/layout.ts'
58
63
  import type { WikiTypeView } from './WikiTypeEditor.tsx'
59
64
  import { WikiFlowCard } from './WikiFlowCard.tsx'
60
- import { TypeDefinitionDrawer } from './TypeDefinitionDrawer.tsx'
65
+ import { TypeDefinitionDrawer, type TypeDefinitionSection } from './TypeDefinitionDrawer.tsx'
66
+ import { WikiCardReview } from './WikiCardReview.tsx'
61
67
 
62
68
  // ------------------------------------------------------------------ contract
63
69
  // Mirrors the plugin HTTP contract (see plugins/dsh-data-domain-knowledge/README.md).
@@ -77,9 +83,19 @@ export interface SourceTypeDef {
77
83
  enabled: boolean
78
84
  builtin: boolean
79
85
  fallback: boolean
86
+ /** 这一类来源该带哪些元数据字段(0048)。缺省 = 没有声明。 */
87
+ metadataFields?: SourceMetadataField[]
80
88
  sourceCount: number
81
89
  }
82
90
 
91
+ /** 一个元数据字段声明。`key` 是 front matter 顶层键,或 `sources[0].author` 这种路径。 */
92
+ export interface SourceMetadataField {
93
+ key: string
94
+ label: string
95
+ description?: string
96
+ required?: boolean
97
+ }
98
+
83
99
  export interface SourceClassifierStep {
84
100
  type: string
85
101
  rule: string
@@ -121,6 +137,8 @@ export interface SourceTypeDraft {
121
137
  description: string
122
138
  signals: string
123
139
  excludes: string
140
+ /** 这一类来源该带哪些元数据字段(0048)。空行在保存前会被丢掉。 */
141
+ metadataFields?: SourceMetadataField[]
124
142
  }
125
143
 
126
144
  export interface KnowledgeBase {
@@ -133,7 +151,6 @@ export interface KnowledgeBase {
133
151
  /** Wiki 卡片类型注册表(0024)。 */
134
152
  wikiTypes: WikiTypeView[]
135
153
  /** `report` 只标记问题,`strict` 拒绝不合规的写入。 */
136
- wikiSchemaMode: 'strict' | 'report'
137
154
  /** 计算字段:这套 schema 是否已偏离出厂定义。 */
138
155
  wikiTypesCustomized: boolean
139
156
  createdAt: string
@@ -165,8 +182,15 @@ export interface KnowledgeDocument {
165
182
 
166
183
  export interface KnowledgeDocumentDetail extends KnowledgeDocument {
167
184
  content: string
185
+ /**
186
+ * 正文总字符数。`content` 可能只是前一段 —— 明细接口按 `SOURCE_PREVIEW_CHARS`
187
+ * 封顶(4000),所以**不能**再用 `content.length` 当「全文有多少」。
188
+ */
189
+ contentChars: number
190
+ /** `content` 是不是被截断过。为真时给「新页面打开」。 */
191
+ contentTruncated: boolean
168
192
  frontmatter: Record<string, unknown>
169
- assets: Array<{ rel: string; bytes: number; url: string }>
193
+ assets: AssetRefView[]
170
194
  lineage?: Record<string, string>
171
195
  }
172
196
 
@@ -181,6 +205,98 @@ export interface DocumentVersion {
181
205
  }
182
206
 
183
207
  /** Wiki 卡片(0025:带上 metadata,界面要能显示 schema 待补项)。 */
208
+ /** 图片素材的客户端形状。解析规则住在 `asset-images.ts`,两个预览抽屉共用它。 */
209
+ export type AssetRefView = AssetRef
210
+
211
+ /**
212
+ * 格式类 → 人话(0049)。
213
+ *
214
+ * `source_format` 是转换内核的词汇(`web_page` / `office` / `anydoc@0.2.4/pptx` 这一类),
215
+ * 直接摆在界面上等于让人先学一遍实现。表里没有的格式**原样显示**:一个看得出是原始值
216
+ * 的字符串,好过猜一个像人话的错标签。
217
+ */
218
+ const SOURCE_FORMAT_LABEL: Record<string, string> = {
219
+ web_page: '网页',
220
+ html: '网页',
221
+ markdown: 'Markdown',
222
+ text: '纯文本',
223
+ csv: '表格',
224
+ json: 'JSON',
225
+ office: 'Office 文档',
226
+ pdf: 'PDF',
227
+ image: '图片',
228
+ audio: '音频',
229
+ video: '视频',
230
+ }
231
+
232
+ function sourceFormatLabel(format: string | undefined): string {
233
+ if (!format) return 'unknown'
234
+ return SOURCE_FORMAT_LABEL[format] ?? format
235
+ }
236
+
237
+ /** `generated.by`(谁把它转出来的)。它只在悬停里出现,所以不给它一行。 */
238
+ function generatorOf(frontmatter: Record<string, unknown>): string {
239
+ const generated = (frontmatter.generated ?? {}) as Record<string, unknown>
240
+ return typeof generated.by === 'string' && generated.by.trim() ? `生成者 ${generated.by}` : ''
241
+ }
242
+
243
+ /**
244
+ * OKF 的那几个顶层键 → 元信息行(0048)。
245
+ *
246
+ * 只显示**文件里真的有**的键:没有就不占一行。`sources[]` 取第一条(这份文件的来源),
247
+ * 它里面最要紧的两项是作者与发布时间 —— 0048 之前它们被抽出来就扔了,知识库里
248
+ * 没有任何一份来源记得住「谁写的、什么时候发的」。
249
+ */
250
+ function sourceProvenanceRows(frontmatter: Record<string, unknown>): PreviewRow[] {
251
+ const rows: PreviewRow[] = []
252
+ const text = (value: unknown): string => (typeof value === 'string' ? value.trim() : '')
253
+ const resource = text(frontmatter.resource)
254
+ if (resource) rows.push({ label: '来源链接', value: /^https?:/i.test(resource) ? <a href={resource} target="_blank" rel="noreferrer">{resource}</a> : resource, code: true })
255
+ const generated = (frontmatter.generated ?? {}) as Record<string, unknown>
256
+ const at = text(generated.at)
257
+ if (at) rows.push({ label: '采集时间', value: formatDateTime(at) })
258
+ const first = Array.isArray(frontmatter.sources) ? (frontmatter.sources[0] as Record<string, unknown> | undefined) : undefined
259
+ const author = text(first?.author)
260
+ if (author) rows.push({ label: '作者 / 账号', value: author })
261
+ const lastModified = text(first?.last_modified)
262
+ if (lastModified) rows.push({ label: '来源更新时间', value: formatDateTime(lastModified) })
263
+ const description = text(frontmatter.description)
264
+ if (description) rows.push({ label: '摘要', value: description })
265
+ return rows
266
+ }
267
+
268
+ /**
269
+ * 分类声明的元数据字段 → 元信息行(0048)。
270
+ *
271
+ * 缺的字段**照样列出来**并写「缺」:那一行本身就是提示(「这类来源通常要带作者」),
272
+ * 藏起来等于让人自己去猜分类声明了什么。
273
+ */
274
+ function declaredMetadataRows(fields: SourceMetadataField[] | undefined, frontmatter: Record<string, unknown>): PreviewRow[] {
275
+ if (!fields || fields.length === 0) return []
276
+ const valueAt = (key: string): string => {
277
+ let current: unknown = frontmatter
278
+ for (const part of key.split('.')) {
279
+ const name = part.replace(/\[\d+\]/g, '')
280
+ if (name) {
281
+ if (current === null || typeof current !== 'object') return ''
282
+ current = (current as Record<string, unknown>)[name]
283
+ }
284
+ for (const index of part.matchAll(/\[(\d+)\]/g)) {
285
+ current = Array.isArray(current) ? current[Number(index[1])] : undefined
286
+ }
287
+ }
288
+ return typeof current === 'string' ? current.trim() : current === undefined || current === null ? '' : String(current)
289
+ }
290
+ return fields.map(field => {
291
+ const value = valueAt(field.key)
292
+ return {
293
+ label: field.label,
294
+ value: value !== '' ? value : <span className={styles.kbHint}>缺{field.required === true ? '(这一类通常要带)' : ''}</span>,
295
+ ...(field.description ? { title: field.description } : {}),
296
+ } as PreviewRow
297
+ })
298
+ }
299
+
184
300
  export interface KnowledgeCardView {
185
301
  entityId: string
186
302
  entityType: string
@@ -191,6 +307,26 @@ export interface KnowledgeCardView {
191
307
  sourceRefs: string[]
192
308
  metadata?: Record<string, unknown>
193
309
  updatedAt: string
310
+ /** 人工备注(0044)。正文之外的东西 —— 编译不碰它,也不进召回正文。 */
311
+ annotations?: Array<{ id: string; kind: 'note' | 'anomaly'; note: string; author?: string; createdAt?: string; sourceRef?: string }>
312
+ /** review 结论与评分(0044)。缺省 = 还没人看过。 */
313
+ review?: { state: 'unreviewed' | 'reviewed'; score: number; reviewedAt?: string; reviewedBy?: string }
314
+ }
315
+
316
+ /**
317
+ * 一张卡片的相关卡片(0049):出链与入链。服务端按图上的关系边与「同一份来源」算好
318
+ * 之后给过来,界面只负责显示成可点的行 —— 客户端不解析正文里那块 `## 相关`。
319
+ */
320
+ export interface CardRelatedView {
321
+ outgoing: CardRelatedEntry[]
322
+ incoming: CardRelatedEntry[]
323
+ }
324
+
325
+ export interface CardRelatedEntry {
326
+ entityId: string
327
+ entityType: string
328
+ title: string
329
+ reason: 'relation' | 'source'
194
330
  }
195
331
 
196
332
  /** 一张 wiki 卡片的历史版本快照。 */
@@ -205,25 +341,7 @@ export interface WikiCardVersionView {
205
341
  isCurrent: boolean
206
342
  }
207
343
 
208
- interface Envelope<T> { ok?: boolean; data?: T; warnings?: string[]; error?: { code?: string; message?: string; suggestion?: string } | null }
209
-
210
- async function requestJson<T>(url: string, init?: RequestInit): Promise<Envelope<T>> {
211
- const response = await fetch(url, init)
212
- const text = await response.text()
213
- if (!text.trim()) throw new Error(`服务端返回空响应(HTTP ${response.status})`)
214
- let payload: Envelope<T>
215
- try {
216
- payload = JSON.parse(text) as Envelope<T>
217
- } catch {
218
- throw new Error(`服务端返回了无效响应(HTTP ${response.status})`)
219
- }
220
- const error = payload.error
221
- if (!response.ok || payload.ok === false) {
222
- const detail = [error?.code, error?.message].filter(Boolean).join(' · ') || `请求失败(${response.status})`
223
- throw new Error(error?.suggestion ? `${detail} — ${error.suggestion}` : detail)
224
- }
225
- return payload
226
- }
344
+ import { requestJson, type Envelope } from './request.ts'
227
345
 
228
346
  /** Text-ish formats the browser can read and POST as SourceRaw directly. */
229
347
  const DIRECT_READ_EXT = new Set(['.md', '.markdown', '.txt', '.json', '.tsv', '.csv', '.html'])
@@ -275,7 +393,22 @@ export function KnowledgeBaseTab({ visible, onChanged, onActiveBaseChange, knowl
275
393
  const [loading, setLoading] = useState(false)
276
394
  const [busy, setBusy] = useState<string | null>(null)
277
395
  const [notice, setNotice] = useState<{ tone: 'success' | 'error'; text: string } | null>(null)
278
- const [tab, setTab] = useState<KnowledgeTab>('source-raw')
396
+ /**
397
+ * 分栏与选中的知识库。**起始值来自记住的位置**(`panel-state.ts`):面板会被 DSH
398
+ * 重新挂载(刷新页面必然),只放在 `useState` 里的话,用户每次回来都落回
399
+ * 「SourceRaw + 默认知识库」。分栏存的是上一版的 id 时先对着 `KNOWLEDGE_TABS` 校验;
400
+ * 知识库 id 是服务端数据、没有注册表可校验,所以由 `loadBases` 用它自己的
401
+ * 「不存在就退回默认库」那条规则兜底。
402
+ */
403
+ const [tab, setTab] = useState<KnowledgeTab>(() => readRemembered('knowledgeTab', KNOWLEDGE_TABS.map(item => item.id)) ?? 'source-raw')
404
+ /**
405
+ * wiki 栏内部的二级 tab(0042):卡片 / 抽取流程。
406
+ *
407
+ * **不记进 `panel-state.ts`**:那里记的是「上次看到哪儿」的三个大位置(一级 tab、
408
+ * 分栏、知识库),而这是某一栏内部的两种读法 —— 面板重新挂载时落回「卡片」
409
+ * (这一栏的第一件事)是正确的默认,而且不必为一个二级 tab 去改那个模块的 key 表。
410
+ */
411
+ const [wikiTab, setWikiTab] = useState<'cards' | 'flow'>('cards')
279
412
  const [editOpen, setEditOpen] = useState(false)
280
413
  const [draft, setDraft] = useState({ id: '', name: '', description: '' })
281
414
  /**
@@ -284,18 +417,39 @@ export function KnowledgeBaseTab({ visible, onChanged, onActiveBaseChange, knowl
284
417
  * 拉取,因为停用的分类要在提示词里标记出来。
285
418
  */
286
419
  const [catalog, setCatalog] = useState<SourceTypeCatalog | null>(null)
420
+
421
+ /**
422
+ * 换分栏的**唯一入口**:顺手记一笔,下次挂载落回同一栏。两个调用点(步骤轨点击、
423
+ * 从架构图节点跳过来)都走它,所以不会出现「这个入口记得、那个入口不记得」。
424
+ */
425
+ const changeTab = useCallback((next: KnowledgeTab): void => {
426
+ setTab(next)
427
+ remember('knowledgeTab', next)
428
+ }, [])
287
429
  /**
288
430
  * 「类型定义」抽屉(0034):本库的词汇表 —— 来源分类 + Wiki 卡片 schema。
289
431
  * 弹窗草稿在抽屉内部,这里只留开关与写路径。
290
432
  */
291
- const [typeDefOpen, setTypeDefOpen] = useState(false)
433
+ /**
434
+ * 类型定义抽屉的打开状态 + **该落在哪一节**。
435
+ *
436
+ * 两件事合成一个 state 是有意的:分开写时,「打开」与「看哪一节」会在两次渲染里生效,
437
+ * 抽屉先按默认节滚一次、再滚一次到目标节 —— 人看到的是闪一下。
438
+ */
439
+ const [typeDef, setTypeDef] = useState<{ open: boolean; section: TypeDefinitionSection }>({ open: false, section: 'source' })
292
440
  /** 上传 / 导入时用的来源分类;注册表变了要跟着回退,不能指向一个已停用的 id。 */
293
441
  const [uploadType, setUploadType] = useState('document')
294
442
  const [detail, setDetail] = useState<{ document: KnowledgeDocumentDetail; versions: DocumentVersion[] } | null>(null)
295
443
  const [detailLoading, setDetailLoading] = useState(false)
296
444
  const [restoreTarget, setRestoreTarget] = useState<VersionManagerEntry | null>(null)
297
445
  /** Wiki 卡片详情与版本(0025)。 */
298
- const [cardDetail, setCardDetail] = useState<{ card: KnowledgeCardView; versions: WikiCardVersionView[] } | null>(null)
446
+ /**
447
+ * 卡片详情的三个部分:卡片本体、历史版本、图片素材。
448
+ *
449
+ * `assets` 是服务端把卡片正文里的图解析成的可加载 URL(0047)。少了它,预览抽屉里
450
+ * 卡片的图只能按 GUI 的地址去解析 `_assets/...`,全是 404。
451
+ */
452
+ const [cardDetail, setCardDetail] = useState<{ card: KnowledgeCardView; versions: WikiCardVersionView[]; assets: AssetRefView[]; related?: CardRelatedView } | null>(null)
299
453
  const [cardLoading, setCardLoading] = useState(false)
300
454
  const [cardRestore, setCardRestore] = useState<VersionManagerEntry | null>(null)
301
455
  const [graph, setGraph] = useState<{ nodes: GraphNodeView[]; edges: GraphEdgeView[] }>({ nodes: [], edges: [] })
@@ -349,7 +503,7 @@ export function KnowledgeBaseTab({ visible, onChanged, onActiveBaseChange, knowl
349
503
 
350
504
  /** 架构图节点跳转:落到对应分栏。 */
351
505
  useEffect(() => {
352
- if (visible && focus) setTab(focus.tab)
506
+ if (visible && focus) changeTab(focus.tab)
353
507
  }, [visible, focus?.tab, focus?.nonce]) // eslint-disable-line react-hooks/exhaustive-deps
354
508
 
355
509
  /** `sourceId → wiki cards`, so a file row can say how many slices it produced. */
@@ -369,13 +523,16 @@ export function KnowledgeBaseTab({ visible, onChanged, onActiveBaseChange, knowl
369
523
  const loadBases = useCallback(async (preferred?: string): Promise<{ next: KnowledgeBase[]; resolved: string }> => {
370
524
  const payload = await requestJson<{ bases: KnowledgeBase[]; defaultBaseId: string }>('/knowledge/bases')
371
525
  const next = payload.data?.bases ?? []
372
- const requested = preferred ?? activeIdRef.current
526
+ // 优先级:显式指定(新建 / 编辑后回到那一个)→ 本次挂载里已经选中的 → **记住的那个**。
527
+ // 最后一档就是「刷新之后还在原来那个库」,而它不合法时下面那句会退回默认库。
528
+ const requested = preferred ?? (activeIdRef.current !== '' ? activeIdRef.current : readRememberedId('knowledgeBase'))
373
529
  const resolved = next.some(base => base.id === requested)
374
530
  ? requested
375
531
  : payload.data?.defaultBaseId ?? next[0]?.id ?? ''
376
532
  setBases(next)
377
533
  setActiveId(resolved)
378
534
  activeIdRef.current = resolved
535
+ remember('knowledgeBase', resolved)
379
536
  return { next, resolved }
380
537
  }, [])
381
538
 
@@ -531,6 +688,9 @@ export function KnowledgeBaseTab({ visible, onChanged, onActiveBaseChange, knowl
531
688
  description: draft.description.trim(),
532
689
  signals: draft.signals.split('\n').map(line => line.trim()).filter(Boolean),
533
690
  excludes: draft.excludes.split('\n').map(line => line.trim()).filter(Boolean),
691
+ // 元数据字段声明(0048):与 signals / excludes 同一条约定 —— 「没传」不改动磁盘上
692
+ // 已有的声明,传了空数组才是清空。
693
+ ...(draft.metadataFields === undefined ? {} : { metadataFields: draft.metadataFields }),
534
694
  }
535
695
  const isNew = draft.mode === 'create'
536
696
  setBusy('source-type')
@@ -610,44 +770,6 @@ export function KnowledgeBaseTab({ visible, onChanged, onActiveBaseChange, knowl
610
770
  }
611
771
  }
612
772
 
613
- const patchWikiSchemaMode = async (mode: 'strict' | 'report'): Promise<void> => {
614
- const base = active
615
- if (!base) return
616
- setBusy('wiki-mode')
617
- try {
618
- await requestJson(`/knowledge/bases/${encodeURIComponent(base.id)}`, {
619
- method: 'PATCH',
620
- headers: { 'content-type': 'application/json' },
621
- body: JSON.stringify({ wikiSchemaMode: mode }),
622
- })
623
- await loadBases(base.id)
624
- say('success', mode === 'strict' ? '已开启严格模式:缺必填小节 / 字段将拒绝写入' : '已切回 report 模式:只标记问题,不拦截写入')
625
- } catch (error) {
626
- say('error', error instanceof Error ? error.message : String(error))
627
- } finally {
628
- setBusy(null)
629
- }
630
- }
631
-
632
- /** 清除 wiki 类型与模式上的用户层覆盖,回到出厂 11 个类型。 */
633
- const resetWikiTypes = async (): Promise<void> => {
634
- const base = active
635
- if (!base) return
636
- setBusy('wiki-types')
637
- try {
638
- await requestJson(`/knowledge/bases/${encodeURIComponent(base.id)}`, {
639
- method: 'PATCH',
640
- headers: { 'content-type': 'application/json' },
641
- body: JSON.stringify({ resetWikiTypes: true }),
642
- })
643
- await loadBases(base.id)
644
- say('success', '已恢复出厂 Wiki 类型')
645
- } catch (error) {
646
- say('error', error instanceof Error ? error.message : String(error))
647
- } finally {
648
- setBusy(null)
649
- }
650
- }
651
773
 
652
774
  const writeSource = async (title: string, content: string, externalId: string): Promise<void> => {
653
775
  const base = active
@@ -738,11 +860,18 @@ export function KnowledgeBaseTab({ visible, onChanged, onActiveBaseChange, knowl
738
860
  // 的**原文状态重新满足 `open`,于是面板「自己又弹回来」——互斥必须两边都成立。
739
861
  setCardDetail(null)
740
862
  setDetailLoading(true)
741
- setDetail({ document: { ...document, content: '', frontmatter: {}, assets: [] }, versions: [] })
863
+ setDetail({ document: { ...document, content: '', contentChars: 0, contentTruncated: false, frontmatter: {}, assets: [] }, versions: [] })
742
864
  try {
743
865
  const q = `?kb=${encodeURIComponent(baseId)}`
744
- const docPayload = await requestJson<{ document: KnowledgeDocumentDetail }>(`/knowledge/documents/${encodeURIComponent(document.docId)}${q}`)
745
- const versionPayload = await requestJson<{ versions: DocumentVersion[] }>(`/knowledge/documents/${encodeURIComponent(document.docId)}/versions${q}`)
866
+ /**
867
+ * 两条请求**并行**。原来是串行的(先详情、再版本),于是一次打开弹层的等待
868
+ * 是两段的和 —— 而它们之间没有任何依赖。详情返回之后弹层就能读,
869
+ * 版本时间线自己在 `detailLoading` 里补。
870
+ */
871
+ const [docPayload, versionPayload] = await Promise.all([
872
+ requestJson<{ document: KnowledgeDocumentDetail }>(`/knowledge/documents/${encodeURIComponent(document.docId)}${q}`),
873
+ requestJson<{ versions: DocumentVersion[] }>(`/knowledge/documents/${encodeURIComponent(document.docId)}/versions${q}`),
874
+ ])
746
875
  if (docPayload.data?.document) {
747
876
  setDetail({ document: docPayload.data.document, versions: versionPayload.data?.versions ?? [] })
748
877
  }
@@ -796,20 +925,41 @@ export function KnowledgeBaseTab({ visible, onChanged, onActiveBaseChange, knowl
796
925
 
797
926
  // ------------------------------------------------------------ Wiki 详情
798
927
 
799
- /** 点开一张卡片:内容 + 版本历史。版本由服务端在每次正文变化前快照。 */
800
- const openCard = async (card: KnowledgeCardView): Promise<void> => {
928
+ /**
929
+ * 点开一张卡片:内容 + 版本历史(版本由服务端在每次正文变化前快照)。
930
+ *
931
+ *
932
+ * 入参只要求 `entityId` + `entityType`:正文列表里有完整卡片对象,而「相关卡片」
933
+ * 那一列(0049)只知道对方的身份与标题 —— 详情随后由接口取回,占位对象只在取数
934
+ * 失败时用得上,所以它由这里就地补全,不要求每个调用方都造一份假的 KnowledgeCardView。
935
+ */
936
+ const openCard = async (card: Pick<KnowledgeCardView, 'entityId' | 'entityType'> & Partial<KnowledgeCardView>): Promise<void> => {
801
937
  const baseId = activeIdRef.current
802
938
  if (!baseId) return
803
939
  setDetail(null)
804
940
  setCardLoading(true)
805
- setCardDetail({ card, versions: [] })
941
+ setCardDetail({
942
+ card: {
943
+ entityId: card.entityId, entityType: card.entityType, title: card.title ?? card.entityId,
944
+ status: card.status ?? 'unreviewed', content: card.content ?? '', sourceRefs: card.sourceRefs ?? [], updatedAt: card.updatedAt ?? '',
945
+ },
946
+ versions: [], assets: [],
947
+ })
806
948
  try {
807
949
  const q = `?kb=${encodeURIComponent(baseId)}&entity_type=${encodeURIComponent(card.entityType)}`
808
950
  const [detailPayload, versionPayload] = await Promise.all([
809
- requestJson<{ card: KnowledgeCardView }>(`/knowledge/cards/${encodeURIComponent(card.entityId)}${q}`),
951
+ requestJson<{ card: KnowledgeCardView; assets?: AssetRefView[]; related?: CardRelatedView }>(`/knowledge/cards/${encodeURIComponent(card.entityId)}${q}`),
810
952
  requestJson<{ versions: WikiCardVersionView[] }>(`/knowledge/cards/${encodeURIComponent(card.entityId)}/versions${q}`),
811
953
  ])
812
- setCardDetail({ card: detailPayload.data?.card ?? card, versions: versionPayload.data?.versions ?? [] })
954
+ setCardDetail(current => ({
955
+ card: detailPayload.data?.card ?? current?.card ?? {
956
+ entityId: card.entityId, entityType: card.entityType, title: card.title ?? card.entityId,
957
+ status: card.status ?? 'unreviewed', content: card.content ?? '', sourceRefs: card.sourceRefs ?? [], updatedAt: card.updatedAt ?? '',
958
+ },
959
+ versions: versionPayload.data?.versions ?? [],
960
+ assets: detailPayload.data?.assets ?? [],
961
+ ...(detailPayload.data?.related ? { related: detailPayload.data.related } : {}),
962
+ }))
813
963
  } catch (error) {
814
964
  setNotice({ tone: 'error', text: error instanceof Error ? error.message : String(error) })
815
965
  setCardDetail(null)
@@ -818,6 +968,33 @@ export function KnowledgeBaseTab({ visible, onChanged, onActiveBaseChange, knowl
818
968
  }
819
969
  }
820
970
 
971
+ /**
972
+ * 抽屉里做完一次写(review / 备注)之后,把**这张卡片**和**卡片列表**都重新读一遍。
973
+ *
974
+ * 为什么不复用 `openCard`:它先把 `cardDetail` 清成 `{versions: []}` 再拉全量,
975
+ * 那会在人刚点完「review 通过」时把卡片正文闪成空的。这里只覆盖数据、保住抽屉
976
+ * 已经渲染出来的东西。
977
+ *
978
+ * 列表也必须刷:行上的「已 review / 异常点 N」徽标就是列表回答的那个问题。
979
+ */
980
+ const refreshCardAfterReview = async (): Promise<void> => {
981
+ const baseId = activeIdRef.current
982
+ const current = cardDetail
983
+ if (!baseId || !current) return
984
+ try {
985
+ const q = `?kb=${encodeURIComponent(baseId)}&entity_type=${encodeURIComponent(current.card.entityType)}`
986
+ const payload = await requestJson<{ card: KnowledgeCardView }>(`/knowledge/cards/${encodeURIComponent(current.card.entityId)}${q}`)
987
+ // 用函数式更新:人可能在这两次往返之间点了别的卡片,闭包里的 `current` 已经不是
988
+ // 抽屉里现在那一张了。
989
+ setCardDetail(previous => previous && previous.card.entityId === current.card.entityId
990
+ ? { ...previous, card: payload.data?.card ?? previous.card }
991
+ : previous)
992
+ } catch (error) {
993
+ setNotice({ tone: 'error', text: error instanceof Error ? error.message : String(error) })
994
+ }
995
+ void loadCards(baseId)
996
+ }
997
+
821
998
  const revertCard = async (): Promise<void> => {
822
999
  const target = cardRestore
823
1000
  const current = cardDetail
@@ -917,14 +1094,38 @@ export function KnowledgeBaseTab({ visible, onChanged, onActiveBaseChange, knowl
917
1094
  .map(([ext, count]) => ({ value: ext, label: `${ext}(${count})` }))
918
1095
  }, [documents])
919
1096
  /**
920
- * `/knowledge/stats` 在这里是 `Record<string, unknown>`(它同时承载编译设置快照),
921
- * 所以 `lastBuild` 要显式收窄一次再读 —— 否则每个字段都得写一遍类型断言。
1097
+ * 一次性迁移里没有静默处理掉的事(来源分类改名冲突、卡片改名迁移失败)。
1098
+ *
1099
+ * 它必须显示出来:**迁移失败而没人知道,等价于数据已经丢了**。放在卡片列表上方,
1100
+ * 因为它比「库里有什么」更紧急。
922
1101
  */
923
- const lastBuild = (stats.lastBuild ?? {}) as Record<string, unknown>
1102
+ const migrationProblems = Array.isArray(stats.migrationProblems) ? stats.migrationProblems as string[] : []
924
1103
  const currentVersionId = detail?.versions.find(version => version.isCurrent)?.versionId
925
1104
  const currentCardVersionId = cardDetail?.versions.find(version => version.isCurrent)?.versionId
926
1105
  const detailCards = detail ? cardsBySource.get(detail.document.docId) ?? [] : []
927
1106
  const pendingCards = cards.filter(card => schemaIssuesOf(card).length > 0)
1107
+ /**
1108
+ * 需要人看一眼的卡片数:**没人看过**,或者**被标了异常点**(0044)。
1109
+ *
1110
+ * 0044 之前这里是一整块待办区,逐条列出正文与动作。现在 review 长在卡片预览里,
1111
+ * 列表只需要回答「有几张要我动手」—— 具体是哪几张、缺什么、谁说过什么,点开就见。
1112
+ */
1113
+ const unreviewedCards = cards.filter(card =>
1114
+ card.review?.state !== 'reviewed' || (card.annotations ?? []).some(item => item.kind === 'anomaly')).length
1115
+
1116
+ /**
1117
+ * 在独立整页里读这份原文。
1118
+ *
1119
+ * 抽屉宽 380px,而原文可能是一份几十万字的转换文档 —— 那里只显示前 4000 字符
1120
+ * (`SOURCE_PREVIEW_CHARS`),剩下的出口是这个整页:新标签、可整页搜索、自带分页,
1121
+ * 一次只取一段。
1122
+ */
1123
+ const openFullDocument = useCallback((document: KnowledgeDocument): void => {
1124
+ const kb = activeIdRef.current
1125
+ if (!kb) return
1126
+ const url = `/knowledge/documents/${encodeURIComponent(document.docId)}/view?kb=${encodeURIComponent(kb)}`
1127
+ window.open(url, '_blank', 'noopener,noreferrer')
1128
+ }, [])
928
1129
 
929
1130
  /**
930
1131
  * 文件类型筛在客户端做(接口没有这个过滤口),其余两个筛选在服务端。
@@ -949,14 +1150,21 @@ export function KnowledgeBaseTab({ visible, onChanged, onActiveBaseChange, knowl
949
1150
  /**
950
1151
  * 步骤轨上每一步的真实事实。计数来自落盘与设置:`active` 的计数是服务端算的,
951
1152
  * 图谱计数优先读 `/knowledge/stats`(它统计的是全量),拿不到才退回这一页拉到的前 50 条。
1153
+ *
1154
+ * 0042:wiki 这一步的 note 补上 wikiNotes 数、索引版本、增量指纹与卡片快照 ——
1155
+ * 它们原来在「编译 wiki 与 wikiNotes」那张卡里,卡删掉之后这几条数得有地方去。
1156
+ * 顺序按重要性排:窄栏下 note 会省略号截断,先看到的必须是更该看到的。
952
1157
  */
953
1158
  const stepFacts = useMemo(() => ({
954
1159
  'source-raw': { value: active ? `${active.sourceCount} 个文件` : '—', note: '按格式路由解析,同 external_id 幂等' },
955
- wiki: { value: active ? `${active.cardCount} 张卡片` : '—', note: '卡片 → wikiNotes,未变的逐字复用' },
1160
+ wiki: {
1161
+ value: active ? `${active.cardCount} 张卡片` : '—',
1162
+ note: `${active?.noteCount ?? 0} 条 wikiNotes · 索引 v${active?.buildVersion ?? 0} · 指纹 ${String(stats.trackedCards ?? '—')} · 快照 ${String(stats.cardVersions ?? 0)}`,
1163
+ },
956
1164
  graph: { value: `${stats.graphNodes ?? graph.nodes.length} 节点 / ${stats.graphEdges ?? graph.edges.length} 边`, note: 'supported_by 溯源边 + relates_to 关系边' },
957
- recall: { value: `topK ${String(knowledgeValue?.recallTopK ?? 10)}`, note: `${String(knowledgeValue?.recallMode ?? 'hybrid')} 三路融合` },
1165
+ recall: { value: `topK ${String(knowledgeValue?.recallTopK ?? 10)}`, note: `${String(knowledgeValue?.recallMode ?? 'hybrid')} 关键词 + 向量融合` },
958
1166
  rerank: { value: `topN ${String(knowledgeValue?.contextTopN ?? 5)}`, note: 'BERT cross-encoder 精排' },
959
- }), [active, stats.graphNodes, stats.graphEdges, graph.nodes.length, graph.edges.length, knowledgeValue?.recallTopK, knowledgeValue?.recallMode, knowledgeValue?.contextTopN])
1167
+ }), [active, stats.graphNodes, stats.graphEdges, stats.trackedCards, stats.cardVersions, graph.nodes.length, graph.edges.length, knowledgeValue?.recallTopK, knowledgeValue?.recallMode, knowledgeValue?.contextTopN])
960
1168
 
961
1169
  // --------------------------------------------------------------- 分栏内容
962
1170
 
@@ -1056,9 +1264,40 @@ export function KnowledgeBaseTab({ visible, onChanged, onActiveBaseChange, knowl
1056
1264
  这里不再留任何指路文字:工具栏与卡片头各自带着入口,路牌只是搬迁留下的债。 */}
1057
1265
  </div>
1058
1266
 
1059
- /** Wiki:卡片列表(一等内容)+ 编译 + 抽取流程 + schema 注册表。 */
1267
+ /**
1268
+ * Wiki:**两种内容,两枚 tab**(0042)。
1269
+ *
1270
+ * 0042 之前这里是三块竖排:卡片列表 → 「编译 wiki 与 wikiNotes」→ 抽取流程。
1271
+ * 中间那一块同时讲动作(增量 / 全量按钮)与结果(8 行计数)—— 动作是「对这个库
1272
+ * 做什么」,归工具栏;结果是「这一段现在有多少东西」,归步骤轨。把它留在这里,
1273
+ * 等于让一栏内容替工具栏收着一枚按钮,还占掉一屏。
1274
+ *
1275
+ * **review 不进这一栏**(0044 第二次改):它第一版在这里插了一块「入库后的 review」
1276
+ * 待办区,按卡片逐条列出正文、校验与动作。那等于把同一张卡片摊在两个地方,而人点开
1277
+ * 卡片看内容的那一刻,正是他要判断这张卡对不对的那一刻。现在它长在卡片预览抽屉里
1278
+ * (`WikiCardReview`),列表只用行上的徽标回答「哪几张需要我看」。
1279
+ */
1060
1280
  const renderWiki = (): ReactNode => <div className={styles.viewStack}>
1061
- {/* 卡片列表排在第一位,和 SourceRaw 的文件列表同一个位置、同一套筛选行 ——
1281
+ {/* 迁移里没静默处理掉的事排在最前面:**迁移失败而没人知道,等价于数据已经丢了**。
1282
+ 它比「库里有什么」更紧急,所以不藏在统计里、也不只进日志。 */}
1283
+ {migrationProblems.length > 0 && <p className={cn(styles.kbNotice, styles.kbNoticeError)} role="alert">
1284
+ <AlertCircle size={13} />
1285
+ <span>
1286
+ <b>有 {migrationProblems.length} 处改名迁移没有生效</b>,旧文件都留在原地(没有覆盖、没有删除):
1287
+ {migrationProblems.join(';')}
1288
+ </span>
1289
+ </p>}
1290
+ {/* Pass 0 的候选 / 建卡计数(`candidatesExtracted` / `cardsCreated` / `cardsUpdated`)
1291
+ 不在这一屏上显示。0042 撤掉的理由仍然成立:这三个数**没有指向任何可动手的东西** ——
1292
+ 「上一轮抽出 0 个候选」既不是待办,也不是这一栏的内容。数字本身还在
1293
+ build-manifest 里,排查编译问题时从产物读,不占这一屏。 */}
1294
+ <Tabs className={styles.kbSubTabs} value={wikiTab} onValueChange={next => setWikiTab(next as 'cards' | 'flow')}>
1295
+ <TabsList className={cn(styles.tabsList, styles.kbSubTabsList)}>
1296
+ <TabsTrigger value="cards"><FileText size={12} />卡片</TabsTrigger>
1297
+ <TabsTrigger value="flow"><Layers3 size={12} />抽取流程</TabsTrigger>
1298
+ </TabsList>
1299
+ <TabsContent value="cards">
1300
+ {/* 卡片列表和 SourceRaw 的文件列表同一个位置、同一套筛选行 ——
1062
1301
  两栏的「这一层有什么」都应该是打开就能看到的第一件东西。0027 之前这里
1063
1302
  先是一张编译统计卡,卡片列表在第三位,得先读过参数才看得到内容。 */}
1064
1303
  <Card className={styles.kbCard}>
@@ -1092,62 +1331,50 @@ export function KnowledgeBaseTab({ visible, onChanged, onActiveBaseChange, knowl
1092
1331
  />
1093
1332
  <Button variant="ghost" size="icon" title="刷新" aria-label="刷新卡片列表" onClick={() => void loadCards(activeIdRef.current)}><RefreshCw size={14} /></Button>
1094
1333
  </div>
1095
- {pendingCards.length > 0 && <p className={styles.kbFilterHint}>有 <b>{pendingCards.length}</b> 张卡片缺必填小节 / 字段(report 模式只标记不拦截)。筛「待补」请用状态筛选,或在工具栏「类型定义」里切换生效方式。</p>}
1334
+ {/* 两件事分开说:**「有没有人看过」和「卡片填完没有」不是一回事**。
1335
+ 合成一句「有 N 张有问题」,人既不知道要不要动手,也不知道动手做什么。 */}
1336
+ {(unreviewedCards > 0 || pendingCards.length > 0) && <p className={styles.kbFilterHint}>
1337
+ {unreviewedCards > 0 && <>有 <b>{unreviewedCards}</b> 张还没人看过或带着异常点 —— 点开任意一张卡片,review 与备注在它的预览侧栏里。</>}
1338
+ {unreviewedCards > 0 && pendingCards.length > 0 && ' '}
1339
+ {pendingCards.length > 0 && <>另有 <b>{pendingCards.length}</b> 张缺必填小节 / 字段。这不是错误:缺信息也入库,后面有涉及到的文档会补上。</>}
1340
+ </p>}
1096
1341
  {visibleCards.length === 0 && <p className={styles.kbEmpty}>
1097
1342
  {cards.length > 0
1098
1343
  ? '当前筛选没有命中的卡片。清掉筛选条件就能看到这个知识库的全部卡片。'
1099
- : <>还没有卡片。先「增量编译」(Pass 0 会从正文标题、<b>[[链接]]</b> 与 frontmatter 抽候选),或让 Agent 用 <b>write_knowledge_card</b> 写入 —— 写入前会按 wiki 类型的 schema 校验。</>}
1344
+ : <>还没有卡片。先录入一份来源(或上传文件),再点「编译」—— 编译器会按 wiki 类型的 schema 抽出实体并直接入库;缺信息也照进,后面有涉及到的文档会慢慢补上。</>}
1100
1345
  </p>}
1101
1346
  <ul className={styles.cardList}>
1102
1347
  {visibleCards.map(card => {
1103
1348
  const issues = schemaIssuesOf(card)
1104
- // Pass 0 抽出来的卡片是**机器猜的**,与 Agent 写入的卡片必须能一眼分开:
1105
- // 前者的状态永远是 unreviewed,但「没审核」和「是抽出来的」是两件事。
1106
- const extracted = card.metadata?.extraction === 'pass0'
1349
+ // 编译期抽出来的卡片是**机器写的**,与 Agent 直接写入的必须能一眼分开:
1350
+ // 两者的状态都可能是 unreviewed,但「没人看过」和「是抽出来的」是两件事。
1351
+ const extracted = card.metadata?.extraction === 'pass0' || card.metadata?.extraction === 'model'
1352
+ // 异常点是这张卡片**已经被指出有问题**:它照样召回得到(评分被压低),
1353
+ // 但列表里必须显眼 —— 看不见的异常点等于没有异常点。
1354
+ const anomalies = (card.annotations ?? []).filter(item => item.kind === 'anomaly').length
1107
1355
  return <li key={card.entityId} className={styles.cardRow}>
1108
1356
  <button type="button" className={styles.cardRowMain} onClick={() => void openCard(card)}>
1109
- <div className={styles.cardRowHead}>
1357
+ {/* 一行一条:状态 + 标题 + 两枚状态徽标 +(右侧)类型与时间。
1358
+ 0042 撤掉了摘要行与「类型 · id · 出处」行 —— 三行内容让每一张卡片都
1359
+ 长成一个框,一屏三张卡就是三个框。扫一眼只需要认出「是哪一张」,
1360
+ 摘要、entity_id、出处数都进抽屉(title 里挂 id 与出处数)。 */}
1361
+ <span className={styles.cardRowHead}>
1110
1362
  <Badge tone={card.status === 'active' || card.status === 'reviewed' ? 'success' : card.status === 'problem' ? 'warning' : 'neutral'}>{card.status}</Badge>
1111
- <span className={styles.cardRowTitle} title={card.entityId}>{card.title}</span>
1112
- {extracted && <Badge tone="blue">候选</Badge>}
1363
+ <span className={styles.cardRowTitle} title={`${card.entityId} · ${card.sourceRefs.length} 个出处`}>{card.title}</span>
1364
+ {extracted && <Badge tone="blue">编译产出</Badge>}
1365
+ {card.review?.state === 'reviewed' && <Badge tone="success">已 review</Badge>}
1366
+ {anomalies > 0 && <Badge tone="warning">异常点 {anomalies}</Badge>}
1113
1367
  {issues.length > 0 && <Badge tone="warning">待补 {issues.length}</Badge>}
1114
- <span className={styles.kbTypeCount}>{formatTime(card.updatedAt)}</span>
1115
- </div>
1116
- <p className={styles.cardRowText}>{card.summary || card.content.replace(/!\[[^\]]*\]\([^)]*\)/g, ' ').replace(/\s+/g, ' ').slice(0, 160) || '(空卡片)'}</p>
1117
- <span className={styles.kbCode}>{card.entityType} · {card.entityId} · {card.sourceRefs.length} 个出处</span>
1368
+ <span className={styles.kbTypeCount}>{card.entityType} · {formatTime(card.updatedAt)}</span>
1369
+ </span>
1118
1370
  </button>
1119
1371
  </li>
1120
1372
  })}
1121
1373
  </ul>
1122
1374
  </Card>
1123
-
1124
- <Card className={styles.kbCard}>
1125
- <div className={styles.kbCardHead}>
1126
- <div><h3><Layers3 size={13} />编译 wiki 与 wikiNotes</h3></div>
1127
- <div className={styles.kbCardActions}>
1128
- <Button variant="outline" size="sm" onClick={() => void rebuild('wiki')} disabled={busy !== null}><RefreshCw size={13} />{busy === 'compile' ? '编译中…' : '增量编译'}</Button>
1129
- <Button variant="ghost" size="sm" onClick={() => void rebuild('all')} disabled={busy !== null}>全量重编</Button>
1130
- </div>
1131
- </div>
1132
- <p className={styles.kbHint}>
1133
- <b>增量</b>:只有正文 / 标题 / 状态 / 出处变过的卡片会重新切分成 wikiNotes,其余卡片的召回单元**逐字复用**(unitId 不变,向量也不重编码)。
1134
- 语料完全没变时这一次编译是**空操作**:不写产物、不升版本,因为升版本会让整份向量缓存作废。
1135
- </p>
1136
- <div className={styles.infoList}>
1137
- <div><span>wiki 卡片</span><strong>{active?.cardCount ?? 0}</strong></div>
1138
- <div><span>wikiNotes</span><strong>{active?.noteCount ?? 0}</strong></div>
1139
- <div><span>已纳入增量指纹</span><strong>{String(stats.trackedCards ?? '—')}</strong></div>
1140
- <div><span>卡片版本快照</span><strong>{String(stats.cardVersions ?? 0)}</strong></div>
1141
- <div><span>索引版本</span><strong>v{active?.buildVersion ?? 0}</strong></div>
1142
- <div><span>待补 schema 的卡片</span><strong>{pendingCards.length}</strong></div>
1143
- {/* Pass 0 的四个诚实计数:抽了几个候选、拒了几个、建了几张、并进几张。
1144
- 它们来自上一轮编译的 build-manifest,不是估算。 */}
1145
- <div><span>上一轮抽出候选</span><strong>{String(lastBuild.candidatesExtracted ?? '—')}</strong></div>
1146
- <div><span>新建 / 合并卡片</span><strong>{stats.lastBuild ? `${lastBuild.cardsCreated ?? 0} / ${lastBuild.cardsMerged ?? 0}` : '—'}</strong></div>
1147
- </div>
1148
- </Card>
1149
-
1150
- <WikiFlowCard />
1375
+ </TabsContent>
1376
+ <TabsContent value="flow"><WikiFlowCard /></TabsContent>
1377
+ </Tabs>
1151
1378
 
1152
1379
  {/* **Wiki 类型**(卡片 schema)也不在这一栏了(0034):它和来源类型是同一类东西
1153
1380
  —— 按库持久化的词汇表,一个决定「一份文件以什么业务身份进来」,一个决定「一张
@@ -1208,6 +1435,8 @@ export function KnowledgeBaseTab({ visible, onChanged, onActiveBaseChange, knowl
1208
1435
  <SettingsChip target="context" onOpen={onOpenSettings} />
1209
1436
  </> } : {})}
1210
1437
  resetKey={`${active?.id ?? ''}:${String(knowledgeValue?.recallTopK ?? '')}:${String(knowledgeValue?.recallMode ?? '')}:${String(knowledgeValue?.contextTopN ?? '')}`}
1438
+ /* 召回结果里的卡片点得开:抽屉是**宿主的**,面板只负责喊一声(它不认识抽屉)。 */
1439
+ onOpenWiki={card => { void openCard(card) }}
1211
1440
  defaults={{
1212
1441
  ...(typeof knowledgeValue?.recallMode === 'string' ? { mode: knowledgeValue.recallMode } : {}),
1213
1442
  ...(typeof knowledgeValue?.recallTopK === 'number' ? { topK: knowledgeValue.recallTopK } : {}),
@@ -1247,16 +1476,42 @@ export function KnowledgeBaseTab({ visible, onChanged, onActiveBaseChange, knowl
1247
1476
  />
1248
1477
  {/* 三个动作同形:图标 + 文字的 outline 按钮,同一个 30px 高。内核控制台是一个
1249
1478
  **链接**,所以它走 `Button asChild` 拿到同一套外观 —— 早先它是一枚裸的
1250
- `.tita-uiButton` 锚点,在这两枚按钮旁边读起来像第三种控件。 */}
1479
+ `.tita-uiButton` 锚点,在这两枚按钮旁边读起来像第三种控件。
1480
+ 0042:「编译」原本在 wiki 栏的统计卡里(增量 + 全量两枚),现在它是**工具栏的
1481
+ 一个动作**,两档收进同一枚按钮的下拉里 —— 编译是「对这个库做什么」,
1482
+ 它和「选哪个库」「类型定义」同层,不属于任何一栏的内容。 */}
1251
1483
  <span className={styles.kbToolbarActions}>
1252
1484
  <Button
1253
1485
  variant="outline"
1254
1486
  size="sm"
1255
1487
  disabled={!active}
1256
1488
  title={active ? `编辑 ${active.name} 的来源类型与 Wiki 类型` : '先选择一个知识库'}
1257
- onClick={() => setTypeDefOpen(true)}
1489
+ onClick={() => setTypeDef({ open: true, section: 'source' })}
1258
1490
  ><Tags size={13} />类型定义</Button>
1259
- <Button variant="outline" size="sm" onClick={() => void rebuild('wiki')} disabled={busy !== null || !active}><RefreshCw size={14} />{busy === 'compile' ? '编译中…' : '编译'}</Button>
1491
+ <DropdownMenu>
1492
+ <DropdownMenuTrigger asChild>
1493
+ <Button variant="outline" size="sm" disabled={busy !== null || !active}>
1494
+ <RefreshCw size={14} />{busy === 'compile' ? '编译中…' : '编译'}<ChevronDown size={12} />
1495
+ </Button>
1496
+ </DropdownMenuTrigger>
1497
+ <DropdownMenuContent align="end" className={styles.kbCompileMenu}>
1498
+ {/* 每一项是「名字 + 一行说明」两层,不是一句长句子。
1499
+ 共享组件的菜单默认把宽度锁成触发器的宽度(`编译 ⌄` 只有 90px 上下),
1500
+ 长句子进去就是一根 8 个字一行的竖条 —— 宽度由 CSS 纠正,句子由这里拆短。 */}
1501
+ <DropdownMenuItem onSelect={() => void rebuild('wiki')}>
1502
+ <span className={styles.kbCompileItem}>
1503
+ <b>增量编译</b>
1504
+ <small>只重切变过的卡片,其余 wikiNotes 逐字复用</small>
1505
+ </span>
1506
+ </DropdownMenuItem>
1507
+ <DropdownMenuItem onSelect={() => void rebuild('all')}>
1508
+ <span className={styles.kbCompileItem}>
1509
+ <b>全量重编</b>
1510
+ <small>全部重切,并把索引版本加一</small>
1511
+ </span>
1512
+ </DropdownMenuItem>
1513
+ </DropdownMenuContent>
1514
+ </DropdownMenu>
1260
1515
  <Button variant="outline" size="sm" asChild>
1261
1516
  <a href="/convert/" target="_blank" rel="noreferrer" title="打开解析内核控制台"><FileUp size={14} />解析内核</a>
1262
1517
  </Button>
@@ -1265,7 +1520,7 @@ export function KnowledgeBaseTab({ visible, onChanged, onActiveBaseChange, knowl
1265
1520
 
1266
1521
  {/* 分栏是**一条流水线**,所以切换器是步骤轨而不是一排等宽 tab:收录 → 编译 →
1267
1522
  关联 → 检索 → 精排。方向本身就是信息(写入侧在左、读取侧在右)。 */}
1268
- <KnowledgeStepRail tab={tab} onChange={setTab} facts={stepFacts} />
1523
+ <KnowledgeStepRail tab={tab} onChange={changeTab} facts={stepFacts} />
1269
1524
 
1270
1525
  {notice && <p className={cn(styles.kbNotice, notice.tone === 'error' && styles.kbNoticeError)} role="status">
1271
1526
  <AlertCircle size={13} /><span>{notice.text}</span>
@@ -1291,13 +1546,12 @@ export function KnowledgeBaseTab({ visible, onChanged, onActiveBaseChange, knowl
1291
1546
  {editOpen && <DialogContent>
1292
1547
  <DialogHeader><DialogTitle>{draft.id ? '编辑知识库' : '新建知识库'}</DialogTitle></DialogHeader>
1293
1548
  <div className={styles.kbForm}>
1294
- <label className={styles.kbField}><Label>名称</Label>
1549
+ <Field label="名称">
1295
1550
  <Input value={draft.name} onChange={(event: ChangeEvent<HTMLInputElement>) => setDraft({ ...draft, name: event.target.value })} placeholder="例如:产品文档" />
1296
- </label>
1297
- <label className={styles.kbField}><Label>描述</Label>
1551
+ </Field>
1552
+ <Field label="描述" hint="每个知识库有自己的根目录、来源分类与 Wiki 类型注册表;默认知识库沿用现有的知识库根目录。">
1298
1553
  <Input value={draft.description} onChange={(event: ChangeEvent<HTMLInputElement>) => setDraft({ ...draft, description: event.target.value })} placeholder="这个知识库放什么" />
1299
- </label>
1300
- <p className={styles.kbHint}>每个知识库有自己的根目录、来源分类与 Wiki 类型注册表;默认知识库沿用现有的知识库根目录。</p>
1554
+ </Field>
1301
1555
  </div>
1302
1556
  <DialogFooter>
1303
1557
  <Button variant="ghost" onClick={() => setEditOpen(false)}>取消</Button>
@@ -1310,13 +1564,13 @@ export function KnowledgeBaseTab({ visible, onChanged, onActiveBaseChange, knowl
1310
1564
  它**不是**设置抽屉里的第七块 —— 那两个注册表按库持久化,而设置是整个方案一份。
1311
1565
  弹窗状态在这个组件内部;写路径仍走上面那几个 `patch*` / `saveSourceType`。 */}
1312
1566
  {active && <TypeDefinitionDrawer
1313
- open={typeDefOpen}
1314
- onOpenChange={setTypeDefOpen}
1567
+ open={typeDef.open}
1568
+ onOpenChange={open => setTypeDef(current => ({ ...current, open }))}
1569
+ section={typeDef.section}
1315
1570
  kbId={active.id}
1316
1571
  kbName={active.name}
1317
1572
  sourceTypes={active.sourceTypes ?? []}
1318
1573
  wikiTypes={active.wikiTypes ?? []}
1319
- schemaMode={active.wikiSchemaMode ?? 'report'}
1320
1574
  customized={active.wikiTypesCustomized === true}
1321
1575
  catalog={catalog}
1322
1576
  busy={busy}
@@ -1324,8 +1578,6 @@ export function KnowledgeBaseTab({ visible, onChanged, onActiveBaseChange, knowl
1324
1578
  onToggleSourceType={toggleSourceType}
1325
1579
  onRemoveSourceType={removeSourceType}
1326
1580
  onApplyWikiTypes={patchWikiTypes}
1327
- onModeChange={patchWikiSchemaMode}
1328
- onResetWikiTypes={resetWikiTypes}
1329
1581
  />}
1330
1582
 
1331
1583
  <RestoreDialog
@@ -1355,34 +1607,70 @@ export function KnowledgeBaseTab({ visible, onChanged, onActiveBaseChange, knowl
1355
1607
  一个 state —— 「同时开两个」在这里变成不可表达的状态。 */}
1356
1608
  <Drawer open={detail !== null && cardDetail === null} onOpenChange={next => { if (!next) setDetail(null) }}>
1357
1609
  {detail && <DrawerContent className={styles.drawer} resizable resizeKey="tita-source-preview">
1610
+ {/* 抽屉头:标题 + 一行「它是什么」。
1611
+ 0042 把「新页面打开」搬进头里,是为了让它位置固定、不随正文是否被截断而出现;
1612
+ 0049 又把它挪到「内容」小节头那一行 —— 用户的原话是「放到内容旁边」。
1613
+ 它确实是「读这份内容的另一个去处」,与「内容」同处一行比挂在抽屉头更贴近它
1614
+ 服务的那件事;而显隐仍然固定(不依赖正文长度),0042 那条约束没有被破坏。 */}
1358
1615
  <DrawerHeader>
1359
- <DrawerTitle>{detail.document.title}</DrawerTitle>
1360
- <DrawerDescription>{detail.document.relativePath}</DrawerDescription>
1616
+ <div className={styles.drawerHead}>
1617
+ <div className={styles.drawerHeadCopy}>
1618
+ <DrawerTitle>{detail.document.title}</DrawerTitle>
1619
+ <DrawerDescription>{detail.document.relativePath}</DrawerDescription>
1620
+ </div>
1621
+ </div>
1361
1622
  </DrawerHeader>
1362
1623
  <DrawerBody className={styles.drawerStack}>
1363
1624
  {/* 0035:两种预览是**同一套分块**(元信息 → 内容 → 版本),SourceRaw 只多一块
1364
- 「编译产物」。顺序不是随手排的:先确认「这是什么」(元信息,含 hash 与分类),
1625
+ 「编译产物」。顺序不是随手排的:先确认「这是什么」(元信息,含分类与来源),
1365
1626
  再读「它写了什么」(内容),再看「它编译出了什么」(派生结论),最后才是
1366
1627
  「改过几次」(版本)。0030 那条「原文在编译产物之前」在这里仍然成立。
1367
- 时间一律用标准格式(`formatDateTime`)—— 抽屉里是逐字核对事实的地方。 */}
1628
+ 时间一律用标准格式(`formatDateTime`)—— 抽屉里是逐字核对事实的地方。
1629
+
1630
+ 0049 按 **front matter 的键**重排了这一块,并撤掉纯技术项:
1631
+ - 行与键对得上(分类 = `type`,来源链接 = `resource`,作者 = `sources[0].author`,
1632
+ 采集时间 = `generated.at`…),人读界面与读文件是同一件事;
1633
+ - `external_id`(业务系统里的 id)、`content_hash`(内容指纹)、`解析器`
1634
+ (`anydoc@0.2.4/pptx` 这种版本串)都不占一行 —— 它们是排查用的,
1635
+ 不是「这份东西是什么」。前两项在文件里、在版本列表里都查得到;
1636
+ 解析器与生成者收进「文件格式」那一行的悬停说明。 */}
1368
1637
  <PreviewMeta rows={[
1369
- { label: '类型', value: `${detail.document.ext} · ${detail.document.sourceFormat || 'unknown'}` },
1370
- { label: '解析器', value: detail.document.parser || '—' },
1371
1638
  { label: '来源分类', value: `${sourceTypeLabel(detail.document.sourceType)} · ${detail.document.sourceType}` },
1372
- { label: 'external_id', value: detail.document.externalId || '—', code: true },
1373
- { label: 'content_hash', value: detail.document.contentHash || '—', code: true },
1639
+ {
1640
+ label: '文件格式',
1641
+ value: `${sourceFormatLabel(detail.document.sourceFormat)} · ${detail.document.ext}`,
1642
+ title: [
1643
+ detail.document.parser ? `解析器 ${detail.document.parser}` : '',
1644
+ generatorOf(detail.document.frontmatter),
1645
+ ].filter(Boolean).join(' · '),
1646
+ },
1647
+ // 0048:OKF 的那几个顶层键(链接、作者、发布时间、采集时间)就排在分类下面
1648
+ // —— 它们是「这份东西是什么」的一部分,不该埋进 metadata 里让人去翻 JSON。
1649
+ ...sourceProvenanceRows(detail.document.frontmatter),
1374
1650
  { label: '大小', value: formatBytes(detail.document.bytes) },
1375
1651
  { label: '创建', value: formatDateTime(detail.document.createdAt) },
1376
1652
  { label: '更新', value: formatDateTime(detail.document.updatedAt) },
1653
+ // 分类声明的元数据字段:有的显示值,缺的显示「缺」——「缺」本身就是信息,
1654
+ // 而且它指得出该去补什么。
1655
+ ...declaredMetadataRows(active?.sourceTypes.find(def => def.id === detail.document.sourceType)?.metadataFields, detail.document.frontmatter),
1377
1656
  ]} />
1378
1657
 
1379
- <PreviewContent content={detail.document.content} assets={detail.document.assets} />
1658
+ <PreviewContent
1659
+ content={detail.document.content}
1660
+ assets={detail.document.assets}
1661
+ totalChars={detail.document.contentChars}
1662
+ action={<Button
1663
+ variant="outline" size="sm" className={styles.drawerHeadAction}
1664
+ title="在一个独立的整页里读完整原文(新标签页,可分页搜索)"
1665
+ onClick={() => openFullDocument(detail.document)}
1666
+ ><ExternalLink size={12} />新页面打开</Button>}
1667
+ />
1380
1668
 
1381
1669
  <div className={styles.drawerSection}>
1382
1670
  {/* WeKnora 的详情页在文件下面列分块;这里列的是这个 SourceRaw 编译出的 wiki 卡片。 */}
1383
1671
  <div className={styles.kbSubHead}><h4><Layers3 size={13} />编译产物</h4><span>{detailCards.length} 张卡片</span></div>
1384
1672
  {detailCards.length === 0
1385
- ? <p className={styles.kbHint}>还没有从这个文件编译出 wiki 卡片。先「编译」,或在概览里让 Agent 用 <b>write_knowledge_card</b> 引用它。</p>
1673
+ ? <p className={styles.kbHint}>还没有从这个文件编译出 wiki 卡片。先「编译」—— 编译器会按 wiki 类型的 schema 抽出实体并直接入库。</p>
1386
1674
  : <ul className={styles.cardList}>
1387
1675
  {detailCards.map(card => <li key={card.entityId} className={styles.cardRow}>
1388
1676
  <button type="button" className={styles.cardRowMain} onClick={() => void openCard(card)}>
@@ -1427,15 +1715,27 @@ export function KnowledgeBaseTab({ visible, onChanged, onActiveBaseChange, knowl
1427
1715
  { label: '出处', value: cardDetail.card.sourceRefs.join(', ') || '—', code: true },
1428
1716
  ]} />
1429
1717
 
1430
- {/* schema 待补紧跟在元信息后面:它是对「状态」那一行的展开,不是正文的一部分。 */}
1431
- {schemaIssuesOf(cardDetail.card).length > 0 && <details className={styles.kbPrompt} open>
1432
- <summary><ShieldCheck size={12} /> schema 待补 {schemaIssuesOf(cardDetail.card).length} 项(report 模式:只标记不拦截)</summary>
1433
- <ul className={styles.kbPromptSteps}>
1434
- {schemaIssuesOf(cardDetail.card).map((issue, index) => <li key={`${issue.message ?? index}`}><code>{issue.level ?? 'warning'}</code> {issue.message}</li>)}
1435
- </ul>
1436
- </details>}
1718
+ {/* review 与备注排在元信息之后、正文之前(0044)。
1719
+ 位置的理由:它是**人对这张卡做的判断**,与元信息同属「这张卡是什么」,
1720
+ 而正文是要读的东西,可能很长。放在正文下面,人得先滚过全文才够得着按钮;
1721
+ 放在正文上面,点开就能看见它现在是什么状态、别人说过什么。 */}
1722
+ <WikiCardReview
1723
+ kbId={activeId}
1724
+ card={cardDetail.card}
1725
+ onChanged={() => void refreshCardAfterReview()}
1726
+ /* 从卡片进来是奔着 Wiki 类型去的:让抽屉直接落在那一节,不用人再滚一遍。 */
1727
+ onOpenWikiTypes={() => { setCardDetail(null); setTypeDef({ open: true, section: 'wiki' }) }}
1728
+ />
1729
+
1730
+ <PreviewContent content={cardDetail.card.content} assets={cardDetail.assets} />
1437
1731
 
1438
- <PreviewContent content={cardDetail.card.content} />
1732
+ {/* 相关卡片排在正文之后(0049):读完这张卡片,接着看它跟谁有关。放在正文
1733
+ 之前会挡住「这张卡片到底说了什么」,而相关是读完之后才想做的事。 */}
1734
+ <PreviewRelated
1735
+ outgoing={cardDetail.related?.outgoing ?? []}
1736
+ incoming={cardDetail.related?.incoming ?? []}
1737
+ onOpen={link => void openCard(link)}
1738
+ />
1439
1739
 
1440
1740
  <PreviewVersions
1441
1741
  hint="卡片正文每次变化前都会留一份快照;回滚是往前的写入,所以回滚前的那一版也会被快照 —— 回滚本身也可以回滚。"