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
@@ -35,21 +35,25 @@
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,
38
+ AlertCircle, Check, ChevronDown, Eye, ExternalLink, FileText, FileUp, FolderOpen,
39
39
  Layers3, RefreshCw, Search, ShieldCheck, 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
55
  import { PreviewContent, PreviewMeta, PreviewVersions } from './PreviewSections.tsx'
52
- import type { KnowledgeTab } from './architecture.tsx'
56
+ import { KNOWLEDGE_TABS, type KnowledgeTab } from './architecture.tsx'
53
57
  import { KnowledgeStepRail } from './KnowledgeStepRail.tsx'
54
58
  import { NodeSettingsPanel, SettingsChip, useScope, type SettingsNode, type SettingsScopeLike } from './NodeSettings.tsx'
55
59
  import { RecallPanel } from './RecallPanel.tsx'
@@ -58,6 +62,7 @@ import type { GraphEdgeView, GraphNodeView } from './graph/layout.ts'
58
62
  import type { WikiTypeView } from './WikiTypeEditor.tsx'
59
63
  import { WikiFlowCard } from './WikiFlowCard.tsx'
60
64
  import { TypeDefinitionDrawer } from './TypeDefinitionDrawer.tsx'
65
+ import { ProposalReview } from './ProposalReview.tsx'
61
66
 
62
67
  // ------------------------------------------------------------------ contract
63
68
  // Mirrors the plugin HTTP contract (see plugins/dsh-data-domain-knowledge/README.md).
