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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (117) hide show
  1. package/README.md +31 -7
  2. package/cordis.patch.yml +11 -5
  3. package/lib/client.js +5557 -3056
  4. package/lib/doc-page.js +7740 -0
  5. package/lib/types/convert/host.d.ts.map +1 -1
  6. package/lib/types/convert/host.js +36 -9
  7. package/lib/types/convert/host.js.map +1 -1
  8. package/lib/types/diagnosis/cards.d.ts +8 -7
  9. package/lib/types/diagnosis/cards.d.ts.map +1 -1
  10. package/lib/types/diagnosis/cards.js +119 -49
  11. package/lib/types/diagnosis/cards.js.map +1 -1
  12. package/lib/types/diagnosis/run.d.ts +51 -0
  13. package/lib/types/diagnosis/run.d.ts.map +1 -0
  14. package/lib/types/diagnosis/run.js +239 -0
  15. package/lib/types/diagnosis/run.js.map +1 -0
  16. package/lib/types/diagnosis/schema.d.ts +25 -2
  17. package/lib/types/diagnosis/schema.d.ts.map +1 -1
  18. package/lib/types/diagnosis/schema.js +64 -25
  19. package/lib/types/diagnosis/schema.js.map +1 -1
  20. package/lib/types/diagnosis/seed-source.d.ts +17 -2
  21. package/lib/types/diagnosis/seed-source.d.ts.map +1 -1
  22. package/lib/types/diagnosis/seed-source.js +18 -1
  23. package/lib/types/diagnosis/seed-source.js.map +1 -1
  24. package/lib/types/diagnosis/seed.d.ts +32 -42
  25. package/lib/types/diagnosis/seed.d.ts.map +1 -1
  26. package/lib/types/diagnosis/seed.js +187 -51
  27. package/lib/types/diagnosis/seed.js.map +1 -1
  28. package/lib/types/diagnosis/store.d.ts +114 -26
  29. package/lib/types/diagnosis/store.d.ts.map +1 -1
  30. package/lib/types/diagnosis/store.js +336 -167
  31. package/lib/types/diagnosis/store.js.map +1 -1
  32. package/lib/types/diagnosis/types.d.ts +168 -16
  33. package/lib/types/diagnosis/types.d.ts.map +1 -1
  34. package/lib/types/diagnosis/types.js +1 -1
  35. package/lib/types/diagnosis/types.js.map +1 -1
  36. package/lib/types/document-page.d.ts +39 -0
  37. package/lib/types/document-page.d.ts.map +1 -0
  38. package/lib/types/document-page.js +43 -0
  39. package/lib/types/document-page.js.map +1 -0
  40. package/lib/types/fs-atomic.d.ts +12 -0
  41. package/lib/types/fs-atomic.d.ts.map +1 -0
  42. package/lib/types/fs-atomic.js +42 -0
  43. package/lib/types/fs-atomic.js.map +1 -0
  44. package/lib/types/index.d.ts.map +1 -1
  45. package/lib/types/index.js +214 -24
  46. package/lib/types/index.js.map +1 -1
  47. package/lib/types/knowledge-bases.d.ts +10 -2
  48. package/lib/types/knowledge-bases.d.ts.map +1 -1
  49. package/lib/types/knowledge-bases.js +20 -18
  50. package/lib/types/knowledge-bases.js.map +1 -1
  51. package/lib/types/proposals.d.ts +166 -0
  52. package/lib/types/proposals.d.ts.map +1 -0
  53. package/lib/types/proposals.js +238 -0
  54. package/lib/types/proposals.js.map +1 -0
  55. package/lib/types/runtime.d.ts +199 -7
  56. package/lib/types/runtime.d.ts.map +1 -1
  57. package/lib/types/runtime.js +728 -145
  58. package/lib/types/runtime.js.map +1 -1
  59. package/lib/types/settings-schema.d.ts +2 -2
  60. package/lib/types/types.d.ts +61 -10
  61. package/lib/types/types.d.ts.map +1 -1
  62. package/lib/types/types.js +56 -8
  63. package/lib/types/types.js.map +1 -1
  64. package/lib/types/wiki-types.d.ts +8 -1
  65. package/lib/types/wiki-types.d.ts.map +1 -1
  66. package/lib/types/wiki-types.js +16 -25
  67. package/lib/types/wiki-types.js.map +1 -1
  68. package/package.json +4 -3
  69. package/preset/tita-data-agent/agent.cordis.yml +11 -5
  70. package/src/client/DirectoryPicker.tsx +12 -12
  71. package/src/client/InfoHint.tsx +21 -8
  72. package/src/client/IntentTab.tsx +20 -26
  73. package/src/client/KnowledgeBaseTab.tsx +190 -80
  74. package/src/client/KnowledgePanel.tsx +42 -9
  75. package/src/client/KnowledgeStepRail.tsx +3 -0
  76. package/src/client/NodeSettings.tsx +20 -22
  77. package/src/client/PreviewSections.tsx +87 -33
  78. package/src/client/ProposalReview.tsx +313 -0
  79. package/src/client/TypeDefinitionDrawer.tsx +72 -38
  80. package/src/client/WikiTypeEditor.tsx +193 -77
  81. package/src/client/arch/AgentArchitectureGraph.tsx +143 -14
  82. package/src/client/arch/NodeLogicPanel.tsx +6 -3
  83. package/src/client/arch/model.ts +164 -26
  84. package/src/client/architecture.css +29 -9
  85. package/src/client/architecture.tsx +54 -29
  86. package/src/client/attribution/AttributionGraph.tsx +481 -34
  87. package/src/client/attribution/AttributionTab.tsx +507 -106
  88. package/src/client/attribution/CaseView.tsx +9 -14
  89. package/src/client/attribution/GraphStage.tsx +25 -18
  90. package/src/client/attribution/RunView.tsx +27 -9
  91. package/src/client/attribution/StageChrome.tsx +296 -140
  92. package/src/client/attribution/graph-options.ts +611 -47
  93. package/src/client/attribution/model.ts +559 -30
  94. package/src/client/convert/ConvertPanel.tsx +6 -1
  95. package/src/client/doc-page.css +134 -0
  96. package/src/client/doc-page.tsx +263 -0
  97. package/src/client/panel-state.ts +105 -0
  98. package/src/client/request.ts +36 -0
  99. package/src/client/styles.css +317 -43
  100. package/src/client/useFitScale.ts +15 -2
  101. package/src/client/wiki-type-groups.ts +86 -0
  102. package/src/convert/host.ts +41 -7
  103. package/src/diagnosis/cards.ts +120 -50
  104. package/src/diagnosis/run.ts +245 -0
  105. package/src/diagnosis/schema.ts +67 -26
  106. package/src/diagnosis/seed-source.ts +28 -3
  107. package/src/diagnosis/seed.ts +187 -85
  108. package/src/diagnosis/store.ts +358 -174
  109. package/src/diagnosis/types.ts +181 -16
  110. package/src/document-page.ts +51 -0
  111. package/src/fs-atomic.ts +42 -0
  112. package/src/index.ts +209 -22
  113. package/src/knowledge-bases.ts +20 -18
  114. package/src/proposals.ts +337 -0
  115. package/src/runtime.ts +736 -135
  116. package/src/types.ts +113 -19
  117. package/src/wiki-types.ts +17 -27
