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.
- package/README.md +31 -7
- package/cordis.patch.yml +11 -5
- package/lib/client.js +5557 -3056
- package/lib/doc-page.js +7740 -0
- package/lib/types/convert/host.d.ts.map +1 -1
- package/lib/types/convert/host.js +36 -9
- package/lib/types/convert/host.js.map +1 -1
- package/lib/types/diagnosis/cards.d.ts +8 -7
- package/lib/types/diagnosis/cards.d.ts.map +1 -1
- package/lib/types/diagnosis/cards.js +119 -49
- package/lib/types/diagnosis/cards.js.map +1 -1
- package/lib/types/diagnosis/run.d.ts +51 -0
- package/lib/types/diagnosis/run.d.ts.map +1 -0
- package/lib/types/diagnosis/run.js +239 -0
- package/lib/types/diagnosis/run.js.map +1 -0
- package/lib/types/diagnosis/schema.d.ts +25 -2
- package/lib/types/diagnosis/schema.d.ts.map +1 -1
- package/lib/types/diagnosis/schema.js +64 -25
- package/lib/types/diagnosis/schema.js.map +1 -1
- package/lib/types/diagnosis/seed-source.d.ts +17 -2
- package/lib/types/diagnosis/seed-source.d.ts.map +1 -1
- package/lib/types/diagnosis/seed-source.js +18 -1
- package/lib/types/diagnosis/seed-source.js.map +1 -1
- package/lib/types/diagnosis/seed.d.ts +32 -42
- package/lib/types/diagnosis/seed.d.ts.map +1 -1
- package/lib/types/diagnosis/seed.js +187 -51
- package/lib/types/diagnosis/seed.js.map +1 -1
- package/lib/types/diagnosis/store.d.ts +114 -26
- package/lib/types/diagnosis/store.d.ts.map +1 -1
- package/lib/types/diagnosis/store.js +336 -167
- package/lib/types/diagnosis/store.js.map +1 -1
- package/lib/types/diagnosis/types.d.ts +168 -16
- package/lib/types/diagnosis/types.d.ts.map +1 -1
- package/lib/types/diagnosis/types.js +1 -1
- package/lib/types/diagnosis/types.js.map +1 -1
- package/lib/types/document-page.d.ts +39 -0
- package/lib/types/document-page.d.ts.map +1 -0
- package/lib/types/document-page.js +43 -0
- package/lib/types/document-page.js.map +1 -0
- package/lib/types/fs-atomic.d.ts +12 -0
- package/lib/types/fs-atomic.d.ts.map +1 -0
- package/lib/types/fs-atomic.js +42 -0
- package/lib/types/fs-atomic.js.map +1 -0
- package/lib/types/index.d.ts.map +1 -1
- package/lib/types/index.js +214 -24
- package/lib/types/index.js.map +1 -1
- package/lib/types/knowledge-bases.d.ts +10 -2
- package/lib/types/knowledge-bases.d.ts.map +1 -1
- package/lib/types/knowledge-bases.js +20 -18
- package/lib/types/knowledge-bases.js.map +1 -1
- package/lib/types/proposals.d.ts +166 -0
- package/lib/types/proposals.d.ts.map +1 -0
- package/lib/types/proposals.js +238 -0
- package/lib/types/proposals.js.map +1 -0
- package/lib/types/runtime.d.ts +199 -7
- package/lib/types/runtime.d.ts.map +1 -1
- package/lib/types/runtime.js +728 -145
- package/lib/types/runtime.js.map +1 -1
- package/lib/types/settings-schema.d.ts +2 -2
- package/lib/types/types.d.ts +61 -10
- package/lib/types/types.d.ts.map +1 -1
- package/lib/types/types.js +56 -8
- package/lib/types/types.js.map +1 -1
- package/lib/types/wiki-types.d.ts +8 -1
- package/lib/types/wiki-types.d.ts.map +1 -1
- package/lib/types/wiki-types.js +16 -25
- package/lib/types/wiki-types.js.map +1 -1
- package/package.json +4 -3
- package/preset/tita-data-agent/agent.cordis.yml +11 -5
- package/src/client/DirectoryPicker.tsx +12 -12
- package/src/client/InfoHint.tsx +21 -8
- package/src/client/IntentTab.tsx +20 -26
- package/src/client/KnowledgeBaseTab.tsx +190 -80
- package/src/client/KnowledgePanel.tsx +42 -9
- package/src/client/KnowledgeStepRail.tsx +3 -0
- package/src/client/NodeSettings.tsx +20 -22
- package/src/client/PreviewSections.tsx +87 -33
- package/src/client/ProposalReview.tsx +313 -0
- package/src/client/TypeDefinitionDrawer.tsx +72 -38
- package/src/client/WikiTypeEditor.tsx +193 -77
- package/src/client/arch/AgentArchitectureGraph.tsx +143 -14
- package/src/client/arch/NodeLogicPanel.tsx +6 -3
- package/src/client/arch/model.ts +164 -26
- package/src/client/architecture.css +29 -9
- package/src/client/architecture.tsx +54 -29
- package/src/client/attribution/AttributionGraph.tsx +481 -34
- package/src/client/attribution/AttributionTab.tsx +507 -106
- package/src/client/attribution/CaseView.tsx +9 -14
- package/src/client/attribution/GraphStage.tsx +25 -18
- package/src/client/attribution/RunView.tsx +27 -9
- package/src/client/attribution/StageChrome.tsx +296 -140
- package/src/client/attribution/graph-options.ts +611 -47
- package/src/client/attribution/model.ts +559 -30
- package/src/client/convert/ConvertPanel.tsx +6 -1
- package/src/client/doc-page.css +134 -0
- package/src/client/doc-page.tsx +263 -0
- package/src/client/panel-state.ts +105 -0
- package/src/client/request.ts +36 -0
- package/src/client/styles.css +317 -43
- package/src/client/useFitScale.ts +15 -2
- package/src/client/wiki-type-groups.ts +86 -0
- package/src/convert/host.ts +41 -7
- package/src/diagnosis/cards.ts +120 -50
- package/src/diagnosis/run.ts +245 -0
- package/src/diagnosis/schema.ts +67 -26
- package/src/diagnosis/seed-source.ts +28 -3
- package/src/diagnosis/seed.ts +187 -85
- package/src/diagnosis/store.ts +358 -174
- package/src/diagnosis/types.ts +181 -16
- package/src/document-page.ts +51 -0
- package/src/fs-atomic.ts +42 -0
- package/src/index.ts +209 -22
- package/src/knowledge-bases.ts +20 -18
- package/src/proposals.ts +337 -0
- package/src/runtime.ts +736 -135
- package/src/types.ts +113 -19
- 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,
|
|
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
|
-
|
|
18
|
-
|
|
19
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
76
|
+
/** 默认动线:它是 §7.2 定的默认读法。 */
|
|
77
|
+
const [layout, setLayout] = useState<GraphLayout>('line')
|
|
46
78
|
const [overview, setOverview] = useState(true)
|
|
47
|
-
const [focusId, setFocusId] = useState<string | undefined>(
|
|
48
|
-
|
|
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
|
-
|
|
115
|
+
/** 左栏:面板态默认收起成 pill(380px 塞不下四组控件 + 五段泳道,§7.7 末段)。 */
|
|
116
|
+
const [railOpen, setRailOpen] = useState(false)
|
|
51
117
|
const [legendOpen, setLegendOpen] = useState(false)
|
|
52
|
-
|
|
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
|
-
*
|
|
57
|
-
*
|
|
58
|
-
*
|
|
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<
|
|
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('
|
|
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
|
-
|
|
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)
|
|
78
|
-
const
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
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
|
-
() => ({
|
|
120
|
-
|
|
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 ?
|
|
130
|
-
[
|
|
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
|
|
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 (
|
|
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
|
|
141
|
-
|
|
142
|
-
|
|
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
|
-
|
|
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)
|
|
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(
|
|
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 === '
|
|
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={
|
|
198
|
-
|
|
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
|
-
? <
|
|
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
|
-
|
|
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
|
|
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={<
|
|
219
|
-
|
|
220
|
-
|
|
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
|
-
|
|
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={
|
|
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
|
-
|
|
534
|
+
// 左下:读数(统计 + 数据来源),v1.4 从右下搬来。
|
|
535
|
+
bottomLeft: <StatsStrip
|
|
249
536
|
stats={stats}
|
|
250
|
-
source={remote?.source ?? '
|
|
537
|
+
source={remote?.source ?? '还没有排查知识'}
|
|
251
538
|
problems={remote?.problems ?? []}
|
|
252
539
|
/>,
|
|
540
|
+
// 右侧副栏贴右上展开:图例正文 + 选中节点详情。
|
|
253
541
|
side: <>
|
|
254
|
-
{/*
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
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
|
+
}
|