@@ -165,6 +170,13 @@ export interface KnowledgeDocument {
165
170
 
166
171
  export interface KnowledgeDocumentDetail extends KnowledgeDocument {
167
172
  content: string
173
+ /**
174
+ * 正文总字符数。`content` 可能只是前一段 —— 明细接口按 `SOURCE_PREVIEW_CHARS`
175
+ * 封顶(4000),所以**不能**再用 `content.length` 当「全文有多少」。
176
+ */
177
+ contentChars: number
178
+ /** `content` 是不是被截断过。为真时给「新页面打开」。 */
179
+ contentTruncated: boolean
168
180
  frontmatter: Record<string, unknown>
169
181
  assets: Array<{ rel: string; bytes: number; url: string }>
170
182
  lineage?: Record<string, string>
@@ -205,25 +217,7 @@ export interface WikiCardVersionView {
205
217
  isCurrent: boolean
206
218
  }
207
219
 
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
- }
220
+ import { requestJson, type Envelope } from './request.ts'
227
221
 
228
222
  /** Text-ish formats the browser can read and POST as SourceRaw directly. */
229
223
  const DIRECT_READ_EXT = new Set(['.md', '.markdown', '.txt', '.json', '.tsv', '.csv', '.html'])
@@ -275,7 +269,22 @@ export function KnowledgeBaseTab({ visible, onChanged, onActiveBaseChange, knowl
275
269
  const [loading, setLoading] = useState(false)
276
270
  const [busy, setBusy] = useState<string | null>(null)
277
271
  const [notice, setNotice] = useState<{ tone: 'success' | 'error'; text: string } | null>(null)
278
- const [tab, setTab] = useState<KnowledgeTab>('source-raw')
272
+ /**
273
+ * 分栏与选中的知识库。**起始值来自记住的位置**(`panel-state.ts`):面板会被 DSH
274
+ * 重新挂载(刷新页面必然),只放在 `useState` 里的话,用户每次回来都落回
275
+ * 「SourceRaw + 默认知识库」。分栏存的是上一版的 id 时先对着 `KNOWLEDGE_TABS` 校验;
276
+ * 知识库 id 是服务端数据、没有注册表可校验,所以由 `loadBases` 用它自己的
277
+ * 「不存在就退回默认库」那条规则兜底。
278
+ */
279
+ const [tab, setTab] = useState<KnowledgeTab>(() => readRemembered('knowledgeTab', KNOWLEDGE_TABS.map(item => item.id)) ?? 'source-raw')
280
+ /**
281
+ * wiki 栏内部的二级 tab(0042):卡片 / 抽取流程。
282
+ *
283
+ * **不记进 `panel-state.ts`**:那里记的是「上次看到哪儿」的三个大位置(一级 tab、
284
+ * 分栏、知识库),而这是某一栏内部的两种读法 —— 面板重新挂载时落回「卡片」
285
+ * (这一栏的第一件事)是正确的默认,而且不必为一个二级 tab 去改那个模块的 key 表。
286
+ */
287
+ const [wikiTab, setWikiTab] = useState<'cards' | 'flow'>('cards')
279
288
  const [editOpen, setEditOpen] = useState(false)
280
289
  const [draft, setDraft] = useState({ id: '', name: '', description: '' })
281
290
  /**
@@ -284,6 +293,15 @@ export function KnowledgeBaseTab({ visible, onChanged, onActiveBaseChange, knowl
284
293
  * 拉取,因为停用的分类要在提示词里标记出来。
285
294
  */
286
295
  const [catalog, setCatalog] = useState<SourceTypeCatalog | null>(null)
296
+
297
+ /**
298
+ * 换分栏的**唯一入口**:顺手记一笔,下次挂载落回同一栏。两个调用点(步骤轨点击、
299
+ * 从架构图节点跳过来)都走它,所以不会出现「这个入口记得、那个入口不记得」。
300
+ */
301
+ const changeTab = useCallback((next: KnowledgeTab): void => {
302
+ setTab(next)
303
+ remember('knowledgeTab', next)
304
+ }, [])
287
305
  /**
288
306
  * 「类型定义」抽屉(0034):本库的词汇表 —— 来源分类 + Wiki 卡片 schema。
289
307
  * 弹窗草稿在抽屉内部,这里只留开关与写路径。
@@ -349,7 +367,7 @@ export function KnowledgeBaseTab({ visible, onChanged, onActiveBaseChange, knowl
349
367
 
350
368
  /** 架构图节点跳转:落到对应分栏。 */
351
369
  useEffect(() => {
352
- if (visible && focus) setTab(focus.tab)
370
+ if (visible && focus) changeTab(focus.tab)
353
371
  }, [visible, focus?.tab, focus?.nonce]) // eslint-disable-line react-hooks/exhaustive-deps
354
372
 
355
373
  /** `sourceId → wiki cards`, so a file row can say how many slices it produced. */
@@ -369,13 +387,16 @@ export function KnowledgeBaseTab({ visible, onChanged, onActiveBaseChange, knowl
369
387
  const loadBases = useCallback(async (preferred?: string): Promise<{ next: KnowledgeBase[]; resolved: string }> => {
370
388
  const payload = await requestJson<{ bases: KnowledgeBase[]; defaultBaseId: string }>('/knowledge/bases')
371
389
  const next = payload.data?.bases ?? []
372
- const requested = preferred ?? activeIdRef.current
390
+ // 优先级:显式指定(新建 / 编辑后回到那一个)→ 本次挂载里已经选中的 → **记住的那个**。
391
+ // 最后一档就是「刷新之后还在原来那个库」,而它不合法时下面那句会退回默认库。
392
+ const requested = preferred ?? (activeIdRef.current !== '' ? activeIdRef.current : readRememberedId('knowledgeBase'))
373
393
  const resolved = next.some(base => base.id === requested)
374
394
  ? requested
375
395
  : payload.data?.defaultBaseId ?? next[0]?.id ?? ''
376
396
  setBases(next)
377
397
  setActiveId(resolved)
378
398
  activeIdRef.current = resolved
399
+ remember('knowledgeBase', resolved)
379
400
  return { next, resolved }
380
401
  }, [])
381
402
 
@@ -738,11 +759,18 @@ export function KnowledgeBaseTab({ visible, onChanged, onActiveBaseChange, knowl
738
759
  // 的**原文状态重新满足 `open`,于是面板「自己又弹回来」——互斥必须两边都成立。
739
760
  setCardDetail(null)
740
761
  setDetailLoading(true)
741
- setDetail({ document: { ...document, content: '', frontmatter: {}, assets: [] }, versions: [] })
762
+ setDetail({ document: { ...document, content: '', contentChars: 0, contentTruncated: false, frontmatter: {}, assets: [] }, versions: [] })
742
763
  try {
743
764
  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}`)
765
+ /**
766
+ * 两条请求**并行**。原来是串行的(先详情、再版本),于是一次打开弹层的等待
767
+ * 是两段的和 —— 而它们之间没有任何依赖。详情返回之后弹层就能读,
768
+ * 版本时间线自己在 `detailLoading` 里补。
769
+ */
770
+ const [docPayload, versionPayload] = await Promise.all([
771
+ requestJson<{ document: KnowledgeDocumentDetail }>(`/knowledge/documents/${encodeURIComponent(document.docId)}${q}`),
772
+ requestJson<{ versions: DocumentVersion[] }>(`/knowledge/documents/${encodeURIComponent(document.docId)}/versions${q}`),
773
+ ])
746
774
  if (docPayload.data?.document) {
747
775
  setDetail({ document: docPayload.data.document, versions: versionPayload.data?.versions ?? [] })
748
776
  }
@@ -917,15 +945,31 @@ export function KnowledgeBaseTab({ visible, onChanged, onActiveBaseChange, knowl
917
945
  .map(([ext, count]) => ({ value: ext, label: `${ext}(${count})` }))
918
946
  }, [documents])
919
947
  /**
920
- * `/knowledge/stats` 在这里是 `Record<string, unknown>`(它同时承载编译设置快照),
921
- * 所以 `lastBuild` 要显式收窄一次再读 —— 否则每个字段都得写一遍类型断言。
948
+ * 一次性迁移里没有静默处理掉的事(来源分类改名冲突、卡片改名迁移失败)。
949
+ *
950
+ * 它必须显示出来:**迁移失败而没人知道,等价于数据已经丢了**。放在卡片列表上方,
951
+ * 因为它比「库里有什么」更紧急。
922
952
  */
923
- const lastBuild = (stats.lastBuild ?? {}) as Record<string, unknown>
953
+ const migrationProblems = Array.isArray(stats.migrationProblems) ? stats.migrationProblems as string[] : []
924
954
  const currentVersionId = detail?.versions.find(version => version.isCurrent)?.versionId
925
955
  const currentCardVersionId = cardDetail?.versions.find(version => version.isCurrent)?.versionId
926
956
  const detailCards = detail ? cardsBySource.get(detail.document.docId) ?? [] : []
927
957
  const pendingCards = cards.filter(card => schemaIssuesOf(card).length > 0)
928
958
 
959
+ /**
960
+ * 在独立整页里读这份原文。
961
+ *
962
+ * 抽屉宽 380px,而原文可能是一份几十万字的转换文档 —— 那里只显示前 4000 字符
963
+ * (`SOURCE_PREVIEW_CHARS`),剩下的出口是这个整页:新标签、可整页搜索、自带分页,
964
+ * 一次只取一段。
965
+ */
966
+ const openFullDocument = useCallback((document: KnowledgeDocument): void => {
967
+ const kb = activeIdRef.current
968
+ if (!kb) return
969
+ const url = `/knowledge/documents/${encodeURIComponent(document.docId)}/view?kb=${encodeURIComponent(kb)}`
970
+ window.open(url, '_blank', 'noopener,noreferrer')
971
+ }, [])
972
+
929
973
  /**
930
974
  * 文件类型筛在客户端做(接口没有这个过滤口),其余两个筛选在服务端。
931
975
  * 列表与计数都读这一份派生值,所以「筛出 3 个」与表头的数字不可能对不上。
@@ -949,14 +993,21 @@ export function KnowledgeBaseTab({ visible, onChanged, onActiveBaseChange, knowl
949
993
  /**
950
994
  * 步骤轨上每一步的真实事实。计数来自落盘与设置:`active` 的计数是服务端算的,
951
995
  * 图谱计数优先读 `/knowledge/stats`(它统计的是全量),拿不到才退回这一页拉到的前 50 条。
996
+ *
997
+ * 0042:wiki 这一步的 note 补上 wikiNotes 数、索引版本、增量指纹与卡片快照 ——
998
+ * 它们原来在「编译 wiki 与 wikiNotes」那张卡里,卡删掉之后这几条数得有地方去。
999
+ * 顺序按重要性排:窄栏下 note 会省略号截断,先看到的必须是更该看到的。
952
1000
  */
953
1001
  const stepFacts = useMemo(() => ({
954
1002
  'source-raw': { value: active ? `${active.sourceCount} 个文件` : '—', note: '按格式路由解析,同 external_id 幂等' },
955
- wiki: { value: active ? `${active.cardCount} 张卡片` : '—', note: '卡片 → wikiNotes,未变的逐字复用' },
1003
+ wiki: {
1004
+ value: active ? `${active.cardCount} 张卡片` : '—',
1005
+ note: `${active?.noteCount ?? 0} 条 wikiNotes · 索引 v${active?.buildVersion ?? 0} · 指纹 ${String(stats.trackedCards ?? '—')} · 快照 ${String(stats.cardVersions ?? 0)}`,
1006
+ },
956
1007
  graph: { value: `${stats.graphNodes ?? graph.nodes.length} 节点 / ${stats.graphEdges ?? graph.edges.length} 边`, note: 'supported_by 溯源边 + relates_to 关系边' },
957
1008
  recall: { value: `topK ${String(knowledgeValue?.recallTopK ?? 10)}`, note: `${String(knowledgeValue?.recallMode ?? 'hybrid')} 三路融合` },
958
1009
  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])
1010
+ }), [active, stats.graphNodes, stats.graphEdges, stats.trackedCards, stats.cardVersions, graph.nodes.length, graph.edges.length, knowledgeValue?.recallTopK, knowledgeValue?.recallMode, knowledgeValue?.contextTopN])
960
1011
 
961
1012
  // --------------------------------------------------------------- 分栏内容
962
1013
 
@@ -1056,9 +1107,47 @@ export function KnowledgeBaseTab({ visible, onChanged, onActiveBaseChange, knowl
1056
1107
  这里不再留任何指路文字:工具栏与卡片头各自带着入口,路牌只是搬迁留下的债。 */}
1057
1108
  </div>
1058
1109
 
1059
- /** Wiki:卡片列表(一等内容)+ 编译 + 抽取流程 + schema 注册表。 */
1110
+ /**
1111
+ * Wiki:**两种内容,两枚 tab**(0042)。
1112
+ *
1113
+ * 0042 之前这里是三块竖排:卡片列表 → 「编译 wiki 与 wikiNotes」→ 抽取流程。
1114
+ * 中间那一块同时讲动作(增量 / 全量按钮)与结果(8 行计数)—— 动作是「对这个库
1115
+ * 做什么」,归工具栏;结果是「这一段现在有多少东西」,归步骤轨。把它留在这里,
1116
+ * 等于让一栏内容替工具栏收着一枚按钮,还占掉一屏。
1117
+ *
1118
+ * **待办提议不进 tab**:它是这一屏唯一需要人动手的东西(0021 §6.1),藏进 tab
1119
+ * 等于没人看。它继续待在 tab 之上,没有待办时整块不渲染(现状如此)。
1120
+ */
1060
1121
  const renderWiki = (): ReactNode => <div className={styles.viewStack}>
1061
- {/* 卡片列表排在第一位,和 SourceRaw 的文件列表同一个位置、同一套筛选行 ——
1122
+ {/* 迁移里没静默处理掉的事排在最前面:**迁移失败而没人知道,等价于数据已经丢了**。
1123
+ 它比「库里有什么」更紧急,所以不藏在统计里、也不只进日志。 */}
1124
+ {migrationProblems.length > 0 && <p className={cn(styles.kbNotice, styles.kbNoticeError)} role="alert">
1125
+ <AlertCircle size={13} />
1126
+ <span>
1127
+ <b>有 {migrationProblems.length} 处改名迁移没有生效</b>,旧文件都留在原地(没有覆盖、没有删除):
1128
+ {migrationProblems.join(';')}
1129
+ </span>
1130
+ </p>}
1131
+ {/* **待办排在卡片列表之前。** 它是这一屏唯一需要人动手的东西(0021 §6.1):
1132
+ 未确认的提议不落 `02_wiki/`,所以「库里有哪些卡片」和「有哪些卡片在等人点头」
1133
+ 是两件事,而后者更紧急。没有待办时整块不渲染 —— 不留一句「暂无待办」的废话。 */}
1134
+ {active && <ProposalReview
1135
+ kbId={active.id}
1136
+ onApplied={() => void loadCards(active.id)}
1137
+ onOpenWikiTypes={() => setTypeDefOpen(true)}
1138
+ />}
1139
+ {/* Pass 0 的候选 / 提议计数(`candidatesExtracted` / `proposalsCreated` / `proposalsMerged`)
1140
+ 曾经显示在这里。0042 撤掉了:这三个数**没有指向任何可动手的东西** ——
1141
+ 「上一轮抽出 0 个候选」既不是待办,也不是这一栏的内容,它只解释「为什么没有卡片」,
1142
+ 而那句解释已经写在下面卡片列表的空态里(「先『编译』…」)。数字本身还在
1143
+ build-manifest 里,排查编译问题时从产物读,不占这一屏。 */}
1144
+ <Tabs className={styles.kbSubTabs} value={wikiTab} onValueChange={next => setWikiTab(next as 'cards' | 'flow')}>
1145
+ <TabsList className={cn(styles.tabsList, styles.kbSubTabsList)}>
1146
+ <TabsTrigger value="cards"><FileText size={12} />卡片</TabsTrigger>
1147
+ <TabsTrigger value="flow"><Layers3 size={12} />抽取流程</TabsTrigger>
1148
+ </TabsList>
1149
+ <TabsContent value="cards">
1150
+ {/* 卡片列表和 SourceRaw 的文件列表同一个位置、同一套筛选行 ——
1062
1151
  两栏的「这一层有什么」都应该是打开就能看到的第一件东西。0027 之前这里
1063
1152
  先是一张编译统计卡,卡片列表在第三位,得先读过参数才看得到内容。 */}
1064
1153
  <Card className={styles.kbCard}>
@@ -1106,48 +1195,25 @@ export function KnowledgeBaseTab({ visible, onChanged, onActiveBaseChange, knowl
1106
1195
  const extracted = card.metadata?.extraction === 'pass0'
1107
1196
  return <li key={card.entityId} className={styles.cardRow}>
1108
1197
  <button type="button" className={styles.cardRowMain} onClick={() => void openCard(card)}>
1109
- <div className={styles.cardRowHead}>
1198
+ {/* 一行一条:状态 + 标题 + 两枚状态徽标 +(右侧)类型与时间。
1199
+ 0042 撤掉了摘要行与「类型 · id · 出处」行 —— 三行内容让每一张卡片都
1200
+ 长成一个框,一屏三张卡就是三个框。扫一眼只需要认出「是哪一张」,
1201
+ 摘要、entity_id、出处数都进抽屉(title 里挂 id 与出处数)。 */}
1202
+ <span className={styles.cardRowHead}>
1110
1203
  <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>
1204
+ <span className={styles.cardRowTitle} title={`${card.entityId} · ${card.sourceRefs.length} 个出处`}>{card.title}</span>
1112
1205
  {extracted && <Badge tone="blue">候选</Badge>}
1113
1206
  {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>
1207
+ <span className={styles.kbTypeCount}>{card.entityType} · {formatTime(card.updatedAt)}</span>
1208
+ </span>
1118
1209
  </button>
1119
1210
  </li>
1120
1211
  })}
1121
1212
  </ul>
1122
1213
  </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 />
1214
+ </TabsContent>
1215
+ <TabsContent value="flow"><WikiFlowCard /></TabsContent>
1216
+ </Tabs>
1151
1217
 
1152
1218
  {/* **Wiki 类型**(卡片 schema)也不在这一栏了(0034):它和来源类型是同一类东西
1153
1219
  —— 按库持久化的词汇表,一个决定「一份文件以什么业务身份进来」,一个决定「一张
@@ -1247,7 +1313,10 @@ export function KnowledgeBaseTab({ visible, onChanged, onActiveBaseChange, knowl
1247
1313
  />
1248
1314
  {/* 三个动作同形:图标 + 文字的 outline 按钮,同一个 30px 高。内核控制台是一个
1249
1315
  **链接**,所以它走 `Button asChild` 拿到同一套外观 —— 早先它是一枚裸的
1250
- `.tita-uiButton` 锚点,在这两枚按钮旁边读起来像第三种控件。 */}
1316
+ `.tita-uiButton` 锚点,在这两枚按钮旁边读起来像第三种控件。
1317
+ 0042:「编译」原本在 wiki 栏的统计卡里(增量 + 全量两枚),现在它是**工具栏的
1318
+ 一个动作**,两档收进同一枚按钮的下拉里 —— 编译是「对这个库做什么」,
1319
+ 它和「选哪个库」「类型定义」同层,不属于任何一栏的内容。 */}
1251
1320
  <span className={styles.kbToolbarActions}>
1252
1321
  <Button
1253
1322
  variant="outline"
@@ -1256,7 +1325,30 @@ export function KnowledgeBaseTab({ visible, onChanged, onActiveBaseChange, knowl
1256
1325
  title={active ? `编辑 ${active.name} 的来源类型与 Wiki 类型` : '先选择一个知识库'}
1257
1326
  onClick={() => setTypeDefOpen(true)}
1258
1327
  ><Tags size={13} />类型定义</Button>
1259
- <Button variant="outline" size="sm" onClick={() => void rebuild('wiki')} disabled={busy !== null || !active}><RefreshCw size={14} />{busy === 'compile' ? '编译中…' : '编译'}</Button>
1328
+ <DropdownMenu>
1329
+ <DropdownMenuTrigger asChild>
1330
+ <Button variant="outline" size="sm" disabled={busy !== null || !active}>
1331
+ <RefreshCw size={14} />{busy === 'compile' ? '编译中…' : '编译'}<ChevronDown size={12} />
1332
+ </Button>
1333
+ </DropdownMenuTrigger>
1334
+ <DropdownMenuContent align="end" className={styles.kbCompileMenu}>
1335
+ {/* 每一项是「名字 + 一行说明」两层,不是一句长句子。
1336
+ 共享组件的菜单默认把宽度锁成触发器的宽度(`编译 ⌄` 只有 90px 上下),
1337
+ 长句子进去就是一根 8 个字一行的竖条 —— 宽度由 CSS 纠正,句子由这里拆短。 */}
1338
+ <DropdownMenuItem onSelect={() => void rebuild('wiki')}>
1339
+ <span className={styles.kbCompileItem}>
1340
+ <b>增量编译</b>
1341
+ <small>只重切变过的卡片,其余 wikiNotes 逐字复用</small>
1342
+ </span>
1343
+ </DropdownMenuItem>
1344
+ <DropdownMenuItem onSelect={() => void rebuild('all')}>
1345
+ <span className={styles.kbCompileItem}>
1346
+ <b>全量重编</b>
1347
+ <small>全部重切,并把索引版本加一</small>
1348
+ </span>
1349
+ </DropdownMenuItem>
1350
+ </DropdownMenuContent>
1351
+ </DropdownMenu>
1260
1352
  <Button variant="outline" size="sm" asChild>
1261
1353
  <a href="/convert/" target="_blank" rel="noreferrer" title="打开解析内核控制台"><FileUp size={14} />解析内核</a>
1262
1354
  </Button>
@@ -1265,7 +1357,7 @@ export function KnowledgeBaseTab({ visible, onChanged, onActiveBaseChange, knowl
1265
1357
 
1266
1358
  {/* 分栏是**一条流水线**,所以切换器是步骤轨而不是一排等宽 tab:收录 → 编译 →
1267
1359
  关联 → 检索 → 精排。方向本身就是信息(写入侧在左、读取侧在右)。 */}
1268
- <KnowledgeStepRail tab={tab} onChange={setTab} facts={stepFacts} />
1360
+ <KnowledgeStepRail tab={tab} onChange={changeTab} facts={stepFacts} />
1269
1361
 
1270
1362
  {notice && <p className={cn(styles.kbNotice, notice.tone === 'error' && styles.kbNoticeError)} role="status">
1271
1363
  <AlertCircle size={13} /><span>{notice.text}</span>
@@ -1291,13 +1383,12 @@ export function KnowledgeBaseTab({ visible, onChanged, onActiveBaseChange, knowl
1291
1383
  {editOpen && <DialogContent>
1292
1384
  <DialogHeader><DialogTitle>{draft.id ? '编辑知识库' : '新建知识库'}</DialogTitle></DialogHeader>
1293
1385
  <div className={styles.kbForm}>
1294
- <label className={styles.kbField}><Label>名称</Label>
1386
+ <Field label="名称">
1295
1387
  <Input value={draft.name} onChange={(event: ChangeEvent<HTMLInputElement>) => setDraft({ ...draft, name: event.target.value })} placeholder="例如:产品文档" />
1296
- </label>
1297
- <label className={styles.kbField}><Label>描述</Label>
1388
+ </Field>
1389
+ <Field label="描述" hint="每个知识库有自己的根目录、来源分类与 Wiki 类型注册表;默认知识库沿用现有的知识库根目录。">
1298
1390
  <Input value={draft.description} onChange={(event: ChangeEvent<HTMLInputElement>) => setDraft({ ...draft, description: event.target.value })} placeholder="这个知识库放什么" />
1299
- </label>
1300
- <p className={styles.kbHint}>每个知识库有自己的根目录、来源分类与 Wiki 类型注册表;默认知识库沿用现有的知识库根目录。</p>
1391
+ </Field>
1301
1392
  </div>
1302
1393
  <DialogFooter>
1303
1394
  <Button variant="ghost" onClick={() => setEditOpen(false)}>取消</Button>
@@ -1355,9 +1446,24 @@ export function KnowledgeBaseTab({ visible, onChanged, onActiveBaseChange, knowl
1355
1446
  一个 state —— 「同时开两个」在这里变成不可表达的状态。 */}
1356
1447
  <Drawer open={detail !== null && cardDetail === null} onOpenChange={next => { if (!next) setDetail(null) }}>
1357
1448
  {detail && <DrawerContent className={styles.drawer} resizable resizeKey="tita-source-preview">
1449
+ {/* 抽屉头:标题 + 一行「它是什么」+ **全文出口**。
1450
+ 0042 把「新页面打开」从正文块里搬到这一行,两个理由:
1451
+ 1. 它是「这份东西还能去哪看」,位置要固定 —— 头不参与滚动,正文块滚多久它都在;
1452
+ 2. 它原来**只在正文被截断时**才渲染,于是短文档上根本不存在(用户报告的原话是
1453
+ 「那个按钮我也没看到」)。入口的显隐不能依赖内容长度。
1454
+ 头部这一行只放**一种**动作,所以不与正文块里的读法切换抢位置。 */}
1358
1455
  <DrawerHeader>
1359
- <DrawerTitle>{detail.document.title}</DrawerTitle>
1360
- <DrawerDescription>{detail.document.relativePath}</DrawerDescription>
1456
+ <div className={styles.drawerHead}>
1457
+ <div className={styles.drawerHeadCopy}>
1458
+ <DrawerTitle>{detail.document.title}</DrawerTitle>
1459
+ <DrawerDescription>{detail.document.relativePath}</DrawerDescription>
1460
+ </div>
1461
+ <Button
1462
+ variant="outline" size="sm" className={styles.drawerHeadAction}
1463
+ title="在一个独立的整页里读完整原文(新标签页,可分页搜索)"
1464
+ onClick={() => openFullDocument(detail.document)}
1465
+ ><ExternalLink size={12} />新页面打开</Button>
1466
+ </div>
1361
1467
  </DrawerHeader>
1362
1468
  <DrawerBody className={styles.drawerStack}>
1363
1469
  {/* 0035:两种预览是**同一套分块**(元信息 → 内容 → 版本),SourceRaw 只多一块
@@ -1376,7 +1482,11 @@ export function KnowledgeBaseTab({ visible, onChanged, onActiveBaseChange, knowl
1376
1482
  { label: '更新', value: formatDateTime(detail.document.updatedAt) },
1377
1483
  ]} />
1378
1484
 
1379
- <PreviewContent content={detail.document.content} assets={detail.document.assets} />
1485
+ <PreviewContent
1486
+ content={detail.document.content}
1487
+ assets={detail.document.assets}
1488
+ totalChars={detail.document.contentChars}
1489
+ />
1380
1490
 
1381
1491
  <div className={styles.drawerSection}>
1382
1492
  {/* WeKnora 的详情页在文件下面列分块;这里列的是这个 SourceRaw 编译出的 wiki 卡片。 */}
@@ -13,7 +13,7 @@ import { LayoutPreview, useAgentLayout } from './DirectoryPicker.tsx'
13
13
  import { AgentPresetCard, type AgentPresetRemote } from './AgentPresetCard.tsx'
14
14
  import {
15
15
  NODE_META, NODE_ORDER, NODE_STATUS_LABEL, noteCountOf, sourceCountOf,
16
- type KnowledgeStats, type KnowledgeTab, type NodeId, type PanelTab, type Tone,
16
+ type KnowledgeStats, type KnowledgeTab, type NodeId, type PanelTab, type Tone, type WorkspaceTabKind,
17
17
  } from './architecture.tsx'
18
18
  import { AgentArchitectureGraph } from './arch/AgentArchitectureGraph.tsx'
19
19
  import { NodeLogicPanel } from './arch/NodeLogicPanel.tsx'
@@ -24,13 +24,14 @@ import { AttributionTab } from './attribution/AttributionTab.tsx'
24
24
  import { KnowledgeBaseTab } from './KnowledgeBaseTab.tsx'
25
25
  import { IntentTab } from './IntentTab.tsx'
26
26
  import { RecallPanel } from './RecallPanel.tsx'
27
+ import { readRemembered, remember } from './panel-state.ts'
27
28
  import { styles, toneSuffix } from './styles.ts'
28
29
  import '@tita-data-agent/ui/styles.css'
29
30
  import './styles.css'
30
31
 
31
32
  interface Props {
32
33
  visible: boolean
33
- onOpenTab?: (kind: 'data-sheet' | 'data-notebook') => void
34
+ onOpenTab?: (kind: WorkspaceTabKind) => void
34
35
  settings?: {
35
36
  intent?: SettingsScopeLike
36
37
  knowledge?: SettingsScopeLike
@@ -167,7 +168,13 @@ function EvalTab({ visible, settings, onSelect }: { visible: boolean; settings?:
167
168
  }
168
169
 
169
170
  export function KnowledgePanel({ visible, onOpenTab, settings, remote }: Props): ReactNode {
170
- const [activeTab, setActiveTab] = useState<ViewId>('overview')
171
+ /**
172
+ * 一级 tab。**起始值来自记住的位置**(`panel-state.ts`):DSH 会重新挂载这个面板
173
+ * (刷新页面必然、换会话按宿主策略也会),只放在 `useState` 里的话,用户每次回来都
174
+ * 跳回「Agent 概览」。存的值先对着 `VIEW_ORDER` 校验 —— 上一版写下的旧 id 不能让面板
175
+ * 走进一个不存在的 tab。
176
+ */
177
+ const [activeTab, setActiveTab] = useState<ViewId>(() => readRemembered('view', VIEW_ORDER) ?? 'overview')
171
178
  const [stats, setStats] = useState<KnowledgeStats>({})
172
179
  const [statsLoading, setStatsLoading] = useState(false)
173
180
  const [statsError, setStatsError] = useState<string | null>(null)
@@ -230,9 +237,19 @@ export function KnowledgePanel({ visible, onOpenTab, settings, remote }: Props):
230
237
  const selectNode = (id: NodeId | null): void => { setSelected(id); setSelectedDomain(null); setNotice(null) }
231
238
  const selectDomain = (id: string | null): void => { setSelectedDomain(id); setSelected(null); setNotice(null) }
232
239
 
240
+ /**
241
+ * **换一级 tab 的唯一入口。** 三处都会换(tab 栏、节点面板的「去配置」、归因空态送人
242
+ * 去知识库录来源),每一处都顺手记一笔 —— 分头写的话,迟早有一处忘了记,
243
+ * 而那种漏表现为「从这个入口过去不记得、从那个入口过去记得」。
244
+ */
245
+ const changeTab = useCallback((tab: ViewId): void => {
246
+ setActiveTab(tab)
247
+ remember('view', tab)
248
+ }, [])
249
+
233
250
  /** 架构图节点 → 功能 tab。这是节点面板上「去配置」按钮唯一的落点。 */
234
251
  const jumpTo = (tab: PanelTab, knowledgeTab?: KnowledgeTab): void => {
235
- setActiveTab(tab)
252
+ changeTab(tab)
236
253
  if (tab === 'knowledge' && knowledgeTab) setKnowledgeFocus({ tab: knowledgeTab, nonce: Date.now() })
237
254
  }
238
255
 
@@ -258,7 +275,7 @@ export function KnowledgePanel({ visible, onOpenTab, settings, remote }: Props):
258
275
 
259
276
  return <section className={styles.panel} aria-label="Tita Data Agent">
260
277
  {/* Tab 切换是面板外壳的一部分:它不参与 `.tita-content` 的滚动,永远停在顶部。 */}
261
- <Tabs className={styles.panelTabs} value={activeTab} onValueChange={value => setActiveTab(value as ViewId)}>
278
+ <Tabs className={styles.panelTabs} value={activeTab} onValueChange={value => changeTab(value as ViewId)}>
262
279
  <div className={styles.tabBar}>
263
280
  <TabsList className={styles.tabsList}>
264
281
  {VIEW_ORDER.map(id => {
@@ -273,7 +290,10 @@ export function KnowledgePanel({ visible, onOpenTab, settings, remote }: Props):
273
290
  ><Settings2 size={15} /></Button>
274
291
  </div>
275
292
 
276
- <div className={cn(styles.content, activeTab === 'attribution' && styles.contentStage)}>
293
+ {/* 两栏走「舞台」版式(自己占满高度、不参与面板滚动):智能归因是图 + 运行面板,
294
+ 概览是画布 + 画布内浮层。同一套语汇,同一个理由 —— 它们的内容都要自己撑满
295
+ 可视区,让面板替它们滚会把画布推成一条可以滚出视口的纸。 */}
296
+ <div className={cn(styles.content, (activeTab === 'attribution' || activeTab === 'overview') && styles.contentStage)}>
277
297
 
278
298
  {/* 一级 tab 已经说明「现在是哪一页」,这里不再重复一遍标题与说明。
279
299
  只有真的出问题时才占一行:读取失败 / 读取中。 */}
@@ -286,11 +306,13 @@ export function KnowledgePanel({ visible, onOpenTab, settings, remote }: Props):
286
306
  <Button variant="ghost" size="icon" aria-label="关闭提示" title="关闭提示" onClick={() => setNotice(null)}><X size={14} /></Button>
287
307
  </Card>}
288
308
 
289
- <TabsContent value="overview">
309
+ <TabsContent value="overview" className={styles.stageContent}>
290
310
  {/* 概览 = **一个画布**。0027 之前这里是「画布 + 右侧栏」,而问题域还收在栏底的
291
311
  折叠区里 —— 三块内容、两条边框,读起来是三个东西。现在画布是四条带
292
312
  (A/B/C 三条链路 + 一条问题域带),点节点或问题域牌子都在**画布内**的
293
313
  浮层里展开详情:不占侧栏、不缩字、可关闭。
314
+ 0042:这一栏改走「舞台」版式,画布不再是一张会自动收缩的卡片 ——
315
+ 它占满可视高度、只按宽度适应,纵向溢出靠空格 + 拖拽平移。
294
316
  设置不在这里:面板上唯一的设置入口在顶部 tab 栏最右边的齿轮里。 */}
295
317
  <AgentArchitectureGraph
296
318
  stats={stats} version={version} selected={selected}
@@ -312,7 +334,13 @@ export function KnowledgePanel({ visible, onOpenTab, settings, remote }: Props):
312
334
  <IntentTab visible={visible && activeTab === 'intent'} intent={settings?.intent} />
313
335
  </TabsContent>
314
336
 
315
- <TabsContent value="knowledge">
337
+ {/* `forceMount`:知识库这一栏**不要在离开 tab 时卸载**。
338
+ 它有一整套已经拉回来的状态(库列表、文件、卡片、图谱、统计),而 Radix 的
339
+ `TabsContent` 默认是卸载的 —— 于是每次从别的 tab 切回来,选择器先显示
340
+ 「加载中…」、「类型定义」按钮跟着变灰,整栏白一下再长出来。那些数据并没有
341
+ 过期,重拉一遍也不该把已经画好的东西先擦掉。
342
+ 只有这一栏这么做:其余五栏拉的是各自独立的只读数据,卸载它们反而省事。 */}
343
+ <TabsContent value="knowledge" forceMount>
316
344
  <KnowledgeBaseTab
317
345
  visible={visible && activeTab === 'knowledge'}
318
346
  onChanged={() => void refreshStats()}
@@ -323,7 +351,12 @@ export function KnowledgePanel({ visible, onOpenTab, settings, remote }: Props):
323
351
  </TabsContent>
324
352
 
325
353
  <TabsContent value="attribution" className={styles.stageContent}>
326
- <AttributionTab visible={visible && activeTab === 'attribution'} />
354
+ {/* 空态的主操作是「去知识库录来源」,所以这一栏要能把人送过去 ——
355
+ 排查知识是从来源与卡片编译来的,它自己产不出第一份知识。 */}
356
+ <AttributionTab
357
+ visible={visible && activeTab === 'attribution'}
358
+ onOpenKnowledge={() => changeTab('knowledge')}
359
+ />
327
360
  </TabsContent>
328
361
 
329
362
  <TabsContent value="memory">
@@ -39,6 +39,9 @@ export function KnowledgeStepRail({ tab, onChange, facts }: KnowledgeStepRailPro
39
39
  type="button"
40
40
  className={cn(styles.kbStep, on && styles.kbStepOn)}
41
41
  aria-current={on ? 'step' : undefined}
42
+ // 第二行的说明在窄栏下会省略号截断(它是 `nowrap` + ellipsis),
43
+ // 所以完整那句挂在这里 —— 截断的信息要有地方读得到。
44
+ title={`${item.label}:${fact.value} · ${fact.note}`}
42
45
  onClick={() => onChange(item.id)}
43
46
  >
44
47
  {/* 两行,不是一行挤四样东西。一行放「图标 + 名字 + 动作 + 计数」在宿主侧栏