@@ -1,7 +1,7 @@
1
1
  import {
2
- GitBranch, History, Stethoscope, X,
2
+ Database, GitBranch, History, Sparkles, Stethoscope, X,
3
3
  } from '@tita-data-agent/ui/icons'
4
- import { useEffect, useMemo, useRef, useState, type ReactNode } from 'react'
4
+ import { useCallback, useEffect, useMemo, useRef, useState, type ReactNode } from 'react'
5
5
  import { createPortal } from 'react-dom'
6
6
  import { Button } from '@tita-data-agent/ui/components'
7
7
  import { AttributionGraph } from './AttributionGraph.tsx'
@@ -10,15 +10,36 @@ import { GraphStage } from './GraphStage.tsx'
10
10
  import { RunView } from './RunView.tsx'
11
11
  import { ViewSwitch, type ViewSwitchItem } from './ViewSwitch.tsx'
12
12
  import {
13
- FilterBar, LegendBody, LegendToggle, NodeDetailCard, StatsStrip, ToolPill, ToolRail,
13
+ FilterBar, LegendBody, NodeDetailCard, OperationsRail, ReadBar, StatsStrip, ToolPill,
14
14
  } from './StageChrome.tsx'
15
15
  import type { GraphLayout } from './graph-options.ts'
16
16
  import {
17
- candidatesOf, defaultFocusId, EMPTY_FILTER, filterGraph, gapHypothesisIds,
18
- neighborhoodOf, summarize, symptomCountByProblemClass,
19
- type AttributionGraphData, type AttributionNodeKind, type FailureMode, type ProblemClassId,
17
+ CHECK_COST_BANDS, EMPTY_FILTER, EMPTY_GRAPH, FILTERABLE_KINDS, LANES, PROBLEM_CLASSES,
18
+ candidatesOf, chainOf, defaultChainStart, defaultFocusId, filterGraph, gapHypothesisIds, laneKindVisible,
19
+ laneSegments, narrowedToLanes, neighborhoodOf, summarize, symptomCountByProblemClass,
20
+ type AttributionCase, type AttributionGraphData, type AttributionNodeKind, type FailureReason,
21
+ type LaneId, type ProblemClassId,
20
22
  } from './model.ts'
21
- import { SEED_CASES, SEED_GRAPH } from '../../diagnosis/seed.js'
23
+
24
+ /** 一次载入的结果。空库也是一种正常结果,所以它不是错误分支。 */
25
+ interface DiagnosisPayload {
26
+ graph?: AttributionGraphData
27
+ cases?: AttributionCase[]
28
+ source?: string
29
+ migrated?: boolean
30
+ problems?: string[]
31
+ /** 这个库还什么都没有 —— 空态可以「载入内置示例」。 */
32
+ canLoadSample?: boolean
33
+ /** 待人确认的卡片提议条数(§7.1)。归因侧只挂角标,review 在「知识库 → Wiki」。 */
34
+ proposals?: { pending: number; rejected: number; confirmed: number }
35
+ }
36
+
37
+ /** 服务端的三种来源 → 徽标上那句人话。 */
38
+ function sourceLabelOf(source: string | undefined): string {
39
+ if (source === 'cards') return '02_wiki/diagnosis'
40
+ if (source === 'jsonl') return '05_diagnosis(旧格式)'
41
+ return '还没有排查知识'
42
+ }
22
43
 
23
44
  type SubView = 'graph' | 'case' | 'run'
24
45
 
@@ -30,71 +51,203 @@ type SubView = 'graph' | 'case' | 'run'
30
51
  * 全部从「图谱的兄弟节点」改成「浮在画布上的四条边」:画布吃掉全部高度,控件
31
52
  * 叠在它上面。视觉结果就是「打开之后就是一张大图」。
32
53
  *
54
+ * **v1.4 起默认读法是动线视角**(§7.2 / §7.7):五段泳道 + 点亮一条链。
55
+ * 理由不是审美 —— 力导向回答「这张图长什么样」(维护者的问题),而值班同学打开
56
+ * 这一栏问的是「下一步查什么」。默认要服务提问更多的那个角色;想看形状时切「关系」,
57
+ * 一步操作。
58
+ *
33
59
  * 三条读法各自回答一个问题(图谱=结构、案例=路径、排查=剪枝),它们是同一画框里
34
- * 的三层内容,由顶部的分段切换换页 —— 切换器本身也浮在同一个框里,所以**全屏时
35
- * 它天然跟着一起上去**,不需要为全屏再抄一份控件。
60
+ * 的三层内容,由顶部的分段切换换页 —— 切换器在**画框之外**(页面级控件),
61
+ * 所以全屏时它天然跟着一起上去,不需要为全屏再抄一份控件。
36
62
  *
37
- * 另外两个开关各自回答一个不同的问题:
38
- * - **布局**:`力导向` 回答「这张图长什么样」(浮动、可拖、靠聚簇表达关系);
39
- * `分层` 回答「谁是上游」(现象→假设→检查 的方向被显式画出来)。
40
- * - **筛选**:按类型 / 失败模式 / 文本收窄。它是**切面**而不是树的一层 ——
41
- * 可以多选、可以为空、随时能改回看全图,不会像层级那样「选错就得往回退」。
63
+ * 四角分区(§7.1):
64
+ * - 画布左上 = 操作(四组按动线顺序);右上 = 读法(四个开关同排);
65
+ * 左下 = 读数(统计 + 来源);底中 = 搜索;右侧 = 图例正文 / 节点详情。
42
66
  */
43
- export function AttributionTab({ visible }: { visible: boolean }): ReactNode {
67
+ export function AttributionTab({ visible, onOpenKnowledge }: {
68
+ visible: boolean
69
+ /**
70
+ * 切到「知识库」那一栏。空态的主操作要用它 —— 排查知识是从来源与卡片编译来的,
71
+ * 空的时候该把人送去**录知识的那一栏**,而不是让人在这一栏里等。
72
+ */
73
+ onOpenKnowledge?: () => void
74
+ }): ReactNode {
44
75
  const [subView, setSubView] = useState<SubView>('graph')
45
- const [layout, setLayout] = useState<GraphLayout>('force')
76
+ /** 默认动线:它是 §7.2 定的默认读法。 */
77
+ const [layout, setLayout] = useState<GraphLayout>('line')
46
78
  const [overview, setOverview] = useState(true)
47
- const [focusId, setFocusId] = useState<string | undefined>(() => defaultFocusId(SEED_GRAPH))
48
- const [activeCase, setActiveCase] = useState('CASE-001')
79
+ const [focusId, setFocusId] = useState<string | undefined>(undefined)
80
+ /**
81
+ * 被点亮的那条链的起点。
82
+ *
83
+ * 与 `focusId` **分开**:`focusId` 是「聚焦哪个节点的邻域」(默认落在度数最高的
84
+ * 假设上,打开这一栏就该看到最粗的那个结);`selectedId` 是「点亮谁的链」。
85
+ *
86
+ * **它现在默认落在①段的第一个现象上。** 上一版默认是空的,理由是「一进来就压暗
87
+ * 半张图,等于把全景变成需要用户先学会退出」—— 那条理由在真机上被否掉了:3 个现象
88
+ * 共享 7 个假设,14 条 `may_be_caused_by` 交叉成一片,而§7.4 第 9 条本来就写着
89
+ * 「路径高亮是**默认读法**,不是附加功能」。默认空着,等于这条读法默认没生效。
90
+ *
91
+ * 代价仍然存在,所以出口必须明显:**点空白即取消**,而且取消之后不再被自动改回来
92
+ * (见 `pickedDefault`)。
93
+ */
94
+ const [selectedId, setSelectedId] = useState<string | undefined>(undefined)
95
+ /**
96
+ * 动线视角的**当前现象**:①段只画它,其余现象进段头的切换入口。
97
+ *
98
+ * 收窄到一个是必须的:3 个现象共享 7 个假设(§4.5),①→② 之间因此有 14 条
99
+ * `may_be_caused_by` 互相交叉 —— 交叉的成因是「有三个起点」。只留一个之后,
100
+ * 那 7 条线从一个点扇出,而画布上剩下的每一条边**都真实属于这个现象**。
101
+ *
102
+ * 代价:「三个现象共享同一批假设」在动线视角里看不到了,它在关系视图里照旧。
103
+ * 动线回答的是「这个现象的下一步」,那本来就是一个现象的事(§4.6)。
104
+ */
105
+ const [laneSymptomId, setLaneSymptomId] = useState<string | undefined>(undefined)
106
+ /**
107
+ * 已经替用户预置过一次焦点了吗?
108
+ *
109
+ * 预置只在**第一次拿到非空数据**时发生。少了这个闸,每次重载(载入示例、重建之后
110
+ * 刷新)都会把用户手动取消掉的焦点重新按回去 —— 那就是在跟用户抢方向盘。
111
+ */
112
+ const pickedDefault = useRef(false)
113
+ const [activeCase, setActiveCase] = useState('')
49
114
  const [full, setFull] = useState(false)
50
- const [railOpen, setRailOpen] = useState(true)
115
+ /** 左栏:面板态默认收起成 pill(380px 塞不下四组控件 + 五段泳道,§7.7 末段)。 */
116
+ const [railOpen, setRailOpen] = useState(false)
51
117
  const [legendOpen, setLegendOpen] = useState(false)
52
- const [data, setData] = useState<AttributionGraphData>(SEED_GRAPH)
118
+ /** ⑤ 段(实例层)默认折叠;用户展开过就记住。 */
119
+ const [instanceOpen, setInstanceOpen] = useState(false)
120
+ /**
121
+ * 图与案例都**从服务端来**,初始值是空的。
122
+ *
123
+ * 这里原来是 `useState(SEED_GRAPH)`:服务端还没答话时画的就是内置示例,
124
+ * 而它和「你的库真有 34 个节点」在屏幕上长得一样。现在初始值 `EMPTY_GRAPH`,
125
+ * 示例只在用户明确点了「载入内置示例」之后才出现(那时它已经是库里的真卡片了)。
126
+ */
127
+ const [data, setData] = useState<AttributionGraphData>(EMPTY_GRAPH)
128
+ const [cases, setCases] = useState<AttributionCase[]>([])
129
+ const [phase, setPhase] = useState<'loading' | 'ready' | 'error'>('loading')
130
+ /** 空库:可以载入内置示例。 */
131
+ const [canLoadSample, setCanLoadSample] = useState(false)
132
+ /**
133
+ * 待人确认的提议条数。它随图一起从 `/diagnosis/graph` 来(同一个响应体),
134
+ * 不另开一路请求 —— 两个数字来自同一次读取,界面上不可能互相矛盾。
135
+ */
136
+ const [pendingProposals, setPendingProposals] = useState(0)
137
+ const [sampleBusy, setSampleBusy] = useState(false)
138
+ const [sampleError, setSampleError] = useState<string | null>(null)
139
+ /** 载入成功之后的一句话说明:写进去的是什么、写在哪、能不能改。 */
140
+ const [sampleNotice, setSampleNotice] = useState<string | null>(null)
53
141
  /**
54
142
  * 宿主返回的**来源状态**。
55
143
  *
56
- * 三种形态的可信度不同,必须分开显示:卡片是当前真相;`05_diagnosis` 旧格式意味着迁移
57
- * 没能无损生效(有人手工加的边或孤立的资产节点会让它放弃迁移);`seed 数据` 是宿主没就绪
58
- * 时的兜底示例。`problems` 一并显示 —— 写卡片失败、迁移分歧都属于「不能只进日志」的信息。
144
+ * 两种形态的可信度不同,必须分开显示:卡片是当前真相;`05_diagnosis` 旧格式意味着迁移
145
+ * 没能无损生效(有人手工加的边或孤立的资产节点会让它放弃迁移)。`problems` 一并显示
146
+ * —— 写卡片失败、迁移分歧都属于「不能只进日志」的信息。
59
147
  */
60
148
  const [remote, setRemote] = useState<{ source: string; problems: string[] } | undefined>(undefined)
61
149
  const [resetToken, setResetToken] = useState(0)
62
150
  const [query, setQuery] = useState('')
151
+ /** 段(= 节点种类)切面。它就是「类型切面」的归位处(§7.3)。 */
63
152
  const [kinds, setKinds] = useState<ReadonlySet<AttributionNodeKind>>(new Set())
64
- const [modes, setModes] = useState<ReadonlySet<FailureMode>>(new Set())
153
+ const [modes, setModes] = useState<ReadonlySet<FailureReason>>(new Set())
65
154
  const [problems, setProblems] = useState<ReadonlySet<ProblemClassId>>(new Set())
155
+ const [subclasses, setSubclasses] = useState<ReadonlySet<string>>(new Set())
156
+ const [gapOnly, setGapOnly] = useState(false)
157
+ const [costBands, setCostBands] = useState<ReadonlySet<string>>(new Set())
158
+ const [automations, setAutomations] = useState<ReadonlySet<string>>(new Set())
66
159
  const [includeNeighbors, setIncludeNeighbors] = useState(true)
67
- const [symptomId, setSymptomId] = useState('S1')
160
+ const [symptomId, setSymptomId] = useState('')
161
+ /** 段头读数由画布回报(它才知道折叠后实际画了几个)。 */
162
+ /**
163
+ * 被**收起**的段。默认收起 ④ 查询依据。
164
+ *
165
+ * 判据是「它在这个布局里是不是决策对象」:④ 装的是引用层的资产(§0.1「只作定位
166
+ * 锚点」),动线要回答的是「下一步查什么」。而它在面板里要占掉 5 个节点 + 11 条
167
+ * 跨段边 —— 默认铺开时非空段是 4 段,每段的节点被压到几乎读不出字。
168
+ * 收起它之后非空段降到 3 段,节点明显变大,而「这条链到底要查什么」在点亮那条链
169
+ * 时照样展开(§7.7 末段的那条例外)。
170
+ */
171
+ const [collapsedLanes, setCollapsedLanes] = useState<ReadonlySet<LaneId>>(new Set<LaneId>(['asset']))
172
+ const [laneCounts, setLaneCounts] = useState<Record<LaneId, { shown: number; total: number }>>({
173
+ symptom: { shown: 0, total: 0 },
174
+ hypothesis: { shown: 0, total: 0 },
175
+ check_point: { shown: 0, total: 0 },
176
+ asset: { shown: 0, total: 0 },
177
+ instance: { shown: 0, total: 0 },
178
+ })
179
+
180
+ /** 把一次 `/diagnosis/graph` 的结果落进界面。空库也走这里 —— 它不是错误分支。 */
181
+ const applyPayload = useCallback((payload: DiagnosisPayload): void => {
182
+ const graph = payload.graph ?? EMPTY_GRAPH
183
+ const safe = Array.isArray(graph.nodes) && Array.isArray(graph.edges) ? graph : EMPTY_GRAPH
184
+ setData(safe)
185
+ // 默认聚焦度数最高的假设:打开这一栏就该看到「这张图里最粗的那个结」是什么,
186
+ // 而不是一块什么都没有的画布。空图时是 undefined,画布保持干净。
187
+ setFocusId(defaultFocusId(safe))
188
+ setCases(payload.cases ?? [])
189
+ setCanLoadSample(payload.canLoadSample === true)
190
+ setPendingProposals(payload.proposals?.pending ?? 0)
191
+ // 默认点亮①段第一个现象的那条链(§7.4 第 9 条)。只在第一次拿到非空数据时做一次:
192
+ // 之后用户取消过就保持取消,重载不把它按回去。
193
+ if (!pickedDefault.current) {
194
+ const start = defaultChainStart(safe)
195
+ if (start) { pickedDefault.current = true; setLaneSymptomId(start); setSelectedId(start) }
196
+ }
197
+ const troubles = payload.problems ?? []
198
+ setRemote({
199
+ source: sourceLabelOf(payload.source),
200
+ // 迁移成功也是信息:它说明这个知识根刚从旧格式换过来。
201
+ problems: payload.migrated ? [...troubles, '本次已把 05_diagnosis 的知识迁移为 wiki 卡片'] : troubles,
202
+ })
203
+ }, [])
68
204
 
69
- // `/diagnosis/graph`:宿主已实现这条路由(05_diagnosis)。拿不到就静默回落到 seed,
70
- // 并在界面上写明来源 ——「有没有真实数据」必须可见,不能靠猜。
205
+ /**
206
+ * 读一次 `/diagnosis/graph`。**这条路由只读不写** —— 「载入内置示例」是另一次显式 POST。
207
+ *
208
+ * 拿不到就当读失败如实说,不再回落到内置示例:兜底示例会让人把「宿主没答话」看成
209
+ * 「我的库里有 34 个节点」,而这正是这一栏最不该让人猜的地方。
210
+ */
211
+ const [reloadToken, setReloadToken] = useState(0)
71
212
  useEffect(() => {
72
- if (!visible) return
213
+ if (!visible) return undefined
73
214
  let cancelled = false
215
+ setPhase('loading')
74
216
  void (async () => {
75
217
  try {
76
218
  const response = await fetch('/diagnosis/graph')
77
- if (!response.ok) return
78
- const payload = await response.json() as {
79
- data?: { graph?: AttributionGraphData; source?: string; migrated?: boolean; problems?: string[] }
80
- } & AttributionGraphData
81
- const graph = payload.data?.graph ?? payload
82
- if (!cancelled && Array.isArray(graph.nodes) && graph.nodes.length > 0) {
83
- setData(graph)
84
- const source = payload.data?.source
85
- const problems = payload.data?.problems ?? []
86
- setRemote({
87
- source: source === 'cards' ? '02_wiki/diagnosis'
88
- : source === 'jsonl' ? '05_diagnosis(旧格式)'
89
- : '05_diagnosis',
90
- // 迁移成功也是信息:它说明这个知识根刚从旧格式换过来。
91
- problems: payload.data?.migrated ? [...problems, '本次已把 05_diagnosis 的知识迁移为 wiki 卡片'] : problems,
92
- })
93
- }
94
- } catch { /* 宿主未就绪:保留 seed。 */ }
219
+ if (!response.ok) throw new Error(`HTTP ${response.status}`)
220
+ const body = await response.json() as { data?: DiagnosisPayload }
221
+ if (cancelled) return
222
+ applyPayload(body.data ?? {})
223
+ setPhase('ready')
224
+ } catch {
225
+ if (!cancelled) setPhase('error')
226
+ }
95
227
  })()
96
228
  return () => { cancelled = true }
97
- }, [visible])
229
+ }, [visible, reloadToken, applyPayload])
230
+
231
+ /**
232
+ * 载入内置示例:**显式的一次写**,写进的是这个库自己的 `01_source_truth/` 与 `02_wiki/`。
233
+ *
234
+ * 载完重新读一次,让图、案例、来源徽标全部换成真实数据 —— 载入之后它们就是普通卡片,
235
+ * 可以照常改、照常删,所以说明里必须把这件事讲清楚。
236
+ */
237
+ const loadSample = async (): Promise<void> => {
238
+ setSampleBusy(true); setSampleError(null)
239
+ try {
240
+ const response = await fetch('/diagnosis/sample', { method: 'POST' })
241
+ const body = await response.json() as { ok?: boolean; data?: DiagnosisPayload; error?: { message?: string } }
242
+ if (!response.ok || body.ok === false) throw new Error(body.error?.message ?? `HTTP ${response.status}`)
243
+ applyPayload(body.data ?? {})
244
+ setSampleNotice('已载入内置示例:4 份来源写进 01_source_truth/,26 张卡片写进 02_wiki/diagnosis/。它们就是普通来源与卡片,可以照常改或删。')
245
+ } catch (error) {
246
+ setSampleError(error instanceof Error ? error.message : String(error))
247
+ } finally {
248
+ setSampleBusy(false)
249
+ }
250
+ }
98
251
 
99
252
  /**
100
253
  * 全屏期间锁住宿主页面的滚动。
@@ -116,48 +269,145 @@ export function AttributionTab({ visible }: { visible: boolean }): ReactNode {
116
269
  }, [full])
117
270
 
118
271
  const filter = useMemo(
119
- () => ({ ...EMPTY_FILTER, query, kinds, failureModes: modes, problemClasses: problems, includeNeighbors }),
120
- [query, kinds, modes, problems, includeNeighbors],
272
+ () => ({
273
+ ...EMPTY_FILTER, query, kinds, failureReasons: modes, problemClasses: problems,
274
+ problemSubclasses: subclasses, gapOnly, includeNeighbors,
275
+ }),
276
+ [query, kinds, modes, problems, subclasses, gapOnly, includeNeighbors],
121
277
  )
122
278
  const filtered = useMemo(() => filterGraph(data, filter), [data, filter])
123
279
  const gapIds = useMemo(() => new Set(gapHypothesisIds(filtered)), [filtered])
124
280
  const stats = useMemo(() => summarize(filtered), [filtered])
125
- const candidateCount = useMemo(() => candidatesOf(data, symptomId).length, [data, symptomId])
126
281
  const symptomCounts = useMemo(() => symptomCountByProblemClass(data.nodes), [data])
127
282
  const symptoms = useMemo(() => data.nodes.filter(node => node.kind === 'symptom'), [data])
283
+ /**
284
+ * 当前选中的现象。**推导而不是存两份**:`symptomId` 的初值是空串,因为只有服务端
285
+ * 才知道这个库有哪几个现象 —— 换库、载入示例之后,原来那个 id 可能根本不存在,
286
+ * 而 `candidatesOf(data, '不存在的 id')` 会安静地返回空列表,看起来像「这个现象没有候选」。
287
+ */
288
+ const activeSymptomId = symptoms.some(node => node.id === symptomId) ? symptomId : (symptoms[0]?.id ?? '')
289
+ const candidateCount = useMemo(() => candidatesOf(data, activeSymptomId).length, [data, activeSymptomId])
290
+
291
+ /**
292
+ * 被点亮的那条链上的实例节点。
293
+ *
294
+ * ⑤ 段默认折叠,**但被点亮的那条链例外**(§7.7 末段):那时用户问的正是
295
+ * 「这条链到底跑过没有」,把证据藏起来等于不回答。
296
+ */
297
+ const chainInstanceIds = useMemo(() => {
298
+ if (!selectedId) return new Set<string>()
299
+ const chain = chainOf(data, selectedId)
300
+ return new Set(data.nodes.filter(node => node.layer === 'instance' && chain.has(node.id)).map(node => node.id))
301
+ }, [data, selectedId])
302
+ const includeInstance = instanceOpen || chainInstanceIds.size > 0
303
+
304
+ /**
305
+ * 段切面先收窄「哪些段画」,问题大类再收成子图,最后叠节点谓词。
306
+ *
307
+ * 顺序就是 §7.3 的语义:**先决定这张图怎么画,再在图里找**。反过来的话,
308
+ * 「关掉检查点」会把已经按问题收窄的范围再切一刀,两边都不好解释。
309
+ */
310
+ const laneScoped = useMemo(
311
+ () => ({ nodes: narrowedToLanes(filtered.nodes, kinds), edges: filtered.edges }),
312
+ [filtered, kinds],
313
+ )
128
314
  const graphData = useMemo(
129
- () => (overview ? filtered : (focusId ? neighborhoodOf(filtered, focusId, 1) : filtered)),
130
- [filtered, overview, focusId],
315
+ () => (overview ? laneScoped : (focusId ? neighborhoodOf(laneScoped, focusId, 1) : laneScoped)),
316
+ [laneScoped, overview, focusId],
131
317
  )
132
318
  const focused = data.nodes.find(node => node.id === focusId)
133
319
  const filtering = query.trim() !== '' || kinds.size > 0 || modes.size > 0 || problems.size > 0
320
+ || subclasses.size > 0 || gapOnly || costBands.size > 0 || automations.size > 0
134
321
 
135
- const toggleKind = (kind: AttributionNodeKind): void => {
322
+ const toggleIn = <T,>(set: ReadonlySet<T>, value: T, apply: (next: ReadonlySet<T>) => void): void => {
323
+ const next = new Set(set)
324
+ if (next.has(value)) next.delete(value); else next.add(value)
325
+ apply(next)
326
+ }
327
+ const toggleKind = (kind: AttributionNodeKind): void => toggleIn(kinds, kind, setKinds)
328
+ const toggleMode = (mode: FailureReason): void => toggleIn(modes, mode, setModes)
329
+ const toggleProblem = (id: ProblemClassId): void => {
330
+ toggleIn(problems, id, next => {
331
+ setProblems(next)
332
+ // 大类取消勾选时,属于它的子类一并撤掉 —— 否则会留下一个「什么也筛不出」
333
+ // 的隐形条件:界面上再也看不到那个子类 chip(它只跟着选中的大类出现)。
334
+ if (!next.has(id)) {
335
+ setSubclasses(previous => {
336
+ const kept = new Set(previous)
337
+ for (const sub of PROBLEM_CLASS_IDS_TO_SUBCLASSES[id] ?? []) kept.delete(sub)
338
+ return kept
339
+ })
340
+ }
341
+ })
342
+ }
343
+ const toggleSubclass = (id: string): void => toggleIn(subclasses, id, setSubclasses)
344
+ const toggleCostBand = (id: string): void => toggleIn(costBands, id, setCostBands)
345
+ const toggleAutomation = (id: string): void => toggleIn(automations, id, setAutomations)
346
+ /**
347
+ * 段开关:关掉一段 = 那一段的节点种类一起离开(§7.3)。
348
+ *
349
+ * 关键是**「全开」与「全关」这两个边界**:`kinds` 空集表示不过滤,所以
350
+ * 从全开点掉一段,必须显式把**其余**段的种类写进去(否则空集又等于全开,
351
+ * 点一下没反应);反过来把最后一段也点掉时,回到空集 = 回到全开 ——
352
+ * 「什么都不画」不是这张图上可表达的状态,一张空画布没法读。
353
+ */
354
+ const toggleLane = (id: LaneId): void => {
355
+ const lane = LANES.find(item => item.id === id)
356
+ if (!lane) return
357
+ const on = laneKindVisible(kinds, lane)
136
358
  const next = new Set(kinds)
137
- if (next.has(kind)) next.delete(kind); else next.add(kind)
359
+ if (on) {
360
+ for (const kind of FILTERABLE_KINDS) if (!lane.kinds.includes(kind)) next.add(kind)
361
+ } else {
362
+ for (const kind of lane.kinds) next.delete(kind)
363
+ }
138
364
  setKinds(next)
139
365
  }
140
- const toggleMode = (mode: FailureMode): void => {
141
- const next = new Set(modes)
142
- if (next.has(mode)) next.delete(mode); else next.add(mode)
143
- setModes(next)
144
- }
145
- const toggleProblem = (id: ProblemClassId): void => {
146
- const next = new Set(problems)
147
- if (next.has(id)) next.delete(id); else next.add(id)
148
- setProblems(next)
366
+ const clearFilter = (): void => {
367
+ setQuery(''); setKinds(new Set()); setModes(new Set()); setProblems(new Set())
368
+ setSubclasses(new Set()); setGapOnly(false); setCostBands(new Set()); setAutomations(new Set())
149
369
  }
150
- const clearFilter = (): void => { setQuery(''); setKinds(new Set()); setModes(new Set()); setProblems(new Set()) }
370
+ /** 全部取消点亮:再点同一个节点、点空白处、或按「取消点亮」都走这一条。 */
371
+ const clearSelection = (): void => { setSelectedId(undefined); setFocusId(undefined) }
372
+ /**
373
+ * 换到另一个现象的动线。
374
+ *
375
+ * 它同时做两件事:①段改画那个现象、并点亮它的链。少了第二件,用户点了一个入口却
376
+ * 看到画面没变亮,会以为点没生效。
377
+ *
378
+ * 注意它**不碰** `focusId`:那是「聚焦哪个节点的邻域」,与「看哪个现象的动线」
379
+ * 是两件事,混在一起会让右上的「全景 / 聚焦」跟着乱跳。
380
+ */
381
+ const pickSymptom = useCallback((id: string): void => {
382
+ setLaneSymptomId(id)
383
+ setSelectedId(id)
384
+ }, [])
385
+ /** 被关掉的段(左栏段 chip 的选中态与划线)。 */
386
+ const lanesOff = useMemo(
387
+ () => new Set(LANES.filter(lane => !laneKindVisible(kinds, lane)).map(lane => lane.id)),
388
+ [kinds],
389
+ )
390
+
391
+ /** 收起 / 展开一段。点段头与左栏的段 chip 走同一条路。 */
392
+ const toggleLaneCollapsed = useCallback((lane: LaneId): void => {
393
+ setCollapsedLanes(previous => {
394
+ const next = new Set(previous)
395
+ if (next.has(lane)) next.delete(lane); else next.add(lane)
396
+ return next
397
+ })
398
+ }, [])
151
399
 
152
400
  /**
153
401
  * 进出全屏。
154
402
  *
155
- * 进全屏时顺手把图例打开:那个档位本来就该「相关选项都展示上去」,而且全屏里
156
- * 右侧副栏宽一倍,放得下。退出时不动用户的选择 —— 收起图例是用户的决定,
157
- * 不该被一次全屏往返改回去。
403
+ * 进全屏时顺手展开左栏与图例(§7.6):**全屏放得下,就不该让用户再点一次**。
404
+ * 面板态默认收起是因为 380px 塞不下四组控件 + 五段泳道;退出时不动用户的选择。
158
405
  */
159
406
  const toggleFull = (): void => {
160
- if (!full) setLegendOpen(true)
407
+ if (!full) {
408
+ setLegendOpen(true)
409
+ setRailOpen(true)
410
+ }
161
411
  setFull(!full)
162
412
  }
163
413
 
@@ -165,43 +415,55 @@ export function AttributionTab({ visible }: { visible: boolean }): ReactNode {
165
415
  const viewItems: ReadonlyArray<ViewSwitchItem<SubView>> = [
166
416
  {
167
417
  id: 'graph', label: '图谱', icon: GitBranch,
168
- hint: '结构:谁可能是谁的原因。空区和僵尸边只在这张图上可见。',
418
+ hint: '动线:问题现象 → 假设原因 → 检查点 → 查询依据 → 实际查询与结果。空区与僵尸边只在这张图上可见。',
169
419
  meta: String(stats.typeNodes + stats.referenceNodes),
170
420
  },
171
421
  {
172
422
  id: 'case', label: '案例', icon: History,
173
423
  hint: '路径:一次真实排查怎么走的,包括走错的那一段。',
174
- meta: String(SEED_CASES.length),
424
+ meta: String(cases.length),
175
425
  },
176
426
  {
177
427
  id: 'run', label: '排查', icon: Stethoscope,
178
- hint: '剪枝:按优先分排出下一步该查什么,每个检查都有主操作。',
428
+ hint: '剪枝:按优先分排出下一步该查什么,每个检查点都有主操作。',
179
429
  meta: String(candidateCount),
180
430
  },
181
431
  ]
182
432
 
183
- const layoutHint = layout === 'force'
184
- ? '力导向:节点可拖动,松手后图会继续摊开 —— 关系靠聚簇和距离表达,方向不保证。'
185
- : '分层:现象 → 假设 → 检查 的方向被显式画出来,但看起来更像流程图。'
433
+ const layoutHint = layout === 'line'
434
+ ? `动线:五段泳道,段内各有排序;点一个现象或假设会点亮它那条链。${instanceOpen ? '⑤ 段已展开。' : '⑤ 段(实际查询与结果)默认折叠,点它可展开。'}`
435
+ : '关系:浮动节点、可拖拽,松手后图会继续摊开 —— 关系靠聚簇和距离表达,方向不保证。'
436
+ const filterCount = kinds.size + problems.size + subclasses.size + modes.size + costBands.size + automations.size + (gapOnly ? 1 : 0)
186
437
 
187
438
  const stage = <GraphStage
188
439
  mode={full ? 'fullscreen' : 'panel'}
189
440
  // 画布不随子视图卸载:切走再切回来时,拖过的位置要还在。
190
441
  canvas={<AttributionGraph
191
- key={`${resetToken}-${layout}`}
442
+ key={`${resetToken}-${layout}-${full ? 'full' : 'panel'}`}
192
443
  data={graphData}
193
444
  gapIds={gapIds}
194
445
  rankdir="LR"
195
446
  layout={layout}
196
447
  mode={full ? 'fullscreen' : 'panel'}
197
- selectedId={focusId}
198
- onSelect={setFocusId}
448
+ selectedId={selectedId}
449
+ includeInstance={includeInstance}
450
+ collapsedLanes={collapsedLanes}
451
+ lanesOff={lanesOff}
452
+ onToggleLane={toggleLaneCollapsed}
453
+ {...(layout === 'line' && laneSymptomId ? { focusSymptomId: laneSymptomId } : {})}
454
+ onPickSymptom={pickSymptom}
455
+ onLaneCounts={setLaneCounts}
456
+ onSelect={id => {
457
+ // 再点一次取消,回到全亮;点空白处同理(§7.4 第 9 条)。
458
+ setSelectedId(previous => (id === undefined || previous === id ? undefined : id))
459
+ setFocusId(id)
460
+ }}
199
461
  />}
200
462
  topLeft={
201
- // 画布左上角只剩「这张图怎么画」:布局 + 类型切面。页面切换已经搬到画框之外。
202
- // 案例 / 排查页没有图可读,这两个开关也就没有对象 —— 不传,不留空壳。
463
+ // 画布左上角只剩「这张图怎么画」:四组按动线顺序的切面。页面切换已经搬到画框之外。
464
+ // 案例 / 排查页没有图可读,这些开关也就没有对象 —— 不传,不留空壳。
203
465
  subView !== 'graph' ? undefined : railOpen
204
- ? <ToolRail
466
+ ? <OperationsRail
205
467
  layout={layout}
206
468
  onLayout={setLayout}
207
469
  onReset={() => setResetToken(token => token + 1)}
@@ -209,60 +471,88 @@ export function AttributionTab({ visible }: { visible: boolean }): ReactNode {
209
471
  onToggleKind={toggleKind}
210
472
  problems={problems}
211
473
  onToggleProblem={toggleProblem}
474
+ subclasses={subclasses}
475
+ onToggleSubclass={toggleSubclass}
212
476
  symptomCounts={symptomCounts}
213
- hint={layoutHint + (!overview && focused ? ` 聚焦 ${focused.id} 的 1 跳邻域,点节点切换。` : '')}
477
+ failureReasons={modes}
478
+ onToggleFailureReason={toggleMode}
479
+ gapOnly={gapOnly}
480
+ onGapOnly={() => setGapOnly(!gapOnly)}
481
+ costBands={costBands}
482
+ onToggleCostBand={toggleCostBand}
483
+ automations={automations}
484
+ onToggleAutomation={toggleAutomation}
485
+ laneCounts={laneCounts}
486
+ lanesOff={lanesOff}
487
+ onToggleLane={toggleLane}
488
+ hint={layoutHint + (overview ? '' : (focused ? ` 正聚焦 ${focused.id} 的 1 跳邻域,点节点切换。` : ' 处在聚焦态,点一个节点看它的邻域。'))}
214
489
  onCollapse={() => setRailOpen(false)}
215
490
  />
216
- : <ToolPill layout={layout} kinds={kinds} problems={problems} onClick={() => setRailOpen(true)} />
491
+ : <ToolPill
492
+ layout={layout}
493
+ filterCount={filterCount}
494
+ laneCount={laneScoped.nodes.length}
495
+ pendingProposals={pendingProposals}
496
+ {...(onOpenKnowledge ? { onOpenProposals: onOpenKnowledge } : {})}
497
+ onClick={() => setRailOpen(true)}
498
+ />
217
499
  }
218
- topRight={<LegendToggle
219
- open={legendOpen}
220
- onToggle={() => setLegendOpen(!legendOpen)}
500
+ topRight={<ReadBar
501
+ legendOpen={legendOpen}
502
+ onToggleLegend={() => setLegendOpen(!legendOpen)}
503
+ showLegend={subView === 'graph'}
504
+ overview={overview}
505
+ onOverview={setOverview}
506
+ includeNeighbors={includeNeighbors}
507
+ onIncludeNeighbors={setIncludeNeighbors}
221
508
  full={full}
222
509
  onToggleFull={toggleFull}
223
- showLegend={subView === 'graph'}
510
+ showScope={subView === 'graph'}
224
511
  />}
225
- {...(subView === 'case' ? { layer: <CaseView activeId={activeCase} onActiveChange={setActiveCase} /> } : {})}
512
+ {...(subView === 'case' ? { layer: <CaseView cases={cases} activeId={activeCase} onActiveChange={setActiveCase} /> } : {})}
226
513
  {...(subView === 'run' ? {
227
514
  layer: <RunView
228
515
  data={data}
516
+ cases={cases}
229
517
  symptoms={symptoms}
230
- symptomId={symptomId}
518
+ symptomId={activeSymptomId}
231
519
  onSymptomChange={setSymptomId}
232
- onSelectNode={id => { setFocusId(id); setSubView('graph') }}
520
+ onSelectNode={id => { setSelectedId(id); setFocusId(id); setSubView('graph') }}
233
521
  />,
234
522
  } : {})}
235
523
  {...(subView === 'graph' ? {
524
+ // 底中**只剩文本搜索 + 命中数**(§7.1):失败原因切面在左栏第 ③ 组,
525
+ // 「保留 1 跳邻居」在右上读法排。搜索留在底部,因为它是跨全图的动作。
236
526
  bottomCenter: <FilterBar
237
527
  query={query}
238
528
  onQuery={setQuery}
239
- modes={modes}
240
- onToggleMode={toggleMode}
241
- includeNeighbors={includeNeighbors}
242
- onIncludeNeighbors={setIncludeNeighbors}
243
529
  filtering={filtering}
244
530
  onClear={clearFilter}
245
531
  hit={filtered.nodes.length}
246
532
  total={data.nodes.length}
247
533
  />,
248
- bottomRight: <StatsStrip
534
+ // 左下:读数(统计 + 数据来源),v1.4 从右下搬来。
535
+ bottomLeft: <StatsStrip
249
536
  stats={stats}
250
- source={remote?.source ?? 'seed 数据'}
537
+ source={remote?.source ?? '还没有排查知识'}
251
538
  problems={remote?.problems ?? []}
252
539
  />,
540
+ // 右侧副栏贴右上展开:图例正文 + 选中节点详情。
253
541
  side: <>
254
- {/* 「全景 / 聚焦邻域」是画布读法,不是筛选,所以它贴在图例下面而不是筛选条里。 */}
255
- <div className="mb-1.5 flex justify-end">
256
- <Button
257
- size="sm"
258
- className="h-[26px] min-h-[26px] px-2 text-[11px]"
259
- variant={overview ? 'default' : 'outline'}
260
- onClick={() => setOverview(!overview)}
261
- >{overview ? '全景' : '聚焦 1 跳'}</Button>
262
- </div>
542
+ {/* ⑤ 段(实例层)默认折叠。展开它就把每次排查的产物铺进来 ——
543
+ 类型层会被挤成小点,所以它是**显式的一次选择**。 */}
544
+ {layout === 'line' && <label className="mb-1.5 flex cursor-pointer items-center gap-1.5 rounded-md border border-tita-line bg-tita-surface px-2 py-1 text-[10.5px] text-tita-ink-muted">
545
+ <input
546
+ type="checkbox"
547
+ className="h-[12px] w-[12px] accent-[var(--tita-primary)]"
548
+ checked={instanceOpen}
549
+ onChange={event => setInstanceOpen(event.target.checked)}
550
+ />
551
+ ⑤ 段({laneCounts.instance.total} 条实际查询与结果)
552
+ </label>}
263
553
  <div className="flex flex-col gap-1.5">
264
554
  {legendOpen && <LegendBody stats={stats} />}
265
- {focused && <NodeDetailCard node={focused} />}
555
+ {focused && <NodeDetailCard node={focused} onClear={selectedId ? clearSelection : undefined} />}
266
556
  </div>
267
557
  </>,
268
558
  } : {})}
@@ -274,7 +564,7 @@ export function AttributionTab({ visible }: { visible: boolean }): ReactNode {
274
564
  * 它曾经浮在画布的左上角。搬到外面来的理由不是审美:**页面切换是页面级控件,
275
565
  * 画布是它切换的内容之一**。放在画布里,等于把「换一页」和「改这张图怎么画」
276
566
  * 混成同一层的浮层 —— 而 §7.1 的分区原则正好相反(改「怎么画」的贴画布,
277
- * 换「看哪一页」的贴页面)。搬出来之后画布左上角只剩布局与类型两个开关。
567
+ * 换「看哪一页」的贴页面)。搬出来之后画布左上角只剩四组切面。
278
568
  *
279
569
  * 说明文案不在切换器内部,而是同一行的右侧:一整行读下来是
280
570
  * 「你在哪一页 → 这一页回答什么问题」,比把它折到切换器下面更省一行高度。
@@ -289,6 +579,25 @@ export function AttributionTab({ visible }: { visible: boolean }): ReactNode {
289
579
  />
290
580
  const viewHint = viewItems.find(item => item.id === subView)?.hint ?? ''
291
581
 
582
+ /**
583
+ * 空态优先于一切。
584
+ *
585
+ * 这一栏原来没有空态:库是空的也照样画一张图 —— 只不过画的是内置示例的 34 个节点。
586
+ * 用户没法分辨「这是我的库」还是「这是样例」,实跑反馈里那句「为什么把我默认的知识库
587
+ * 内容也带进去了」问的就是这件事。现在空的就明说空,并且把「怎么让它有东西」摆出来。
588
+ */
589
+ if (phase !== 'ready' || data.nodes.length === 0) {
590
+ return <AttributionEmpty
591
+ phase={phase}
592
+ canLoadSample={canLoadSample}
593
+ busy={sampleBusy}
594
+ {...(sampleError ? { loadError: sampleError } : {})}
595
+ onLoadSample={() => void loadSample()}
596
+ onRetry={() => setReloadToken(token => token + 1)}
597
+ {...(onOpenKnowledge ? { onOpenKnowledge } : {})}
598
+ />
599
+ }
600
+
292
601
  if (full) {
293
602
  return createPortal(
294
603
  <div
@@ -323,6 +632,21 @@ export function AttributionTab({ visible }: { visible: boolean }): ReactNode {
323
632
  }
324
633
 
325
634
  return <div className="flex min-h-0 flex-1 flex-col gap-1.5">
635
+ {/* 载入示例之后的一句话。写清楚「写了什么、写在哪、能不能改」—— 载入是一次真实的
636
+ 写入,用户有权知道它落在自己知识树的哪两个目录里。 */}
637
+ {sampleNotice && <div
638
+ role="status"
639
+ className="flex items-start gap-1.5 rounded-lg border border-tita-line bg-tita-surface-muted p-2 text-[10.5px] leading-relaxed text-tita-ink-muted"
640
+ >
641
+ <Sparkles size={12} className="mt-0.5 shrink-0" />
642
+ <span className="min-w-0 flex-1">{sampleNotice}</span>
643
+ <button
644
+ type="button"
645
+ aria-label="关闭提示"
646
+ className="shrink-0 rounded-sm focus-visible:outline-[3px] focus-visible:outline-offset-1 focus-visible:outline-tita-focus-ring"
647
+ onClick={() => setSampleNotice(null)}
648
+ ><X size={12} /></button>
649
+ </div>}
326
650
  <div className="flex min-w-0 items-center gap-2">
327
651
  {switcher}
328
652
  <p className="min-w-0 flex-1 truncate text-[10px] text-tita-ink-muted">{viewHint}</p>
@@ -330,3 +654,80 @@ export function AttributionTab({ visible }: { visible: boolean }): ReactNode {
330
654
  {stage}
331
655
  </div>
332
656
  }
657
+
658
+ /** 大类 id → 它下面的子类 id。取消大类时要把这些子类一起撤掉。 */
659
+ const PROBLEM_CLASS_IDS_TO_SUBCLASSES: Record<string, string[]> = Object.fromEntries(
660
+ PROBLEM_CLASSES.map(item => [item.id, item.subclasses.map(sub => sub.id)]),
661
+ )
662
+
663
+ /**
664
+ * 空态:这一栏还没有东西可看时,界面上唯一该出现的那一块。
665
+ *
666
+ * 三件事按顺序回答,不多不少:
667
+ * 1. **这里会有什么** —— 排查经验(现象 → 假设 → 检查点,再由案例串成路径),
668
+ * 以及它的出处(wiki 卡片,不是凭空生成的);
669
+ * 2. **怎么让它有东西** —— 去知识库录来源(主操作);
670
+ * 3. **示例是什么、写在哪** —— 一份排查手册加三篇复盘,写进你自己的
671
+ * `01_source_truth/` 与 `02_wiki/diagnosis/`,载入后就是普通来源与卡片。
672
+ *
673
+ * 它**不是**一张卡片:这里只有一条正文,套进 `Card` 就变成「空页里放了一张卡」,
674
+ * 而卡片是给并列内容用的容器。读失败与读取中是同一块地方的另外两种状态。
675
+ */
676
+ function AttributionEmpty({ phase, canLoadSample, busy, loadError, onLoadSample, onRetry, onOpenKnowledge }: {
677
+ phase: 'loading' | 'ready' | 'error'
678
+ canLoadSample: boolean
679
+ busy: boolean
680
+ loadError?: string
681
+ onLoadSample: () => void
682
+ onRetry: () => void
683
+ onOpenKnowledge?: () => void
684
+ }): ReactNode {
685
+ if (phase === 'loading') {
686
+ return <div className="flex min-h-0 flex-1 items-center justify-center">
687
+ <p role="status" className="text-[11px] text-tita-ink-muted">正在读取这个知识库的排查知识…</p>
688
+ </div>
689
+ }
690
+
691
+ if (phase === 'error') {
692
+ return <div className="flex min-h-0 flex-1 items-center justify-center">
693
+ <div className="flex max-w-[300px] flex-col items-start gap-2">
694
+ <h3 className="text-[13px] font-semibold text-tita-ink">没能读到这个知识库的排查知识</h3>
695
+ <p className="text-[11px] leading-relaxed text-tita-ink-muted">
696
+ 这一栏读的是 `/diagnosis/graph`,它没答话。宿主刚起来、或者这个知识库的根目录暂时读不到时会这样。
697
+ </p>
698
+ <Button size="sm" variant="outline" className="h-[26px] min-h-[26px] px-2 text-[11px]" onClick={onRetry}>重试</Button>
699
+ </div>
700
+ </div>
701
+ }
702
+
703
+ return <div className="flex min-h-0 flex-1 items-center justify-center p-3">
704
+ <div className="flex max-w-[320px] flex-col items-start gap-2">
705
+ <h3 className="text-[13px] font-semibold text-tita-ink">这个知识库还没有排查知识</h3>
706
+ <p className="text-[11px] leading-relaxed text-tita-ink-muted">
707
+ 这一栏画的是排查经验:问题现象 → 假设原因 → 检查点,再由案例把它们串成一条走过的路径。
708
+ 它由 wiki 卡片编译而来 —— 先在「知识库」里录一份手册、几篇复盘,或者把卡片写出来,
709
+ 这里就会有东西。
710
+ </p>
711
+ <div className="flex flex-wrap gap-1.5">
712
+ {onOpenKnowledge && <Button
713
+ size="sm"
714
+ variant="default"
715
+ className="h-[26px] min-h-[26px] px-2 text-[11px]"
716
+ onClick={onOpenKnowledge}
717
+ ><Database size={12} />去知识库录来源</Button>}
718
+ {canLoadSample && <Button
719
+ size="sm"
720
+ variant={onOpenKnowledge ? 'outline' : 'default'}
721
+ className="h-[26px] min-h-[26px] px-2 text-[11px]"
722
+ disabled={busy}
723
+ onClick={onLoadSample}
724
+ ><Sparkles size={12} />{busy ? '载入中…' : '载入内置示例'}</Button>}
725
+ </div>
726
+ {canLoadSample && <p className="text-[10.5px] leading-relaxed text-tita-ink-muted">
727
+ 示例是一份排查手册加三篇复盘。载入后它们写进你自己的 <b>01_source_truth/</b> 与
728
+ <b> 02_wiki/diagnosis/</b>,就是普通的来源与卡片 —— 可以照常改、照常删。
729
+ </p>}
730
+ {loadError && <p role="alert" className="text-[10.5px] leading-relaxed text-tita-danger">载入失败:{loadError}</p>}
731
+ </div>
732
+ </div>
733
+ }