dsh-data-domain-knowledge 0.1.0-alpha.1
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/LICENSE +21 -0
- package/README.md +360 -0
- package/cordis.patch.yml +410 -0
- package/eval/README.md +76 -0
- package/eval/evaluate-retrieval.mjs +121 -0
- package/eval/queries.example.jsonl +2 -0
- package/lib/client.js +111415 -0
- package/lib/types/config-schema.d.ts +35 -0
- package/lib/types/config-schema.d.ts.map +1 -0
- package/lib/types/config-schema.js +34 -0
- package/lib/types/config-schema.js.map +1 -0
- package/lib/types/convert/core-loader.d.ts +32 -0
- package/lib/types/convert/core-loader.d.ts.map +1 -0
- package/lib/types/convert/core-loader.js +60 -0
- package/lib/types/convert/core-loader.js.map +1 -0
- package/lib/types/convert/host.d.ts +88 -0
- package/lib/types/convert/host.d.ts.map +1 -0
- package/lib/types/convert/host.js +479 -0
- package/lib/types/convert/host.js.map +1 -0
- package/lib/types/convert/ingest.d.ts +125 -0
- package/lib/types/convert/ingest.d.ts.map +1 -0
- package/lib/types/convert/ingest.js +228 -0
- package/lib/types/convert/ingest.js.map +1 -0
- package/lib/types/convert/settings-schema.d.ts +18 -0
- package/lib/types/convert/settings-schema.d.ts.map +1 -0
- package/lib/types/convert/settings-schema.js +26 -0
- package/lib/types/convert/settings-schema.js.map +1 -0
- package/lib/types/convert/types.d.ts +46 -0
- package/lib/types/convert/types.d.ts.map +1 -0
- package/lib/types/convert/types.js +41 -0
- package/lib/types/convert/types.js.map +1 -0
- package/lib/types/diagnosis/cards.d.ts +71 -0
- package/lib/types/diagnosis/cards.d.ts.map +1 -0
- package/lib/types/diagnosis/cards.js +482 -0
- package/lib/types/diagnosis/cards.js.map +1 -0
- package/lib/types/diagnosis/problem-classes.d.ts +51 -0
- package/lib/types/diagnosis/problem-classes.d.ts.map +1 -0
- package/lib/types/diagnosis/problem-classes.js +66 -0
- package/lib/types/diagnosis/problem-classes.js.map +1 -0
- package/lib/types/diagnosis/schema.d.ts +65 -0
- package/lib/types/diagnosis/schema.d.ts.map +1 -0
- package/lib/types/diagnosis/schema.js +208 -0
- package/lib/types/diagnosis/schema.js.map +1 -0
- package/lib/types/diagnosis/seed-source.d.ts +20 -0
- package/lib/types/diagnosis/seed-source.d.ts.map +1 -0
- package/lib/types/diagnosis/seed-source.js +96 -0
- package/lib/types/diagnosis/seed-source.js.map +1 -0
- package/lib/types/diagnosis/seed.d.ts +50 -0
- package/lib/types/diagnosis/seed.d.ts.map +1 -0
- package/lib/types/diagnosis/seed.js +242 -0
- package/lib/types/diagnosis/seed.js.map +1 -0
- package/lib/types/diagnosis/store.d.ts +81 -0
- package/lib/types/diagnosis/store.d.ts.map +1 -0
- package/lib/types/diagnosis/store.js +409 -0
- package/lib/types/diagnosis/store.js.map +1 -0
- package/lib/types/diagnosis/types.d.ts +124 -0
- package/lib/types/diagnosis/types.d.ts.map +1 -0
- package/lib/types/diagnosis/types.js +13 -0
- package/lib/types/diagnosis/types.js.map +1 -0
- package/lib/types/directory-picker.d.ts +94 -0
- package/lib/types/directory-picker.d.ts.map +1 -0
- package/lib/types/directory-picker.js +110 -0
- package/lib/types/directory-picker.js.map +1 -0
- package/lib/types/extract-model.d.ts +76 -0
- package/lib/types/extract-model.d.ts.map +1 -0
- package/lib/types/extract-model.js +193 -0
- package/lib/types/extract-model.js.map +1 -0
- package/lib/types/extract.d.ts +121 -0
- package/lib/types/extract.d.ts.map +1 -0
- package/lib/types/extract.js +549 -0
- package/lib/types/extract.js.map +1 -0
- package/lib/types/fts.d.ts +96 -0
- package/lib/types/fts.d.ts.map +1 -0
- package/lib/types/fts.js +184 -0
- package/lib/types/fts.js.map +1 -0
- package/lib/types/index.d.ts +39 -0
- package/lib/types/index.d.ts.map +1 -0
- package/lib/types/index.js +1112 -0
- package/lib/types/index.js.map +1 -0
- package/lib/types/intent/engine.d.ts +108 -0
- package/lib/types/intent/engine.d.ts.map +1 -0
- package/lib/types/intent/engine.js +501 -0
- package/lib/types/intent/engine.js.map +1 -0
- package/lib/types/intent/host.d.ts +88 -0
- package/lib/types/intent/host.d.ts.map +1 -0
- package/lib/types/intent/host.js +389 -0
- package/lib/types/intent/host.js.map +1 -0
- package/lib/types/intent/preset-tools.d.ts +37 -0
- package/lib/types/intent/preset-tools.d.ts.map +1 -0
- package/lib/types/intent/preset-tools.js +45 -0
- package/lib/types/intent/preset-tools.js.map +1 -0
- package/lib/types/intent/settings-schema.d.ts +59 -0
- package/lib/types/intent/settings-schema.d.ts.map +1 -0
- package/lib/types/intent/settings-schema.js +57 -0
- package/lib/types/intent/settings-schema.js.map +1 -0
- package/lib/types/intent/store.d.ts +75 -0
- package/lib/types/intent/store.d.ts.map +1 -0
- package/lib/types/intent/store.js +139 -0
- package/lib/types/intent/store.js.map +1 -0
- package/lib/types/intent/tools.d.ts +63 -0
- package/lib/types/intent/tools.d.ts.map +1 -0
- package/lib/types/intent/tools.js +108 -0
- package/lib/types/intent/tools.js.map +1 -0
- package/lib/types/intent/types.d.ts +189 -0
- package/lib/types/intent/types.d.ts.map +1 -0
- package/lib/types/intent/types.js +62 -0
- package/lib/types/intent/types.js.map +1 -0
- package/lib/types/knowledge-bases.d.ts +266 -0
- package/lib/types/knowledge-bases.d.ts.map +1 -0
- package/lib/types/knowledge-bases.js +1107 -0
- package/lib/types/knowledge-bases.js.map +1 -0
- package/lib/types/layout.d.ts +262 -0
- package/lib/types/layout.d.ts.map +1 -0
- package/lib/types/layout.js +312 -0
- package/lib/types/layout.js.map +1 -0
- package/lib/types/migrate.d.ts +110 -0
- package/lib/types/migrate.d.ts.map +1 -0
- package/lib/types/migrate.js +280 -0
- package/lib/types/migrate.js.map +1 -0
- package/lib/types/recall-rank.d.ts +158 -0
- package/lib/types/recall-rank.d.ts.map +1 -0
- package/lib/types/recall-rank.js +261 -0
- package/lib/types/recall-rank.js.map +1 -0
- package/lib/types/runtime.d.ts +568 -0
- package/lib/types/runtime.d.ts.map +1 -0
- package/lib/types/runtime.js +3045 -0
- package/lib/types/runtime.js.map +1 -0
- package/lib/types/settings-schema.d.ts +117 -0
- package/lib/types/settings-schema.d.ts.map +1 -0
- package/lib/types/settings-schema.js +92 -0
- package/lib/types/settings-schema.js.map +1 -0
- package/lib/types/settings-volatile.d.ts +102 -0
- package/lib/types/settings-volatile.d.ts.map +1 -0
- package/lib/types/settings-volatile.js +114 -0
- package/lib/types/settings-volatile.js.map +1 -0
- package/lib/types/types.d.ts +799 -0
- package/lib/types/types.d.ts.map +1 -0
- package/lib/types/types.js +387 -0
- package/lib/types/types.js.map +1 -0
- package/lib/types/wiki-types.d.ts +68 -0
- package/lib/types/wiki-types.d.ts.map +1 -0
- package/lib/types/wiki-types.js +322 -0
- package/lib/types/wiki-types.js.map +1 -0
- package/package.json +101 -0
- package/preset/tita-data-agent/agent.cordis.yml +376 -0
- package/preset/tita-data-agent/preset.yml +3 -0
- package/python/requirements.txt +8 -0
- package/scripts/doctor.mjs +70 -0
- package/scripts/e2e-pipeline.mjs +355 -0
- package/scripts/install-agent-preset.mjs +92 -0
- package/scripts/setup-embedding.sh +34 -0
- package/scripts/sync-agent-preset.mjs +188 -0
- package/src/client/AgentPresetCard.tsx +247 -0
- package/src/client/DirectoryPicker.tsx +357 -0
- package/src/client/InfoHint.tsx +65 -0
- package/src/client/IntentLogicTab.tsx +185 -0
- package/src/client/IntentTab.tsx +715 -0
- package/src/client/KnowledgeBaseTab.tsx +1454 -0
- package/src/client/KnowledgeGraphView.tsx +306 -0
- package/src/client/KnowledgePanel.tsx +381 -0
- package/src/client/KnowledgeStepRail.tsx +60 -0
- package/src/client/NodeSettings.tsx +392 -0
- package/src/client/PreviewSections.tsx +116 -0
- package/src/client/RecallPanel.tsx +399 -0
- package/src/client/TypeDefinitionDrawer.tsx +215 -0
- package/src/client/WikiFlowCard.tsx +226 -0
- package/src/client/WikiTypeEditor.tsx +332 -0
- package/src/client/agent-preset.ts +121 -0
- package/src/client/arch/AgentArchitectureGraph.tsx +219 -0
- package/src/client/arch/NodeLogicPanel.tsx +133 -0
- package/src/client/arch/ProblemDetailPanel.tsx +88 -0
- package/src/client/arch/model.ts +691 -0
- package/src/client/architecture.css +191 -0
- package/src/client/architecture.tsx +530 -0
- package/src/client/attribution/AttributionGraph.tsx +104 -0
- package/src/client/attribution/AttributionTab.tsx +332 -0
- package/src/client/attribution/CaseView.tsx +102 -0
- package/src/client/attribution/GraphStage.tsx +87 -0
- package/src/client/attribution/RunView.tsx +184 -0
- package/src/client/attribution/StageChrome.tsx +403 -0
- package/src/client/attribution/ViewSwitch.tsx +107 -0
- package/src/client/attribution/graph-options.ts +381 -0
- package/src/client/attribution/model.ts +462 -0
- package/src/client/config-scope.ts +119 -0
- package/src/client/convert/ConvertPanel.tsx +594 -0
- package/src/client/convert/styles.css +138 -0
- package/src/client/convert/styles.ts +49 -0
- package/src/client/format.ts +39 -0
- package/src/client/graph/layout.ts +181 -0
- package/src/client/graph.css +113 -0
- package/src/client/index.ts +98 -0
- package/src/client/problem-domains.ts +114 -0
- package/src/client/styles.css +940 -0
- package/src/client/styles.ts +11 -0
- package/src/client/useFitScale.ts +68 -0
- package/src/config-schema.ts +45 -0
- package/src/convert/core-loader.ts +68 -0
- package/src/convert/host.ts +579 -0
- package/src/convert/ingest.ts +367 -0
- package/src/convert/settings-schema.ts +27 -0
- package/src/convert/types.ts +65 -0
- package/src/diagnosis/cards.ts +498 -0
- package/src/diagnosis/problem-classes.ts +117 -0
- package/src/diagnosis/schema.ts +260 -0
- package/src/diagnosis/seed-source.ts +107 -0
- package/src/diagnosis/seed.ts +291 -0
- package/src/diagnosis/store.ts +432 -0
- package/src/diagnosis/types.ts +152 -0
- package/src/directory-picker.ts +164 -0
- package/src/extract-model.ts +227 -0
- package/src/extract.ts +632 -0
- package/src/fts.ts +203 -0
- package/src/index.ts +1091 -0
- package/src/intent/README.md +63 -0
- package/src/intent/engine.ts +527 -0
- package/src/intent/host.ts +462 -0
- package/src/intent/preset-tools.ts +48 -0
- package/src/intent/settings-schema.ts +63 -0
- package/src/intent/store.ts +143 -0
- package/src/intent/tools.ts +139 -0
- package/src/intent/types.ts +245 -0
- package/src/knowledge-bases.ts +1160 -0
- package/src/layout.ts +350 -0
- package/src/migrate.ts +262 -0
- package/src/python/embedding_worker.py +471 -0
- package/src/recall-rank.ts +299 -0
- package/src/runtime.ts +3069 -0
- package/src/settings-schema.ts +94 -0
- package/src/settings-volatile.ts +122 -0
- package/src/types.ts +1140 -0
- package/src/wiki-types.ts +357 -0
|
@@ -0,0 +1,1454 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 知识库 Tab —— 一个知识库的**内容**,按知识链路拆成四个分栏。
|
|
3
|
+
*
|
|
4
|
+
* 分栏与架构图上的节点对应(SourceRaw / Wiki / 知识图谱 / **召回重排**)。
|
|
5
|
+
* 0033 把「召回」与「重排」合成一栏:它们回答的是同一个问题(跑一次真实召回长什么样),
|
|
6
|
+
* 分栏时用户得先跑召回、再切到重排才能看到同一次运行的全貌。流水线上它们仍是两步,
|
|
7
|
+
* 合的是界面 —— 架构图里也仍是两个节点,只是都落到这一栏。
|
|
8
|
+
* 图上一个节点点「去配置」,落到的就是这里的某一栏。
|
|
9
|
+
*
|
|
10
|
+
* 0034 把这条分工线收干净了 —— **每一层只装一种东西,一个旋钮只有一个家**:
|
|
11
|
+
*
|
|
12
|
+
* 1. **四个分栏只装内容**:这一段的产物、计数、真实运行面板。分栏里不再有任何设置块,
|
|
13
|
+
* 也不再留「设置已移到顶部齿轮」那种**路牌**(路牌是搬迁留下的债,它说明入口离
|
|
14
|
+
* 「你会想起要改它」的那一刻太远)。参数改成卡片头上一枚「参数」chip:打开的还是
|
|
15
|
+
* 那一个设置抽屉,只是落到对应的分块上。
|
|
16
|
+
* 2. **设置抽屉装方案级参数**(存储 / 收录闸门 / 构建 / 召回 / 重排 / Agent preset):
|
|
17
|
+
* 一个 namespace,整个方案一份。
|
|
18
|
+
* 3. **类型定义抽屉装本库词汇表**(来源类型 / Wiki 类型):按知识库持久化,切库就换一套。
|
|
19
|
+
* 它与设置是**两种作用域**,所以是两个容器而不是一个 —— 见 `TypeDefinitionDrawer.tsx`。
|
|
20
|
+
*
|
|
21
|
+
* 产品逻辑参考腾讯 WeKnora:知识库是一等对象(独立配置、独立内容)。每个知识库有自己的
|
|
22
|
+
* 根目录、来源分类注册表与 Wiki 类型注册表;知识库下有文件列表,每个文件有详情页与版本
|
|
23
|
+
* 历史(可回滚)。
|
|
24
|
+
*
|
|
25
|
+
* 0029:「文件类型 → 解析引擎」那张规则表撤掉了 —— 它的 `engine` 一列没有任何一处读
|
|
26
|
+
* 它(真正选适配器的是转换内核自己的格式表)。上传选择框的 `accept` 改为问内核要,
|
|
27
|
+
* 文件类型筛选项与计数改为从这一库真实的文档推导。
|
|
28
|
+
*
|
|
29
|
+
* 0025 的两处结构性变化:
|
|
30
|
+
* 1. **编译设置里的「Schema 类型」撤掉**。wiki 类型注册表就是 schema,入口在「类型定义」;
|
|
31
|
+
* 那个逗号分隔的 `schemaTypes` 文本框既不参与校验也不被生成逻辑读取。
|
|
32
|
+
* 2. **Wiki 也有一等版本历史**。卡片每次正文变化先快照到 `04_memory/card-versions/`,
|
|
33
|
+
* 可以回滚,回滚本身也可回滚 —— 与 SourceRaw 用同一套模型。
|
|
34
|
+
*/
|
|
35
|
+
|
|
36
|
+
import { useCallback, useEffect, useMemo, useRef, useState, type ChangeEvent, type ReactNode } from 'react'
|
|
37
|
+
import {
|
|
38
|
+
AlertCircle, Check, Eye, FileText, FileUp, FolderOpen,
|
|
39
|
+
Layers3, RefreshCw, Search, ShieldCheck, Tags, Trash2, Upload, X,
|
|
40
|
+
} from '@tita-data-agent/ui/icons'
|
|
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'
|
|
43
|
+
import {
|
|
44
|
+
Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle,
|
|
45
|
+
Drawer, DrawerBody, DrawerContent, DrawerDescription, DrawerHeader, DrawerTitle,
|
|
46
|
+
} from '@tita-data-agent/ui/overlays'
|
|
47
|
+
import { ObjectPicker } from '@tita-data-agent/ui/workspace'
|
|
48
|
+
import { RestoreDialog, type VersionManagerEntry } from '@tita-data-agent/ui/versions'
|
|
49
|
+
import { styles } from './styles.ts'
|
|
50
|
+
import { formatBytes, formatDateTime, formatTime } from './format.ts'
|
|
51
|
+
import { PreviewContent, PreviewMeta, PreviewVersions } from './PreviewSections.tsx'
|
|
52
|
+
import type { KnowledgeTab } from './architecture.tsx'
|
|
53
|
+
import { KnowledgeStepRail } from './KnowledgeStepRail.tsx'
|
|
54
|
+
import { NodeSettingsPanel, SettingsChip, useScope, type SettingsNode, type SettingsScopeLike } from './NodeSettings.tsx'
|
|
55
|
+
import { RecallPanel } from './RecallPanel.tsx'
|
|
56
|
+
import { KnowledgeGraphView } from './KnowledgeGraphView.tsx'
|
|
57
|
+
import type { GraphEdgeView, GraphNodeView } from './graph/layout.ts'
|
|
58
|
+
import type { WikiTypeView } from './WikiTypeEditor.tsx'
|
|
59
|
+
import { WikiFlowCard } from './WikiFlowCard.tsx'
|
|
60
|
+
import { TypeDefinitionDrawer } from './TypeDefinitionDrawer.tsx'
|
|
61
|
+
|
|
62
|
+
// ------------------------------------------------------------------ contract
|
|
63
|
+
// Mirrors the plugin HTTP contract (see plugins/dsh-data-domain-knowledge/README.md).
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* 一条来源业务分类(source_type,issue 0013)。
|
|
67
|
+
*
|
|
68
|
+
* 三档默认值刻意分开:`fallback` 必存不可停用、`builtin` 可改可停用不可删、
|
|
69
|
+
* 其余为用户自定义(可增可删)。`sourceCount > 0` 时不允许删除。
|
|
70
|
+
*/
|
|
71
|
+
export interface SourceTypeDef {
|
|
72
|
+
id: string
|
|
73
|
+
label: string
|
|
74
|
+
description: string
|
|
75
|
+
signals: string[]
|
|
76
|
+
excludes: string[]
|
|
77
|
+
enabled: boolean
|
|
78
|
+
builtin: boolean
|
|
79
|
+
fallback: boolean
|
|
80
|
+
sourceCount: number
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export interface SourceClassifierStep {
|
|
84
|
+
type: string
|
|
85
|
+
rule: string
|
|
86
|
+
label: string
|
|
87
|
+
enabled: boolean
|
|
88
|
+
exists: boolean
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/** 内置**只读**判定提示词:用户看得见、改不了。 */
|
|
92
|
+
export interface SourceClassifierPrompt {
|
|
93
|
+
readonly: true
|
|
94
|
+
version: number
|
|
95
|
+
title: string
|
|
96
|
+
priority: Array<{ type: string; rule: string }>
|
|
97
|
+
steps: SourceClassifierStep[]
|
|
98
|
+
confidenceRule: string
|
|
99
|
+
fallback: { type: string; note: string }
|
|
100
|
+
escalation: { code: string; note: string }
|
|
101
|
+
extensionRule: string
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
export interface SourceTypeCatalog {
|
|
105
|
+
types: SourceTypeDef[]
|
|
106
|
+
classifierPrompt: SourceClassifierPrompt
|
|
107
|
+
fallbackTypeId: string
|
|
108
|
+
builtinIds: string[]
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* 分类编辑弹窗提交的草稿(0034)。signals / excludes 在弹窗里是多行文本,提交时按行拆。
|
|
113
|
+
*
|
|
114
|
+
* 类型定义在这里、写路径在 `saveSourceType`:抽屉(`TypeDefinitionDrawer`)只负责呈现
|
|
115
|
+
* 与它自己的弹窗状态,不自己发请求 —— 同一份数据不因为多了一个入口就多一个写入口。
|
|
116
|
+
*/
|
|
117
|
+
export interface SourceTypeDraft {
|
|
118
|
+
mode: 'create' | 'edit'
|
|
119
|
+
id: string
|
|
120
|
+
label: string
|
|
121
|
+
description: string
|
|
122
|
+
signals: string
|
|
123
|
+
excludes: string
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
export interface KnowledgeBase {
|
|
127
|
+
id: string
|
|
128
|
+
name: string
|
|
129
|
+
description: string
|
|
130
|
+
rootDir: string
|
|
131
|
+
isDefault: boolean
|
|
132
|
+
sourceTypes: SourceTypeDef[]
|
|
133
|
+
/** Wiki 卡片类型注册表(0024)。 */
|
|
134
|
+
wikiTypes: WikiTypeView[]
|
|
135
|
+
/** `report` 只标记问题,`strict` 拒绝不合规的写入。 */
|
|
136
|
+
wikiSchemaMode: 'strict' | 'report'
|
|
137
|
+
/** 计算字段:这套 schema 是否已偏离出厂定义。 */
|
|
138
|
+
wikiTypesCustomized: boolean
|
|
139
|
+
createdAt: string
|
|
140
|
+
updatedAt: string
|
|
141
|
+
sourceCount: number
|
|
142
|
+
cardCount: number
|
|
143
|
+
noteCount: number
|
|
144
|
+
buildVersion: number
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
export interface KnowledgeDocument {
|
|
148
|
+
docId: string
|
|
149
|
+
title: string
|
|
150
|
+
sourceType: string
|
|
151
|
+
/** 该分类是否仍在注册表里;false 时渲染成「未知分类」。 */
|
|
152
|
+
sourceTypeKnown?: boolean
|
|
153
|
+
sourceFormat: string
|
|
154
|
+
parser: string
|
|
155
|
+
externalId: string
|
|
156
|
+
contentHash: string
|
|
157
|
+
bytes: number
|
|
158
|
+
assetCount: number
|
|
159
|
+
createdAt: string
|
|
160
|
+
updatedAt: string
|
|
161
|
+
versionCount: number
|
|
162
|
+
relativePath: string
|
|
163
|
+
ext: string
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
export interface KnowledgeDocumentDetail extends KnowledgeDocument {
|
|
167
|
+
content: string
|
|
168
|
+
frontmatter: Record<string, unknown>
|
|
169
|
+
assets: Array<{ rel: string; bytes: number; url: string }>
|
|
170
|
+
lineage?: Record<string, string>
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
export interface DocumentVersion {
|
|
174
|
+
versionId: string
|
|
175
|
+
createdAt: string
|
|
176
|
+
contentHash: string
|
|
177
|
+
bytes: number
|
|
178
|
+
sourceFormat: string
|
|
179
|
+
label: string
|
|
180
|
+
isCurrent: boolean
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/** Wiki 卡片(0025:带上 metadata,界面要能显示 schema 待补项)。 */
|
|
184
|
+
export interface KnowledgeCardView {
|
|
185
|
+
entityId: string
|
|
186
|
+
entityType: string
|
|
187
|
+
title: string
|
|
188
|
+
status: string
|
|
189
|
+
summary?: string
|
|
190
|
+
content: string
|
|
191
|
+
sourceRefs: string[]
|
|
192
|
+
metadata?: Record<string, unknown>
|
|
193
|
+
updatedAt: string
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
/** 一张 wiki 卡片的历史版本快照。 */
|
|
197
|
+
export interface WikiCardVersionView {
|
|
198
|
+
versionId: string
|
|
199
|
+
createdAt: string
|
|
200
|
+
contentHash: string
|
|
201
|
+
bytes: number
|
|
202
|
+
title: string
|
|
203
|
+
status: string
|
|
204
|
+
label: string
|
|
205
|
+
isCurrent: boolean
|
|
206
|
+
}
|
|
207
|
+
|
|
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
|
+
}
|
|
227
|
+
|
|
228
|
+
/** Text-ish formats the browser can read and POST as SourceRaw directly. */
|
|
229
|
+
const DIRECT_READ_EXT = new Set(['.md', '.markdown', '.txt', '.json', '.tsv', '.csv', '.html'])
|
|
230
|
+
|
|
231
|
+
const CONVERT_POLL_MS = 500
|
|
232
|
+
const CONVERT_TIMEOUT_MS = 180_000
|
|
233
|
+
|
|
234
|
+
interface Props {
|
|
235
|
+
visible: boolean
|
|
236
|
+
/** Tells the parent to refresh its own stats after a write. */
|
|
237
|
+
onChanged?: () => void
|
|
238
|
+
/** Lets the parent scope its recall test / upload to the selected base. */
|
|
239
|
+
onActiveBaseChange?: (base: { id: string; name: string } | null) => void
|
|
240
|
+
/** 知识库设置作用域:召回 / 重排面板读它算默认参数,收录闸门读它决定能不能上传。 */
|
|
241
|
+
knowledge?: SettingsScopeLike
|
|
242
|
+
/** 架构图节点跳转过来时要落到哪一栏。`nonce` 变化即重新应用。 */
|
|
243
|
+
focus?: { tab: KnowledgeTab; nonce: number }
|
|
244
|
+
/**
|
|
245
|
+
* 卡片头的「参数」chip 落到设置抽屉的哪一块(0034)。**设置不在这一层** —— 这里只
|
|
246
|
+
* 转交一个「打开并定位」的意图,抽屉与它读写的 scope 都还在 `KnowledgePanel` 手里。
|
|
247
|
+
*/
|
|
248
|
+
onOpenSettings?: (target: SettingsNode) => void
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
/** 卡片 metadata 里的待补项(0024 的 report 模式会把问题留在卡片上)。 */
|
|
252
|
+
function schemaIssuesOf(card: KnowledgeCardView): Array<{ level?: string; message?: string }> {
|
|
253
|
+
const raw = card.metadata?.schema_issues
|
|
254
|
+
return Array.isArray(raw) ? raw as Array<{ level?: string; message?: string }> : []
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
export function KnowledgeBaseTab({ visible, onChanged, onActiveBaseChange, knowledge, focus, onOpenSettings }: Props): ReactNode {
|
|
258
|
+
const [bases, setBases] = useState<KnowledgeBase[]>([])
|
|
259
|
+
const [activeId, setActiveId] = useState('')
|
|
260
|
+
const [documents, setDocuments] = useState<KnowledgeDocument[]>([])
|
|
261
|
+
const [cards, setCards] = useState<KnowledgeCardView[]>([])
|
|
262
|
+
const [total, setTotal] = useState(0)
|
|
263
|
+
const [query, setQuery] = useState('')
|
|
264
|
+
const [extFilter, setExtFilter] = useState('')
|
|
265
|
+
/**
|
|
266
|
+
* 来源分类筛选。**它同时是上传分类**:这个 select 回答的是同一个问题 ——
|
|
267
|
+
* 「这一份文件以什么业务身份进来」。分成两个控件就会出现「筛着 A 类,却把新文件
|
|
268
|
+
* 存成 B 类」,而两个控件还都叫「来源分类」。
|
|
269
|
+
*/
|
|
270
|
+
const [typeFilter, setTypeFilter] = useState('')
|
|
271
|
+
/** Wiki 卡片列表的筛选(与 SourceRaw 那一块同形)。 */
|
|
272
|
+
const [cardQuery, setCardQuery] = useState('')
|
|
273
|
+
const [cardType, setCardType] = useState('')
|
|
274
|
+
const [cardStatus, setCardStatus] = useState('')
|
|
275
|
+
const [loading, setLoading] = useState(false)
|
|
276
|
+
const [busy, setBusy] = useState<string | null>(null)
|
|
277
|
+
const [notice, setNotice] = useState<{ tone: 'success' | 'error'; text: string } | null>(null)
|
|
278
|
+
const [tab, setTab] = useState<KnowledgeTab>('source-raw')
|
|
279
|
+
const [editOpen, setEditOpen] = useState(false)
|
|
280
|
+
const [draft, setDraft] = useState({ id: '', name: '', description: '' })
|
|
281
|
+
/**
|
|
282
|
+
* 来源分类注册表(0013)。`active.sourceTypes` 提供分类行(含实时 sourceCount),
|
|
283
|
+
* `catalog` 只负责那段**内置只读**的判定提示词——它不随知识库变化,但仍按库
|
|
284
|
+
* 拉取,因为停用的分类要在提示词里标记出来。
|
|
285
|
+
*/
|
|
286
|
+
const [catalog, setCatalog] = useState<SourceTypeCatalog | null>(null)
|
|
287
|
+
/**
|
|
288
|
+
* 「类型定义」抽屉(0034):本库的词汇表 —— 来源分类 + Wiki 卡片 schema。
|
|
289
|
+
* 弹窗草稿在抽屉内部,这里只留开关与写路径。
|
|
290
|
+
*/
|
|
291
|
+
const [typeDefOpen, setTypeDefOpen] = useState(false)
|
|
292
|
+
/** 上传 / 导入时用的来源分类;注册表变了要跟着回退,不能指向一个已停用的 id。 */
|
|
293
|
+
const [uploadType, setUploadType] = useState('document')
|
|
294
|
+
const [detail, setDetail] = useState<{ document: KnowledgeDocumentDetail; versions: DocumentVersion[] } | null>(null)
|
|
295
|
+
const [detailLoading, setDetailLoading] = useState(false)
|
|
296
|
+
const [restoreTarget, setRestoreTarget] = useState<VersionManagerEntry | null>(null)
|
|
297
|
+
/** Wiki 卡片详情与版本(0025)。 */
|
|
298
|
+
const [cardDetail, setCardDetail] = useState<{ card: KnowledgeCardView; versions: WikiCardVersionView[] } | null>(null)
|
|
299
|
+
const [cardLoading, setCardLoading] = useState(false)
|
|
300
|
+
const [cardRestore, setCardRestore] = useState<VersionManagerEntry | null>(null)
|
|
301
|
+
const [graph, setGraph] = useState<{ nodes: GraphNodeView[]; edges: GraphEdgeView[] }>({ nodes: [], edges: [] })
|
|
302
|
+
/** 当前知识库的编译统计:卡片版本数、增量计数、向量条目都只在这里有。 */
|
|
303
|
+
const [stats, setStats] = useState<Record<string, unknown>>({})
|
|
304
|
+
const importInput = useRef<HTMLInputElement>(null)
|
|
305
|
+
/**
|
|
306
|
+
* 上传选择框的 `accept`:转换内核报出来的扩展名 ∪ 浏览器能直读的扩展名。
|
|
307
|
+
*
|
|
308
|
+
* 拉一次就够 —— 内核的适配器表在一次进程里不变;拿不到就留空(不限制)。
|
|
309
|
+
*/
|
|
310
|
+
const [acceptExts, setAcceptExts] = useState<string[]>([])
|
|
311
|
+
useEffect(() => {
|
|
312
|
+
if (!visible) return undefined
|
|
313
|
+
let alive = true
|
|
314
|
+
void requestJson<{ adapters?: Array<{ extensions?: string[] }> }>('/convert/api/capabilities')
|
|
315
|
+
.then(payload => {
|
|
316
|
+
if (!alive) return
|
|
317
|
+
const fromKernel = (payload.data?.adapters ?? []).flatMap(adapter => adapter.extensions ?? [])
|
|
318
|
+
setAcceptExts([...new Set([...fromKernel, ...DIRECT_READ_EXT])].sort())
|
|
319
|
+
})
|
|
320
|
+
.catch(() => { /* 内核不在就不限制:让内核给出准确的错误,比前端猜一张表强 */ })
|
|
321
|
+
return () => { alive = false }
|
|
322
|
+
}, [visible])
|
|
323
|
+
/** Mirrors `activeId` so callbacks can resolve the target base without stale closures. */
|
|
324
|
+
const activeIdRef = useRef('')
|
|
325
|
+
/**
|
|
326
|
+
* `query` / `typeFilter` 的镜像,理由同上但用途不同:搜索框的防抖计时器在 250ms
|
|
327
|
+
* 后触发,那时它需要的是**此刻**的另一个筛选条件,而不是它被排下时的那一个。
|
|
328
|
+
* 两个控件的改动是各自独立的,谁都不该把对方拖回旧值。
|
|
329
|
+
*/
|
|
330
|
+
const queryRef = useRef('')
|
|
331
|
+
const typeFilterRef = useRef('')
|
|
332
|
+
|
|
333
|
+
useEffect(() => { activeIdRef.current = activeId }, [activeId])
|
|
334
|
+
useEffect(() => { queryRef.current = query }, [query])
|
|
335
|
+
useEffect(() => { typeFilterRef.current = typeFilter }, [typeFilter])
|
|
336
|
+
|
|
337
|
+
const active = bases.find(base => base.id === activeId)
|
|
338
|
+
const knowledgeValue = useScope(knowledge).value
|
|
339
|
+
/**
|
|
340
|
+
* 收录闸门(方案级设置,`source-raw` 组)。**默认开**:设置没读到(未接设置服务)时
|
|
341
|
+
* 不该把上传锁死 —— 那就成了「读不到配置 = 不能用」,比读不到更糟。
|
|
342
|
+
*/
|
|
343
|
+
const gateClosed = knowledgeValue?.allowSourceTruth === false
|
|
344
|
+
|
|
345
|
+
// The parent scopes its recall test / upload UI to whatever base is selected here.
|
|
346
|
+
useEffect(() => {
|
|
347
|
+
onActiveBaseChange?.(active ? { id: active.id, name: active.name } : null)
|
|
348
|
+
}, [active?.id, active?.name, onActiveBaseChange]) // eslint-disable-line react-hooks/exhaustive-deps
|
|
349
|
+
|
|
350
|
+
/** 架构图节点跳转:落到对应分栏。 */
|
|
351
|
+
useEffect(() => {
|
|
352
|
+
if (visible && focus) setTab(focus.tab)
|
|
353
|
+
}, [visible, focus?.tab, focus?.nonce]) // eslint-disable-line react-hooks/exhaustive-deps
|
|
354
|
+
|
|
355
|
+
/** `sourceId → wiki cards`, so a file row can say how many slices it produced. */
|
|
356
|
+
const cardsBySource = useMemo(() => {
|
|
357
|
+
const index = new Map<string, KnowledgeCardView[]>()
|
|
358
|
+
for (const card of cards) {
|
|
359
|
+
for (const ref of card.sourceRefs ?? []) {
|
|
360
|
+
const list = index.get(ref)
|
|
361
|
+
if (list) list.push(card)
|
|
362
|
+
else index.set(ref, [card])
|
|
363
|
+
}
|
|
364
|
+
}
|
|
365
|
+
return index
|
|
366
|
+
}, [cards])
|
|
367
|
+
|
|
368
|
+
/** Pull the base list, keep (or repair) the active selection, and resolve it. */
|
|
369
|
+
const loadBases = useCallback(async (preferred?: string): Promise<{ next: KnowledgeBase[]; resolved: string }> => {
|
|
370
|
+
const payload = await requestJson<{ bases: KnowledgeBase[]; defaultBaseId: string }>('/knowledge/bases')
|
|
371
|
+
const next = payload.data?.bases ?? []
|
|
372
|
+
const requested = preferred ?? activeIdRef.current
|
|
373
|
+
const resolved = next.some(base => base.id === requested)
|
|
374
|
+
? requested
|
|
375
|
+
: payload.data?.defaultBaseId ?? next[0]?.id ?? ''
|
|
376
|
+
setBases(next)
|
|
377
|
+
setActiveId(resolved)
|
|
378
|
+
activeIdRef.current = resolved
|
|
379
|
+
return { next, resolved }
|
|
380
|
+
}, [])
|
|
381
|
+
|
|
382
|
+
/**
|
|
383
|
+
* 文件列表。搜索与来源分类都**交给服务端**(它本来就有 `query` / `source_type` 两个
|
|
384
|
+
* 过滤口),文件类型没有对应的服务端口,所以在拿回来的一页里筛。
|
|
385
|
+
*
|
|
386
|
+
* `limit=500` 是接口允许的上限:文件类型筛在客户端做,只取默认的 50 条会让
|
|
387
|
+
* 「筛出 3 个」变成「这一页里有 3 个」—— 两个意思差得很远。
|
|
388
|
+
*/
|
|
389
|
+
const loadDocuments = useCallback(async (baseId: string, search: string, sourceType: string): Promise<void> => {
|
|
390
|
+
const params = new URLSearchParams({ kb: baseId, limit: '500' })
|
|
391
|
+
if (search.trim()) params.set('query', search.trim())
|
|
392
|
+
if (sourceType) params.set('source_type', sourceType)
|
|
393
|
+
const payload = await requestJson<{ documents: KnowledgeDocument[]; total: number }>(`/knowledge/documents?${params.toString()}`)
|
|
394
|
+
const all = payload.data?.documents ?? []
|
|
395
|
+
setDocuments(all)
|
|
396
|
+
setTotal(payload.data?.total ?? all.length)
|
|
397
|
+
}, [])
|
|
398
|
+
|
|
399
|
+
/** Compiled wiki cards of one base — the local answer to WeKnora's chunk list. */
|
|
400
|
+
const loadCards = useCallback(async (baseId: string): Promise<void> => {
|
|
401
|
+
try {
|
|
402
|
+
const payload = await requestJson<{ cards: KnowledgeCardView[] }>(`/knowledge/cards?kb=${encodeURIComponent(baseId)}`)
|
|
403
|
+
setCards(payload.data?.cards ?? [])
|
|
404
|
+
} catch { setCards([]) }
|
|
405
|
+
}, [])
|
|
406
|
+
|
|
407
|
+
/** 图谱节点与边:知识图谱分栏列的就是它,不另外画一张小图(架构图已经画了)。 */
|
|
408
|
+
const loadGraph = useCallback(async (baseId: string): Promise<void> => {
|
|
409
|
+
try {
|
|
410
|
+
const payload = await requestJson<{ nodes?: GraphNodeView[]; edges?: GraphEdgeView[] }>(`/knowledge/graph?action=list&limit=50&kb=${encodeURIComponent(baseId)}`)
|
|
411
|
+
setGraph({ nodes: payload.data?.nodes ?? [], edges: payload.data?.edges ?? [] })
|
|
412
|
+
} catch { setGraph({ nodes: [], edges: [] }) }
|
|
413
|
+
}, [])
|
|
414
|
+
|
|
415
|
+
/** 当前知识库的编译统计。设置快照里没有这些字段,所以这里单独拉一次。 */
|
|
416
|
+
const loadStats = useCallback(async (baseId: string): Promise<void> => {
|
|
417
|
+
try {
|
|
418
|
+
const payload = await requestJson<Record<string, unknown>>(`/knowledge/stats?kb=${encodeURIComponent(baseId)}`)
|
|
419
|
+
setStats(payload.data ?? {})
|
|
420
|
+
} catch { setStats({}) }
|
|
421
|
+
}, [])
|
|
422
|
+
|
|
423
|
+
/** 内置只读判定提示词 + 注册表快照;SourceRaw 分栏里按库拉一次。 */
|
|
424
|
+
const loadCatalog = useCallback(async (baseId: string): Promise<void> => {
|
|
425
|
+
try {
|
|
426
|
+
const payload = await requestJson<SourceTypeCatalog>(`/knowledge/source-types?kb=${encodeURIComponent(baseId)}`)
|
|
427
|
+
setCatalog(payload.data ?? null)
|
|
428
|
+
} catch { setCatalog(null) }
|
|
429
|
+
}, [])
|
|
430
|
+
|
|
431
|
+
const refresh = useCallback(async (preferred?: string, search = '', sourceType = ''): Promise<void> => {
|
|
432
|
+
setLoading(true)
|
|
433
|
+
try {
|
|
434
|
+
const { resolved } = await loadBases(preferred)
|
|
435
|
+
if (resolved) await Promise.all([loadDocuments(resolved, search, sourceType), loadCards(resolved), loadGraph(resolved), loadCatalog(resolved), loadStats(resolved)])
|
|
436
|
+
} catch (error) {
|
|
437
|
+
setNotice({ tone: 'error', text: error instanceof Error ? error.message : String(error) })
|
|
438
|
+
} finally {
|
|
439
|
+
setLoading(false)
|
|
440
|
+
}
|
|
441
|
+
}, [loadBases, loadCards, loadDocuments, loadGraph, loadCatalog, loadStats])
|
|
442
|
+
|
|
443
|
+
useEffect(() => { if (visible) void refresh() }, [visible]) // eslint-disable-line react-hooks/exhaustive-deps
|
|
444
|
+
|
|
445
|
+
/**
|
|
446
|
+
* 搜索框:打字是**连续输入**,所以防抖 250ms,不必每个字符打一次服务端。
|
|
447
|
+
*
|
|
448
|
+
* 这里**只跟 `query`**。原来它也跟 `typeFilter`,而下拉是**离散选择** —— 共用
|
|
449
|
+
* 一个防抖的直接后果就是「选完「全部来源分类」,列表要愣 250ms 才动」(GUI 反馈
|
|
450
|
+
* 2026-09-22)。两类交互的节奏不同,就不该共用一条延时。
|
|
451
|
+
*/
|
|
452
|
+
useEffect(() => {
|
|
453
|
+
if (!visible) return undefined
|
|
454
|
+
const timer = setTimeout(() => {
|
|
455
|
+
const baseId = activeIdRef.current
|
|
456
|
+
if (baseId) void loadDocuments(baseId, query, typeFilterRef.current).catch(() => undefined)
|
|
457
|
+
}, 250)
|
|
458
|
+
return () => clearTimeout(timer)
|
|
459
|
+
}, [visible, activeId, query, loadDocuments])
|
|
460
|
+
|
|
461
|
+
/**
|
|
462
|
+
* 分类下拉:选完立刻请求,不进退避。
|
|
463
|
+
*
|
|
464
|
+
* 它是服务端筛选(`source_type` 是接口参数),所以客户端做不了 —— 但「立刻」
|
|
465
|
+
* 仍然成立:一次请求就是这个选择本身,没有理由再等一个为打字准备的延时。
|
|
466
|
+
*/
|
|
467
|
+
const selectTypeFilter = useCallback((next: string): void => {
|
|
468
|
+
setTypeFilter(next)
|
|
469
|
+
// 「筛什么就按什么收录」:选中某一类时,上传分类跟着一致。
|
|
470
|
+
if (next) setUploadType(next)
|
|
471
|
+
const baseId = activeIdRef.current
|
|
472
|
+
if (baseId) void loadDocuments(baseId, queryRef.current, next).catch(() => undefined)
|
|
473
|
+
}, [loadDocuments])
|
|
474
|
+
|
|
475
|
+
/** 上传分类必须始终指向一个「存在且启用」的分类,停用后自动回退。 */
|
|
476
|
+
useEffect(() => {
|
|
477
|
+
const enabled = (active?.sourceTypes ?? []).filter(def => def.enabled)
|
|
478
|
+
if (enabled.length === 0 || enabled.some(def => def.id === uploadType)) return
|
|
479
|
+
setUploadType(enabled.find(def => def.id === 'document')?.id ?? enabled[0]!.id)
|
|
480
|
+
}, [active?.sourceTypes, uploadType])
|
|
481
|
+
|
|
482
|
+
const say = (tone: 'success' | 'error', text: string): void => { setNotice({ tone, text }); onChanged?.() }
|
|
483
|
+
|
|
484
|
+
// ----------------------------------------------------------------- actions
|
|
485
|
+
|
|
486
|
+
const saveBase = async (): Promise<void> => {
|
|
487
|
+
setBusy('base')
|
|
488
|
+
try {
|
|
489
|
+
const isNew = !draft.id
|
|
490
|
+
const body = JSON.stringify({ name: draft.name.trim(), description: draft.description.trim() })
|
|
491
|
+
const init: RequestInit = { method: isNew ? 'POST' : 'PATCH', headers: { 'content-type': 'application/json' }, body }
|
|
492
|
+
const payload = await requestJson<{ base: KnowledgeBase }>(isNew ? '/knowledge/bases' : `/knowledge/bases/${encodeURIComponent(draft.id)}`, init)
|
|
493
|
+
setEditOpen(false)
|
|
494
|
+
await refresh(payload.data?.base.id)
|
|
495
|
+
say('success', isNew ? `已创建知识库「${draft.name.trim()}」` : '知识库已更新')
|
|
496
|
+
} catch (error) {
|
|
497
|
+
say('error', error instanceof Error ? error.message : String(error))
|
|
498
|
+
} finally {
|
|
499
|
+
setBusy(null)
|
|
500
|
+
}
|
|
501
|
+
}
|
|
502
|
+
|
|
503
|
+
const removeBase = async (id: string): Promise<void> => {
|
|
504
|
+
setBusy('base-delete')
|
|
505
|
+
try {
|
|
506
|
+
await requestJson(`/knowledge/bases/${encodeURIComponent(id)}`, { method: 'DELETE' })
|
|
507
|
+
await refresh()
|
|
508
|
+
say('success', '知识库已删除')
|
|
509
|
+
} catch (error) {
|
|
510
|
+
say('error', error instanceof Error ? error.message : String(error))
|
|
511
|
+
} finally {
|
|
512
|
+
setBusy(null)
|
|
513
|
+
}
|
|
514
|
+
}
|
|
515
|
+
|
|
516
|
+
/**
|
|
517
|
+
* 新增走 POST、编辑走 PATCH。
|
|
518
|
+
*
|
|
519
|
+
* 两条路都要把**判定文案**一起提交——服务端会强制 description / signals /
|
|
520
|
+
* excludes 的完整性,这是「用户追加新类型时必须用文字标明怎么判断」的落点。
|
|
521
|
+
*
|
|
522
|
+
* 草稿由「类型定义」抽屉传入:弹窗状态属于抽屉,写路径属于这里。
|
|
523
|
+
*/
|
|
524
|
+
const saveSourceType = async (draft: SourceTypeDraft): Promise<void> => {
|
|
525
|
+
const base = active
|
|
526
|
+
if (!base) return
|
|
527
|
+
const body = {
|
|
528
|
+
kb: base.id,
|
|
529
|
+
id: draft.id.trim(),
|
|
530
|
+
label: draft.label.trim(),
|
|
531
|
+
description: draft.description.trim(),
|
|
532
|
+
signals: draft.signals.split('\n').map(line => line.trim()).filter(Boolean),
|
|
533
|
+
excludes: draft.excludes.split('\n').map(line => line.trim()).filter(Boolean),
|
|
534
|
+
}
|
|
535
|
+
const isNew = draft.mode === 'create'
|
|
536
|
+
setBusy('source-type')
|
|
537
|
+
try {
|
|
538
|
+
await requestJson(isNew ? '/knowledge/source-types' : `/knowledge/source-types/${encodeURIComponent(body.id)}`, {
|
|
539
|
+
method: isNew ? 'POST' : 'PATCH',
|
|
540
|
+
headers: { 'content-type': 'application/json' },
|
|
541
|
+
body: JSON.stringify(body),
|
|
542
|
+
})
|
|
543
|
+
await loadBases(base.id)
|
|
544
|
+
await loadCatalog(base.id)
|
|
545
|
+
say('success', isNew ? `已新增分类「${body.label}」` : `分类「${body.label}」已更新`)
|
|
546
|
+
} catch (error) {
|
|
547
|
+
say('error', error instanceof Error ? error.message : String(error))
|
|
548
|
+
} finally {
|
|
549
|
+
setBusy(null)
|
|
550
|
+
}
|
|
551
|
+
}
|
|
552
|
+
|
|
553
|
+
const toggleSourceType = async (def: SourceTypeDef, enabled: boolean): Promise<void> => {
|
|
554
|
+
const base = active
|
|
555
|
+
if (!base) return
|
|
556
|
+
setBusy(`source-type-${def.id}`)
|
|
557
|
+
try {
|
|
558
|
+
await requestJson(`/knowledge/source-types/${encodeURIComponent(def.id)}`, {
|
|
559
|
+
method: 'PATCH',
|
|
560
|
+
headers: { 'content-type': 'application/json' },
|
|
561
|
+
body: JSON.stringify({ kb: base.id, enabled }),
|
|
562
|
+
})
|
|
563
|
+
await loadBases(base.id)
|
|
564
|
+
await loadCatalog(base.id)
|
|
565
|
+
say('success', `${def.label} 已${enabled ? '启用' : '停用'}`)
|
|
566
|
+
} catch (error) {
|
|
567
|
+
say('error', error instanceof Error ? error.message : String(error))
|
|
568
|
+
} finally {
|
|
569
|
+
setBusy(null)
|
|
570
|
+
}
|
|
571
|
+
}
|
|
572
|
+
|
|
573
|
+
const removeSourceType = async (def: SourceTypeDef): Promise<void> => {
|
|
574
|
+
const base = active
|
|
575
|
+
if (!base) return
|
|
576
|
+
setBusy(`source-type-${def.id}`)
|
|
577
|
+
try {
|
|
578
|
+
await requestJson(`/knowledge/source-types/${encodeURIComponent(def.id)}?kb=${encodeURIComponent(base.id)}`, { method: 'DELETE' })
|
|
579
|
+
await loadBases(base.id)
|
|
580
|
+
await loadCatalog(base.id)
|
|
581
|
+
say('success', `已删除分类「${def.label}」`)
|
|
582
|
+
} catch (error) {
|
|
583
|
+
say('error', error instanceof Error ? error.message : String(error))
|
|
584
|
+
} finally {
|
|
585
|
+
setBusy(null)
|
|
586
|
+
}
|
|
587
|
+
}
|
|
588
|
+
|
|
589
|
+
/**
|
|
590
|
+
* Wiki 类型注册表与 schema 生效方式都走 base PATCH(与 fileTypes / sourceTypes 同一条写路径)。
|
|
591
|
+
*
|
|
592
|
+
* `wikiTypes` 是**完整的期望列表**:服务端守卫会拦住"删掉在用类型/内置类型"的整表提交。
|
|
593
|
+
*/
|
|
594
|
+
const patchWikiTypes = async (next: WikiTypeView[], describe: string): Promise<void> => {
|
|
595
|
+
const base = active
|
|
596
|
+
if (!base) return
|
|
597
|
+
setBusy('wiki-types')
|
|
598
|
+
try {
|
|
599
|
+
await requestJson(`/knowledge/bases/${encodeURIComponent(base.id)}`, {
|
|
600
|
+
method: 'PATCH',
|
|
601
|
+
headers: { 'content-type': 'application/json' },
|
|
602
|
+
body: JSON.stringify({ wikiTypes: next }),
|
|
603
|
+
})
|
|
604
|
+
await loadBases(base.id)
|
|
605
|
+
say('success', describe)
|
|
606
|
+
} catch (error) {
|
|
607
|
+
say('error', error instanceof Error ? error.message : String(error))
|
|
608
|
+
} finally {
|
|
609
|
+
setBusy(null)
|
|
610
|
+
}
|
|
611
|
+
}
|
|
612
|
+
|
|
613
|
+
const patchWikiSchemaMode = async (mode: 'strict' | 'report'): Promise<void> => {
|
|
614
|
+
const base = active
|
|
615
|
+
if (!base) return
|
|
616
|
+
setBusy('wiki-mode')
|
|
617
|
+
try {
|
|
618
|
+
await requestJson(`/knowledge/bases/${encodeURIComponent(base.id)}`, {
|
|
619
|
+
method: 'PATCH',
|
|
620
|
+
headers: { 'content-type': 'application/json' },
|
|
621
|
+
body: JSON.stringify({ wikiSchemaMode: mode }),
|
|
622
|
+
})
|
|
623
|
+
await loadBases(base.id)
|
|
624
|
+
say('success', mode === 'strict' ? '已开启严格模式:缺必填小节 / 字段将拒绝写入' : '已切回 report 模式:只标记问题,不拦截写入')
|
|
625
|
+
} catch (error) {
|
|
626
|
+
say('error', error instanceof Error ? error.message : String(error))
|
|
627
|
+
} finally {
|
|
628
|
+
setBusy(null)
|
|
629
|
+
}
|
|
630
|
+
}
|
|
631
|
+
|
|
632
|
+
/** 清除 wiki 类型与模式上的用户层覆盖,回到出厂 11 个类型。 */
|
|
633
|
+
const resetWikiTypes = async (): Promise<void> => {
|
|
634
|
+
const base = active
|
|
635
|
+
if (!base) return
|
|
636
|
+
setBusy('wiki-types')
|
|
637
|
+
try {
|
|
638
|
+
await requestJson(`/knowledge/bases/${encodeURIComponent(base.id)}`, {
|
|
639
|
+
method: 'PATCH',
|
|
640
|
+
headers: { 'content-type': 'application/json' },
|
|
641
|
+
body: JSON.stringify({ resetWikiTypes: true }),
|
|
642
|
+
})
|
|
643
|
+
await loadBases(base.id)
|
|
644
|
+
say('success', '已恢复出厂 Wiki 类型')
|
|
645
|
+
} catch (error) {
|
|
646
|
+
say('error', error instanceof Error ? error.message : String(error))
|
|
647
|
+
} finally {
|
|
648
|
+
setBusy(null)
|
|
649
|
+
}
|
|
650
|
+
}
|
|
651
|
+
|
|
652
|
+
const writeSource = async (title: string, content: string, externalId: string): Promise<void> => {
|
|
653
|
+
const base = active
|
|
654
|
+
if (!base) throw new Error('请先选择一个知识库')
|
|
655
|
+
await requestJson('/knowledge/sources', {
|
|
656
|
+
method: 'POST',
|
|
657
|
+
headers: { 'content-type': 'application/json' },
|
|
658
|
+
body: JSON.stringify({ kb: base.id, source_type: uploadType, title, external_id: externalId, content }),
|
|
659
|
+
})
|
|
660
|
+
}
|
|
661
|
+
|
|
662
|
+
/** Convert one file with the kernel, then ingest the delivery into the active base. */
|
|
663
|
+
const convertAndIngest = async (file: File): Promise<void> => {
|
|
664
|
+
const base = active
|
|
665
|
+
if (!base) throw new Error('请先选择一个知识库')
|
|
666
|
+
const form = new FormData()
|
|
667
|
+
form.append('file', file, file.name)
|
|
668
|
+
form.append('title', file.name)
|
|
669
|
+
const submitted = await requestJson<{ id: string }>('/convert/api/tasks', { method: 'POST', body: form })
|
|
670
|
+
const taskId = submitted.data?.id
|
|
671
|
+
if (!taskId) throw new Error('转换内核没有返回 taskId')
|
|
672
|
+
const deadline = Date.now() + CONVERT_TIMEOUT_MS
|
|
673
|
+
for (;;) {
|
|
674
|
+
const payload = await requestJson<{ state?: string; error?: { code?: string; message?: string } | null }>(`/convert/api/tasks/${encodeURIComponent(taskId)}`)
|
|
675
|
+
const state = payload.data?.state
|
|
676
|
+
if (state === 'succeeded') break
|
|
677
|
+
if (state === 'failed' || state === 'cancelled') {
|
|
678
|
+
throw new Error(`转换失败:${payload.data?.error?.code ?? state} ${payload.data?.error?.message ?? ''}`.trim())
|
|
679
|
+
}
|
|
680
|
+
if (Date.now() > deadline) throw new Error('转换超时,可在任务列表里稍后重试')
|
|
681
|
+
await new Promise(resolve => setTimeout(resolve, CONVERT_POLL_MS))
|
|
682
|
+
}
|
|
683
|
+
await requestJson('/convert/api/ingest', {
|
|
684
|
+
method: 'POST',
|
|
685
|
+
headers: { 'content-type': 'application/json' },
|
|
686
|
+
body: JSON.stringify({ task_id: taskId, kb: base.id, title: file.name, source_type: uploadType }),
|
|
687
|
+
})
|
|
688
|
+
}
|
|
689
|
+
|
|
690
|
+
/** 分类 id → 显示名;不在注册表里的一律显示「未知分类」(文档本身不再被丢弃)。 */
|
|
691
|
+
const sourceTypeLabel = (id: string): string => active?.sourceTypes.find(def => def.id === id)?.label ?? '未知分类'
|
|
692
|
+
|
|
693
|
+
const handleImport = async (event: ChangeEvent<HTMLInputElement>): Promise<void> => {
|
|
694
|
+
const files = Array.from(event.target.files ?? [])
|
|
695
|
+
event.target.value = ''
|
|
696
|
+
const base = active
|
|
697
|
+
if (!files.length || !base) return
|
|
698
|
+
setBusy('import'); setNotice(null)
|
|
699
|
+
let done = 0
|
|
700
|
+
const failures: string[] = []
|
|
701
|
+
try {
|
|
702
|
+
for (const file of files) {
|
|
703
|
+
try {
|
|
704
|
+
const ext = file.name.slice(file.name.lastIndexOf('.')).toLowerCase()
|
|
705
|
+
// 文本类扩展名**直读**,不走转换内核。
|
|
706
|
+
//
|
|
707
|
+
// 0029 之前这里还看「文件类型规则里这个扩展名绑了哪个引擎」,而那个字段
|
|
708
|
+
// 内核根本不读 —— 它只在这里把 `直读` 改成 `anydoc`,让一份 Markdown 绕
|
|
709
|
+
// 一圈转换内核再落成同一份纯文本。规则表拆掉之后这里只剩一条真话:
|
|
710
|
+
// 能直读的就直读,不能的交给内核,由内核按它自己的格式表选适配器。
|
|
711
|
+
if (DIRECT_READ_EXT.has(ext)) {
|
|
712
|
+
const content = await file.text()
|
|
713
|
+
if (!content.trim()) throw new Error('文件内容为空')
|
|
714
|
+
await writeSource(file.name, content, `upload:${file.name}:${file.lastModified}`)
|
|
715
|
+
} else {
|
|
716
|
+
await convertAndIngest(file)
|
|
717
|
+
}
|
|
718
|
+
done += 1
|
|
719
|
+
} catch (error) {
|
|
720
|
+
failures.push(`${file.name}:${error instanceof Error ? error.message : String(error)}`)
|
|
721
|
+
}
|
|
722
|
+
}
|
|
723
|
+
if (failures.length === 0) say('success', `已导入 ${done} 个文件到「${base.name}」`)
|
|
724
|
+
else say('error', `成功 ${done} 个,失败 ${failures.length} 个 — ${failures[0]}`)
|
|
725
|
+
await loadBases(base.id)
|
|
726
|
+
await Promise.all([loadDocuments(base.id, query, typeFilter), loadCards(base.id)])
|
|
727
|
+
} finally {
|
|
728
|
+
setBusy(null)
|
|
729
|
+
}
|
|
730
|
+
}
|
|
731
|
+
|
|
732
|
+
// ------------------------------------------------------- SourceRaw 详情
|
|
733
|
+
|
|
734
|
+
const openDetail = async (document: KnowledgeDocument): Promise<void> => {
|
|
735
|
+
const baseId = activeIdRef.current
|
|
736
|
+
if (!baseId) return
|
|
737
|
+
// 两个预览抽屉互斥:打开这一个就清掉另一个。不清的话,关掉卡片抽屉会让**残留
|
|
738
|
+
// 的**原文状态重新满足 `open`,于是面板「自己又弹回来」——互斥必须两边都成立。
|
|
739
|
+
setCardDetail(null)
|
|
740
|
+
setDetailLoading(true)
|
|
741
|
+
setDetail({ document: { ...document, content: '', frontmatter: {}, assets: [] }, versions: [] })
|
|
742
|
+
try {
|
|
743
|
+
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}`)
|
|
746
|
+
if (docPayload.data?.document) {
|
|
747
|
+
setDetail({ document: docPayload.data.document, versions: versionPayload.data?.versions ?? [] })
|
|
748
|
+
}
|
|
749
|
+
} catch (error) {
|
|
750
|
+
setNotice({ tone: 'error', text: error instanceof Error ? error.message : String(error) })
|
|
751
|
+
setDetail(null)
|
|
752
|
+
} finally {
|
|
753
|
+
setDetailLoading(false)
|
|
754
|
+
}
|
|
755
|
+
}
|
|
756
|
+
|
|
757
|
+
const restoreVersion = async (): Promise<void> => {
|
|
758
|
+
const target = restoreTarget
|
|
759
|
+
const current = detail
|
|
760
|
+
const baseId = activeIdRef.current
|
|
761
|
+
if (!target || !current || !baseId) return
|
|
762
|
+
setBusy('restore')
|
|
763
|
+
try {
|
|
764
|
+
await requestJson(`/knowledge/documents/${encodeURIComponent(current.document.docId)}/revert?kb=${encodeURIComponent(baseId)}`, {
|
|
765
|
+
method: 'POST',
|
|
766
|
+
headers: { 'content-type': 'application/json' },
|
|
767
|
+
body: JSON.stringify({ versionId: target.id }),
|
|
768
|
+
})
|
|
769
|
+
setRestoreTarget(null)
|
|
770
|
+
await openDetail(current.document)
|
|
771
|
+
await Promise.all([loadDocuments(baseId, query, typeFilter), loadCards(baseId)])
|
|
772
|
+
say('success', `已回滚到 V${target.number}`)
|
|
773
|
+
} catch (error) {
|
|
774
|
+
say('error', error instanceof Error ? error.message : String(error))
|
|
775
|
+
} finally {
|
|
776
|
+
setBusy(null)
|
|
777
|
+
}
|
|
778
|
+
}
|
|
779
|
+
|
|
780
|
+
const deleteDocument = async (document: KnowledgeDocument): Promise<void> => {
|
|
781
|
+
const baseId = activeIdRef.current
|
|
782
|
+
if (!baseId) return
|
|
783
|
+
setBusy(`doc-${document.docId}`)
|
|
784
|
+
try {
|
|
785
|
+
await requestJson(`/knowledge/documents/${encodeURIComponent(document.docId)}?kb=${encodeURIComponent(baseId)}`, { method: 'DELETE' })
|
|
786
|
+
if (detail?.document.docId === document.docId) setDetail(null)
|
|
787
|
+
await loadBases(baseId)
|
|
788
|
+
await Promise.all([loadDocuments(baseId, query, typeFilter), loadCards(baseId)])
|
|
789
|
+
say('success', `已删除「${document.title}」`)
|
|
790
|
+
} catch (error) {
|
|
791
|
+
say('error', error instanceof Error ? error.message : String(error))
|
|
792
|
+
} finally {
|
|
793
|
+
setBusy(null)
|
|
794
|
+
}
|
|
795
|
+
}
|
|
796
|
+
|
|
797
|
+
// ------------------------------------------------------------ Wiki 详情
|
|
798
|
+
|
|
799
|
+
/** 点开一张卡片:内容 + 版本历史。版本由服务端在每次正文变化前快照。 */
|
|
800
|
+
const openCard = async (card: KnowledgeCardView): Promise<void> => {
|
|
801
|
+
const baseId = activeIdRef.current
|
|
802
|
+
if (!baseId) return
|
|
803
|
+
setDetail(null)
|
|
804
|
+
setCardLoading(true)
|
|
805
|
+
setCardDetail({ card, versions: [] })
|
|
806
|
+
try {
|
|
807
|
+
const q = `?kb=${encodeURIComponent(baseId)}&entity_type=${encodeURIComponent(card.entityType)}`
|
|
808
|
+
const [detailPayload, versionPayload] = await Promise.all([
|
|
809
|
+
requestJson<{ card: KnowledgeCardView }>(`/knowledge/cards/${encodeURIComponent(card.entityId)}${q}`),
|
|
810
|
+
requestJson<{ versions: WikiCardVersionView[] }>(`/knowledge/cards/${encodeURIComponent(card.entityId)}/versions${q}`),
|
|
811
|
+
])
|
|
812
|
+
setCardDetail({ card: detailPayload.data?.card ?? card, versions: versionPayload.data?.versions ?? [] })
|
|
813
|
+
} catch (error) {
|
|
814
|
+
setNotice({ tone: 'error', text: error instanceof Error ? error.message : String(error) })
|
|
815
|
+
setCardDetail(null)
|
|
816
|
+
} finally {
|
|
817
|
+
setCardLoading(false)
|
|
818
|
+
}
|
|
819
|
+
}
|
|
820
|
+
|
|
821
|
+
const revertCard = async (): Promise<void> => {
|
|
822
|
+
const target = cardRestore
|
|
823
|
+
const current = cardDetail
|
|
824
|
+
const baseId = activeIdRef.current
|
|
825
|
+
if (!target || !current || !baseId) return
|
|
826
|
+
setBusy('card-revert')
|
|
827
|
+
try {
|
|
828
|
+
await requestJson(`/knowledge/cards/${encodeURIComponent(current.card.entityId)}/revert?kb=${encodeURIComponent(baseId)}&entity_type=${encodeURIComponent(current.card.entityType)}`, {
|
|
829
|
+
method: 'POST',
|
|
830
|
+
headers: { 'content-type': 'application/json' },
|
|
831
|
+
body: JSON.stringify({ versionId: target.id }),
|
|
832
|
+
})
|
|
833
|
+
setCardRestore(null)
|
|
834
|
+
await openCard(current.card)
|
|
835
|
+
await loadCards(baseId)
|
|
836
|
+
say('success', `卡片已回滚到 V${target.number}(回滚前版本也已快照,可以再回滚回来)`)
|
|
837
|
+
} catch (error) {
|
|
838
|
+
say('error', error instanceof Error ? error.message : String(error))
|
|
839
|
+
} finally {
|
|
840
|
+
setBusy(null)
|
|
841
|
+
}
|
|
842
|
+
}
|
|
843
|
+
|
|
844
|
+
// --------------------------------------------------------------- 编译
|
|
845
|
+
|
|
846
|
+
const rebuild = async (only: 'wiki' | 'all'): Promise<void> => {
|
|
847
|
+
const baseId = activeIdRef.current
|
|
848
|
+
if (!baseId) return
|
|
849
|
+
setBusy('compile'); setNotice(null)
|
|
850
|
+
try {
|
|
851
|
+
const payload = await requestJson<{
|
|
852
|
+
version?: number; skipped?: boolean
|
|
853
|
+
cardsReused?: number; cardsRecompiled?: number; cardsAdded?: number; cardsRemoved?: number
|
|
854
|
+
notesReused?: number; notesRecompiled?: number
|
|
855
|
+
}>('/knowledge/rebuild', {
|
|
856
|
+
method: 'POST',
|
|
857
|
+
headers: { 'content-type': 'application/json' },
|
|
858
|
+
body: JSON.stringify({ only, kb: baseId }),
|
|
859
|
+
})
|
|
860
|
+
const data = payload.data ?? {}
|
|
861
|
+
await loadBases(baseId)
|
|
862
|
+
await Promise.all([loadCards(baseId), loadGraph(baseId), loadStats(baseId)])
|
|
863
|
+
if (data.skipped) {
|
|
864
|
+
say('success', `没有变化:复用 ${data.notesReused ?? 0} 条 wikiNotes,索引仍是 v${data.version ?? 1}`)
|
|
865
|
+
} else {
|
|
866
|
+
say('success', only === 'wiki'
|
|
867
|
+
? `增量编译完成:复用 ${data.cardsReused ?? 0} 张 / 重编 ${data.cardsRecompiled ?? 0} 张卡片,新增 ${data.cardsAdded ?? 0}、移除 ${data.cardsRemoved ?? 0},索引 v${data.version ?? 1}`
|
|
868
|
+
: `全量重建完成:${data.notesRecompiled ?? 0} 条 wikiNotes 重新切分,索引 v${data.version ?? 1}`)
|
|
869
|
+
}
|
|
870
|
+
} catch (error) {
|
|
871
|
+
say('error', error instanceof Error ? error.message : String(error))
|
|
872
|
+
} finally {
|
|
873
|
+
setBusy(null)
|
|
874
|
+
}
|
|
875
|
+
}
|
|
876
|
+
|
|
877
|
+
const pickerItems = useMemo(() => bases.map(base => ({
|
|
878
|
+
id: base.id,
|
|
879
|
+
name: base.name,
|
|
880
|
+
meta: `${base.sourceCount} 个文件 · ${base.cardCount} 张卡片 · v${base.buildVersion}`,
|
|
881
|
+
detail: base.rootDir,
|
|
882
|
+
})), [bases])
|
|
883
|
+
|
|
884
|
+
const versionEntries: VersionManagerEntry[] = (detail?.versions ?? []).map((version, index, all) => ({
|
|
885
|
+
id: version.versionId,
|
|
886
|
+
number: all.length - index,
|
|
887
|
+
summary: `${version.label || version.sourceFormat || 'document'} · ${formatBytes(version.bytes)} · ${version.contentHash.slice(0, 8)}`,
|
|
888
|
+
actor: 'user',
|
|
889
|
+
time: formatTime(version.createdAt),
|
|
890
|
+
status: version.isCurrent ? 'committed' : 'superseded',
|
|
891
|
+
stepCount: 0,
|
|
892
|
+
replayable: false,
|
|
893
|
+
}))
|
|
894
|
+
|
|
895
|
+
const cardVersionEntries: VersionManagerEntry[] = (cardDetail?.versions ?? []).map((version, index, all) => ({
|
|
896
|
+
id: version.versionId,
|
|
897
|
+
number: all.length - index,
|
|
898
|
+
summary: `${version.label} · ${formatBytes(version.bytes)} · ${version.contentHash.slice(0, 8)}`,
|
|
899
|
+
actor: 'user',
|
|
900
|
+
time: formatTime(version.createdAt),
|
|
901
|
+
status: version.isCurrent ? 'committed' : 'superseded',
|
|
902
|
+
stepCount: 0,
|
|
903
|
+
replayable: false,
|
|
904
|
+
}))
|
|
905
|
+
|
|
906
|
+
/**
|
|
907
|
+
* 按文件类型筛选的下拉项,**从这一库真实的文档推导**。
|
|
908
|
+
*
|
|
909
|
+
* 0029 之前它来自每个库那份「文件类型规则」表:既会列出 0 个文件的类型,也会
|
|
910
|
+
* 漏掉文档里有、但没人登记过的扩展名。按实际文档算,两项毛病都没有。
|
|
911
|
+
*/
|
|
912
|
+
const extOptions = useMemo(() => {
|
|
913
|
+
const counts = new Map<string, number>()
|
|
914
|
+
for (const document of documents) counts.set(document.ext, (counts.get(document.ext) ?? 0) + 1)
|
|
915
|
+
return [...counts.entries()]
|
|
916
|
+
.sort((a, b) => a[0].localeCompare(b[0]))
|
|
917
|
+
.map(([ext, count]) => ({ value: ext, label: `${ext}(${count})` }))
|
|
918
|
+
}, [documents])
|
|
919
|
+
/**
|
|
920
|
+
* `/knowledge/stats` 在这里是 `Record<string, unknown>`(它同时承载编译设置快照),
|
|
921
|
+
* 所以 `lastBuild` 要显式收窄一次再读 —— 否则每个字段都得写一遍类型断言。
|
|
922
|
+
*/
|
|
923
|
+
const lastBuild = (stats.lastBuild ?? {}) as Record<string, unknown>
|
|
924
|
+
const currentVersionId = detail?.versions.find(version => version.isCurrent)?.versionId
|
|
925
|
+
const currentCardVersionId = cardDetail?.versions.find(version => version.isCurrent)?.versionId
|
|
926
|
+
const detailCards = detail ? cardsBySource.get(detail.document.docId) ?? [] : []
|
|
927
|
+
const pendingCards = cards.filter(card => schemaIssuesOf(card).length > 0)
|
|
928
|
+
|
|
929
|
+
/**
|
|
930
|
+
* 文件类型筛在客户端做(接口没有这个过滤口),其余两个筛选在服务端。
|
|
931
|
+
* 列表与计数都读这一份派生值,所以「筛出 3 个」与表头的数字不可能对不上。
|
|
932
|
+
*/
|
|
933
|
+
const visibleDocuments = useMemo(
|
|
934
|
+
() => extFilter ? documents.filter(document => document.ext === extFilter) : documents,
|
|
935
|
+
[documents, extFilter],
|
|
936
|
+
)
|
|
937
|
+
|
|
938
|
+
/** Wiki 卡片列表的三个筛选。**类型选项取自「库里实际有什么」**,不是注册表全集 —— 筛一个空类型没有意义。 */
|
|
939
|
+
const cardTypes = useMemo(() => [...new Set(cards.map(card => card.entityType))].sort(), [cards])
|
|
940
|
+
const cardStatuses = useMemo(() => [...new Set(cards.map(card => card.status))].sort(), [cards])
|
|
941
|
+
const visibleCards = useMemo(() => {
|
|
942
|
+
const needle = cardQuery.trim().toLocaleLowerCase()
|
|
943
|
+
return cards.filter(card =>
|
|
944
|
+
(!cardType || card.entityType === cardType)
|
|
945
|
+
&& (!cardStatus || card.status === cardStatus)
|
|
946
|
+
&& (!needle || `${card.title}\n${card.entityId}\n${card.summary ?? ''}`.toLocaleLowerCase().includes(needle)))
|
|
947
|
+
}, [cards, cardQuery, cardType, cardStatus])
|
|
948
|
+
|
|
949
|
+
/**
|
|
950
|
+
* 步骤轨上每一步的真实事实。计数来自落盘与设置:`active` 的计数是服务端算的,
|
|
951
|
+
* 图谱计数优先读 `/knowledge/stats`(它统计的是全量),拿不到才退回这一页拉到的前 50 条。
|
|
952
|
+
*/
|
|
953
|
+
const stepFacts = useMemo(() => ({
|
|
954
|
+
'source-raw': { value: active ? `${active.sourceCount} 个文件` : '—', note: '按格式路由解析,同 external_id 幂等' },
|
|
955
|
+
wiki: { value: active ? `${active.cardCount} 张卡片` : '—', note: '卡片 → wikiNotes,未变的逐字复用' },
|
|
956
|
+
graph: { value: `${stats.graphNodes ?? graph.nodes.length} 节点 / ${stats.graphEdges ?? graph.edges.length} 边`, note: 'supported_by 溯源边 + relates_to 关系边' },
|
|
957
|
+
recall: { value: `topK ${String(knowledgeValue?.recallTopK ?? 10)}`, note: `${String(knowledgeValue?.recallMode ?? 'hybrid')} 三路融合` },
|
|
958
|
+
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])
|
|
960
|
+
|
|
961
|
+
// --------------------------------------------------------------- 分栏内容
|
|
962
|
+
|
|
963
|
+
/** SourceRaw:筛选 + 文件列表。分类的**定义**在工具栏「类型定义」里,这里只消费它。 */
|
|
964
|
+
const renderSourceRaw = (): ReactNode => <div className={styles.viewStack}>
|
|
965
|
+
<Card className={styles.kbCard}>
|
|
966
|
+
<div className={styles.kbCardHead}>
|
|
967
|
+
<div><h3><FolderOpen size={13} />原始文档</h3></div>
|
|
968
|
+
{/* 「添加文件」挂在卡片头上,不塞进筛选行:它是这张列表的动作,不是筛选条件。
|
|
969
|
+
挤在筛选行里,宿主侧栏的宽度下它必然被挤到第二行单独站着。 */}
|
|
970
|
+
<div className={styles.kbCardActions}>
|
|
971
|
+
<span className={styles.kbCount}>
|
|
972
|
+
{visibleDocuments.length === total ? `${total} 个` : `筛出 ${visibleDocuments.length} / ${total} 个`}
|
|
973
|
+
</span>
|
|
974
|
+
{/* 0034:收录闸门在设置里,这里**读它**。此前这个开关没有任何一处读 —— 闸门
|
|
975
|
+
关着时按钮照样可点,然后由服务端报错。参数搬进抽屉之后,「它现在是不是开着」
|
|
976
|
+
必须在动作上看得出来,否则用户只会看到一次莫名其妙的失败。 */}
|
|
977
|
+
<Button
|
|
978
|
+
variant="default"
|
|
979
|
+
size="sm"
|
|
980
|
+
onClick={() => importInput.current?.click()}
|
|
981
|
+
disabled={busy !== null || !active || gateClosed}
|
|
982
|
+
title={gateClosed ? '收录闸门已关闭:设置 → SourceRaw 收录' : '添加文件'}
|
|
983
|
+
>
|
|
984
|
+
<Upload size={14} />{busy === 'import' ? '导入中…' : '添加文件'}
|
|
985
|
+
</Button>
|
|
986
|
+
</div>
|
|
987
|
+
</div>
|
|
988
|
+
{/* 一行筛选:搜索 · 来源分类 · 文件类型 · 刷新 · 添加文件。
|
|
989
|
+
0027 之前这里是两行(上传行 + 筛选行),而「来源分类」出现在两处 —— 一处决定
|
|
990
|
+
新文件以什么身份进来,另一处只筛列表。同一个名字两个控件,人就会以为筛着的
|
|
991
|
+
那一类就是新文件会落到的那一类。现在它只有一个:**筛什么,就按什么收录**。 */}
|
|
992
|
+
<div className={styles.kbFilterRow}>
|
|
993
|
+
<Input className={styles.kbFilterSearch} value={query} placeholder="搜索标题、external_id 或 hash" onChange={(event: ChangeEvent<HTMLInputElement>) => setQuery(event.target.value)} />
|
|
994
|
+
<SelectMenu
|
|
995
|
+
value={typeFilter}
|
|
996
|
+
onValueChange={selectTypeFilter}
|
|
997
|
+
disabled={!active}
|
|
998
|
+
className="max-w-[220px]"
|
|
999
|
+
aria-label="按来源分类筛选"
|
|
1000
|
+
options={[
|
|
1001
|
+
{ value: '', label: '全部来源分类' },
|
|
1002
|
+
...(active?.sourceTypes ?? []).map(def => ({ value: def.id, label: `${def.label}(${def.sourceCount})` })),
|
|
1003
|
+
]}
|
|
1004
|
+
/>
|
|
1005
|
+
<SelectMenu
|
|
1006
|
+
value={extFilter}
|
|
1007
|
+
onValueChange={setExtFilter}
|
|
1008
|
+
className="max-w-[220px]"
|
|
1009
|
+
aria-label="按文件类型筛选"
|
|
1010
|
+
options={[{ value: '', label: '全部文件类型' }, ...extOptions.map(option => ({ value: option.value, label: option.label }))]}
|
|
1011
|
+
/>
|
|
1012
|
+
<Button variant="ghost" size="icon" title="刷新" aria-label="刷新文件列表" onClick={() => void refresh(activeId, query, typeFilter)}><RefreshCw size={14} /></Button>
|
|
1013
|
+
</div>
|
|
1014
|
+
<p className={styles.kbFilterHint}>
|
|
1015
|
+
新文件按 <b>{sourceTypeLabel(typeFilter || uploadType)}</b> 收录。这个选择器同时是筛选与收录分类:选中某一类时两者一致,选「全部」时只放开筛选。
|
|
1016
|
+
{(query || extFilter) ? ` 当前筛选:${[query && `关键词「${query}」`, extFilter && `类型 ${extFilter}`].filter(Boolean).join(' · ')}` : ''}
|
|
1017
|
+
</p>
|
|
1018
|
+
{visibleDocuments.length === 0 && <p className={styles.kbEmpty}>
|
|
1019
|
+
{loading
|
|
1020
|
+
? '加载中…'
|
|
1021
|
+
: documents.length > 0
|
|
1022
|
+
? '当前筛选没有命中的文件。清掉筛选条件就能看到这个知识库的全部文件。'
|
|
1023
|
+
: '这个知识库还没有文件。用右边的「添加文件」开始:纯文本直读,Word / PDF / 表格走转换内核。'}
|
|
1024
|
+
</p>}
|
|
1025
|
+
<ul className={styles.kbDocList}>
|
|
1026
|
+
{visibleDocuments.map(document => {
|
|
1027
|
+
const docCards = cardsBySource.get(document.docId) ?? []
|
|
1028
|
+
return <li key={document.docId} className={styles.kbDocRow}>
|
|
1029
|
+
<button type="button" className={styles.kbDocMain} onClick={() => void openDetail(document)}>
|
|
1030
|
+
<span className={styles.kbDocTitle}><FileText size={13} />{document.title}</span>
|
|
1031
|
+
<span className={styles.kbDocMeta}>
|
|
1032
|
+
{sourceTypeLabel(document.sourceType)}{document.sourceTypeKnown === false ? '(未知分类)' : ''}
|
|
1033
|
+
{' · '}{document.ext}
|
|
1034
|
+
{' · '}{document.sourceFormat || 'unknown'}
|
|
1035
|
+
{' · '}{formatBytes(document.bytes)}
|
|
1036
|
+
{document.assetCount > 0 ? ` · ${document.assetCount} 图` : ''}
|
|
1037
|
+
{' · '}{document.versionCount} 个版本
|
|
1038
|
+
{' · '}{docCards.length > 0 ? `${docCards.length} 张卡片` : '尚未编译出卡片'}
|
|
1039
|
+
{' · '}{formatTime(document.updatedAt)}
|
|
1040
|
+
</span>
|
|
1041
|
+
</button>
|
|
1042
|
+
<span className={styles.kbDocActions}>
|
|
1043
|
+
<Button variant="ghost" size="icon" title="详情" aria-label={`查看 ${document.title} 详情`} onClick={() => void openDetail(document)}><Eye size={13} /></Button>
|
|
1044
|
+
<Button variant="ghost" size="icon" title="删除" aria-label={`删除 ${document.title}`} disabled={busy !== null} onClick={() => void deleteDocument(document)}><Trash2 size={13} /></Button>
|
|
1045
|
+
</span>
|
|
1046
|
+
</li>
|
|
1047
|
+
})}
|
|
1048
|
+
</ul>
|
|
1049
|
+
</Card>
|
|
1050
|
+
|
|
1051
|
+
{/* 两块都不在这一栏里了,各有各的家(0034):
|
|
1052
|
+
- **来源类型**(本库词汇表)→ 工具栏的「类型定义」抽屉。它与 Wiki 类型是同一类
|
|
1053
|
+
东西(按库持久化的注册表),分居两栏时「改卡片 schema 去哪」得先回答「它属于
|
|
1054
|
+
编译那一段」——而那是实现细节。
|
|
1055
|
+
- **SourceRaw 收录与存储**(方案级闸门)→ 顶部齿轮的设置抽屉。
|
|
1056
|
+
这里不再留任何指路文字:工具栏与卡片头各自带着入口,路牌只是搬迁留下的债。 */}
|
|
1057
|
+
</div>
|
|
1058
|
+
|
|
1059
|
+
/** Wiki:卡片列表(一等内容)+ 编译 + 抽取流程 + schema 注册表。 */
|
|
1060
|
+
const renderWiki = (): ReactNode => <div className={styles.viewStack}>
|
|
1061
|
+
{/* 卡片列表排在第一位,和 SourceRaw 的文件列表同一个位置、同一套筛选行 ——
|
|
1062
|
+
两栏的「这一层有什么」都应该是打开就能看到的第一件东西。0027 之前这里
|
|
1063
|
+
先是一张编译统计卡,卡片列表在第三位,得先读过参数才看得到内容。 */}
|
|
1064
|
+
<Card className={styles.kbCard}>
|
|
1065
|
+
<div className={styles.kbCardHead}>
|
|
1066
|
+
<div><h3><FileText size={13} />wiki 卡片</h3></div>
|
|
1067
|
+
<span className={styles.kbCount}>
|
|
1068
|
+
{visibleCards.length === cards.length ? `${cards.length} 张` : `筛出 ${visibleCards.length} / ${cards.length} 张`}
|
|
1069
|
+
</span>
|
|
1070
|
+
</div>
|
|
1071
|
+
<div className={styles.kbFilterRow}>
|
|
1072
|
+
<Input className={styles.kbFilterSearch} value={cardQuery} placeholder="搜索标题、entity_id 或摘要" onChange={(event: ChangeEvent<HTMLInputElement>) => setCardQuery(event.target.value)} />
|
|
1073
|
+
<SelectMenu
|
|
1074
|
+
value={cardType}
|
|
1075
|
+
onValueChange={setCardType}
|
|
1076
|
+
className="max-w-[220px]"
|
|
1077
|
+
aria-label="按卡片类型筛选"
|
|
1078
|
+
options={[
|
|
1079
|
+
{ value: '', label: '全部卡片类型' },
|
|
1080
|
+
...cardTypes.map(type => ({ value: type, label: `${type}(${cards.filter(card => card.entityType === type).length})` })),
|
|
1081
|
+
]}
|
|
1082
|
+
/>
|
|
1083
|
+
<SelectMenu
|
|
1084
|
+
value={cardStatus}
|
|
1085
|
+
onValueChange={setCardStatus}
|
|
1086
|
+
className="max-w-[220px]"
|
|
1087
|
+
aria-label="按状态筛选"
|
|
1088
|
+
options={[
|
|
1089
|
+
{ value: '', label: '全部状态' },
|
|
1090
|
+
...cardStatuses.map(status => ({ value: status, label: `${status}(${cards.filter(card => card.status === status).length})` })),
|
|
1091
|
+
]}
|
|
1092
|
+
/>
|
|
1093
|
+
<Button variant="ghost" size="icon" title="刷新" aria-label="刷新卡片列表" onClick={() => void loadCards(activeIdRef.current)}><RefreshCw size={14} /></Button>
|
|
1094
|
+
</div>
|
|
1095
|
+
{pendingCards.length > 0 && <p className={styles.kbFilterHint}>有 <b>{pendingCards.length}</b> 张卡片缺必填小节 / 字段(report 模式只标记不拦截)。筛「待补」请用状态筛选,或在工具栏「类型定义」里切换生效方式。</p>}
|
|
1096
|
+
{visibleCards.length === 0 && <p className={styles.kbEmpty}>
|
|
1097
|
+
{cards.length > 0
|
|
1098
|
+
? '当前筛选没有命中的卡片。清掉筛选条件就能看到这个知识库的全部卡片。'
|
|
1099
|
+
: <>还没有卡片。先「增量编译」(Pass 0 会从正文标题、<b>[[链接]]</b> 与 frontmatter 抽候选),或让 Agent 用 <b>write_knowledge_card</b> 写入 —— 写入前会按 wiki 类型的 schema 校验。</>}
|
|
1100
|
+
</p>}
|
|
1101
|
+
<ul className={styles.cardList}>
|
|
1102
|
+
{visibleCards.map(card => {
|
|
1103
|
+
const issues = schemaIssuesOf(card)
|
|
1104
|
+
// Pass 0 抽出来的卡片是**机器猜的**,与 Agent 写入的卡片必须能一眼分开:
|
|
1105
|
+
// 前者的状态永远是 unreviewed,但「没审核」和「是抽出来的」是两件事。
|
|
1106
|
+
const extracted = card.metadata?.extraction === 'pass0'
|
|
1107
|
+
return <li key={card.entityId} className={styles.cardRow}>
|
|
1108
|
+
<button type="button" className={styles.cardRowMain} onClick={() => void openCard(card)}>
|
|
1109
|
+
<div className={styles.cardRowHead}>
|
|
1110
|
+
<Badge tone={card.status === 'active' || card.status === 'reviewed' ? 'success' : card.status === 'problem' ? 'warning' : 'neutral'}>{card.status}</Badge>
|
|
1111
|
+
<span className={styles.cardRowTitle} title={card.entityId}>{card.title}</span>
|
|
1112
|
+
{extracted && <Badge tone="blue">候选</Badge>}
|
|
1113
|
+
{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>
|
|
1118
|
+
</button>
|
|
1119
|
+
</li>
|
|
1120
|
+
})}
|
|
1121
|
+
</ul>
|
|
1122
|
+
</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 />
|
|
1151
|
+
|
|
1152
|
+
{/* **Wiki 类型**(卡片 schema)也不在这一栏了(0034):它和来源类型是同一类东西
|
|
1153
|
+
—— 按库持久化的词汇表,一个决定「一份文件以什么业务身份进来」,一个决定「一张
|
|
1154
|
+
卡片要长成什么样」。两处合到工具栏的「类型定义」抽屉里,schema 的入口就只有
|
|
1155
|
+
一个。这里消费它(写卡片时按它校验),但不定义它。 */}
|
|
1156
|
+
</div>
|
|
1157
|
+
|
|
1158
|
+
/**
|
|
1159
|
+
* 知识图谱:**一张真的图**,能按节点类型 / 边类型筛,能搜,能点。
|
|
1160
|
+
*
|
|
1161
|
+
* 0027 之前这里是「节点清单 + 折叠起来的边清单」——那回答的是「有哪些」,
|
|
1162
|
+
* 回答不了「连成什么样」,而后者才是打开这一栏的理由。
|
|
1163
|
+
* 构建设置是产出这张图的参数,所以入口挂在**这张卡片的头上**(0034):参数离它
|
|
1164
|
+
* 产出的东西只有一次点击,而不再是一句「已移到顶部齿轮」。
|
|
1165
|
+
*/
|
|
1166
|
+
const renderGraph = (): ReactNode => <div className={styles.viewStack}>
|
|
1167
|
+
<KnowledgeGraphView
|
|
1168
|
+
nodes={graph.nodes} edges={graph.edges} visible={visible && tab === 'graph'}
|
|
1169
|
+
sourceTitles={new Map(documents.map(document => [document.docId, document.title]))}
|
|
1170
|
+
/>
|
|
1171
|
+
|
|
1172
|
+
<Card className={styles.kbCard}>
|
|
1173
|
+
<div className={styles.kbCardHead}>
|
|
1174
|
+
<div><h3>构建产物</h3></div>
|
|
1175
|
+
{onOpenSettings && <div className={styles.kbCardActions}>
|
|
1176
|
+
<SettingsChip target="build" onOpen={onOpenSettings} />
|
|
1177
|
+
</div>}
|
|
1178
|
+
</div>
|
|
1179
|
+
<div className={styles.infoList}>
|
|
1180
|
+
<div><span>图谱节点</span><strong>{String(stats.graphNodes ?? graph.nodes.length)}</strong></div>
|
|
1181
|
+
<div><span>图谱边</span><strong>{String(stats.graphEdges ?? graph.edges.length)}</strong></div>
|
|
1182
|
+
<div><span>向量条目</span><strong>{String(stats.vectorCount ?? 0)}</strong></div>
|
|
1183
|
+
<div><span>最近构建</span><strong>{formatTime(typeof stats.builtAt === 'string' ? stats.builtAt : undefined)}</strong></div>
|
|
1184
|
+
</div>
|
|
1185
|
+
<p className={styles.kbHint}>
|
|
1186
|
+
图上有两类边:<b>supported_by</b>(wiki 卡片 → SourceRaw 的溯源边,每张卡片都能指回它的原始文档)与
|
|
1187
|
+
<b>relates_to</b>(wiki ↔ wiki 的实体关系边,由编译时的 Pass 0 从正文 <b>[[链接]]</b> 与 frontmatter 的关系声明里抽出)。
|
|
1188
|
+
关系边的两端都必须已经存在,抽到一半的关系会被丢掉并在编译报告里计数 —— 图上不会出现悬空的节点。
|
|
1189
|
+
</p>
|
|
1190
|
+
</Card>
|
|
1191
|
+
</div>
|
|
1192
|
+
|
|
1193
|
+
/**
|
|
1194
|
+
* 召回重排:真实运行面板。设置在这张面板的卡头上(参数 chip),不在这栏正文里。
|
|
1195
|
+
*
|
|
1196
|
+
* 两枚 chip 而不是一枚:这一栏同时消费**两组**参数(召回的候选池/阈值/路线,
|
|
1197
|
+
* 与上下文的 topN/重排阈值),而它们是抽屉里的两个分块。给一枚就只能落到其中
|
|
1198
|
+
* 一组,另一组又变回「要自己找」。文案与抽屉分块同源(`SettingsChip` 读
|
|
1199
|
+
* `NODE_SETTINGS`),所以两处不会漂移。
|
|
1200
|
+
*/
|
|
1201
|
+
const renderRecall = (): ReactNode => <div className={styles.viewStack}>
|
|
1202
|
+
<RecallPanel
|
|
1203
|
+
visible={visible && tab === 'recall'}
|
|
1204
|
+
kbId={active?.id}
|
|
1205
|
+
{...(active?.name ? { kbName: active.name } : {})}
|
|
1206
|
+
{...(onOpenSettings ? { settingsChip: <>
|
|
1207
|
+
<SettingsChip target="recall" onOpen={onOpenSettings} />
|
|
1208
|
+
<SettingsChip target="context" onOpen={onOpenSettings} />
|
|
1209
|
+
</> } : {})}
|
|
1210
|
+
resetKey={`${active?.id ?? ''}:${String(knowledgeValue?.recallTopK ?? '')}:${String(knowledgeValue?.recallMode ?? '')}:${String(knowledgeValue?.contextTopN ?? '')}`}
|
|
1211
|
+
defaults={{
|
|
1212
|
+
...(typeof knowledgeValue?.recallMode === 'string' ? { mode: knowledgeValue.recallMode } : {}),
|
|
1213
|
+
...(typeof knowledgeValue?.recallTopK === 'number' ? { topK: knowledgeValue.recallTopK } : {}),
|
|
1214
|
+
...(typeof knowledgeValue?.contextTopN === 'number' ? { topN: knowledgeValue.contextTopN } : {}),
|
|
1215
|
+
}}
|
|
1216
|
+
/>
|
|
1217
|
+
</div>
|
|
1218
|
+
|
|
1219
|
+
return <div className={styles.kbLayout}>
|
|
1220
|
+
{/* 一行顶栏:选库 · 这一库的计数 · 动作。**它在步骤轨之上** —— 先选「哪个知识库」,
|
|
1221
|
+
再看「这个知识库走到哪一步」。反过来(先看到五个步骤的计数,再选库)会让那些
|
|
1222
|
+
计数在切换知识库之前是别人的数字。
|
|
1223
|
+
0027 之前这是三行(选库 + 改名、编译 + 控制台、计数),而「改名 / 描述」
|
|
1224
|
+
还和库选择器里那行菜单重复了一遍 —— 同一件事两个入口,就会有人改完一处
|
|
1225
|
+
以为另一处也跟着变了。库路径也不在这里了:它是「存储位置」设置,
|
|
1226
|
+
入口在面板顶部的齿轮里。
|
|
1227
|
+
0034:「类型定义」也挂在这一行 —— 它是**按库**的动作(来源分类与卡片 schema 都是
|
|
1228
|
+
这一库的词汇表),所以它和「选哪个库」同层;而它**不是**第六个步骤:步骤轨是
|
|
1229
|
+
一条流水线,词汇表不属于任何一段(理由见 `TypeDefinitionDrawer.tsx`)。
|
|
1230
|
+
0035:**计数撤出这一行**,三个动作与选择器一起左对齐。计数没有丢 —— 步骤轨每一步
|
|
1231
|
+
第二行写的就是「这一段现在有多少东西」(`stepFacts`),而这一行只回答「现在看哪个
|
|
1232
|
+
库、能对它做什么」。把同一批数字在一屏里说两遍,会让选择器与动作被挤到两头。 */}
|
|
1233
|
+
<div className={styles.kbToolbar}>
|
|
1234
|
+
<ObjectPicker
|
|
1235
|
+
label="知识库"
|
|
1236
|
+
name={active?.name ?? (loading ? '加载中…' : '没有知识库')}
|
|
1237
|
+
items={pickerItems}
|
|
1238
|
+
currentId={activeId}
|
|
1239
|
+
createLabel="新建知识库"
|
|
1240
|
+
placeholder="还没有知识库"
|
|
1241
|
+
emptyLabel="还没有知识库,先新建一个"
|
|
1242
|
+
allowDeleteCurrent={false}
|
|
1243
|
+
onSelect={id => { setActiveId(id); activeIdRef.current = id; setExtFilter(''); setTypeFilter(''); setQuery('') }}
|
|
1244
|
+
onCreate={() => { setDraft({ id: '', name: '', description: '' }); setEditOpen(true) }}
|
|
1245
|
+
onRename={id => { const base = bases.find(item => item.id === id); if (base) { setDraft({ id: base.id, name: base.name, description: base.description }); setEditOpen(true) } }}
|
|
1246
|
+
onDelete={id => { void removeBase(id) }}
|
|
1247
|
+
/>
|
|
1248
|
+
{/* 三个动作同形:图标 + 文字的 outline 按钮,同一个 30px 高。内核控制台是一个
|
|
1249
|
+
**链接**,所以它走 `Button asChild` 拿到同一套外观 —— 早先它是一枚裸的
|
|
1250
|
+
`.tita-uiButton` 锚点,在这两枚按钮旁边读起来像第三种控件。 */}
|
|
1251
|
+
<span className={styles.kbToolbarActions}>
|
|
1252
|
+
<Button
|
|
1253
|
+
variant="outline"
|
|
1254
|
+
size="sm"
|
|
1255
|
+
disabled={!active}
|
|
1256
|
+
title={active ? `编辑 ${active.name} 的来源类型与 Wiki 类型` : '先选择一个知识库'}
|
|
1257
|
+
onClick={() => setTypeDefOpen(true)}
|
|
1258
|
+
><Tags size={13} />类型定义</Button>
|
|
1259
|
+
<Button variant="outline" size="sm" onClick={() => void rebuild('wiki')} disabled={busy !== null || !active}><RefreshCw size={14} />{busy === 'compile' ? '编译中…' : '编译'}</Button>
|
|
1260
|
+
<Button variant="outline" size="sm" asChild>
|
|
1261
|
+
<a href="/convert/" target="_blank" rel="noreferrer" title="打开解析内核控制台"><FileUp size={14} />解析内核</a>
|
|
1262
|
+
</Button>
|
|
1263
|
+
</span>
|
|
1264
|
+
</div>
|
|
1265
|
+
|
|
1266
|
+
{/* 分栏是**一条流水线**,所以切换器是步骤轨而不是一排等宽 tab:收录 → 编译 →
|
|
1267
|
+
关联 → 检索 → 精排。方向本身就是信息(写入侧在左、读取侧在右)。 */}
|
|
1268
|
+
<KnowledgeStepRail tab={tab} onChange={setTab} facts={stepFacts} />
|
|
1269
|
+
|
|
1270
|
+
{notice && <p className={cn(styles.kbNotice, notice.tone === 'error' && styles.kbNoticeError)} role="status">
|
|
1271
|
+
<AlertCircle size={13} /><span>{notice.text}</span>
|
|
1272
|
+
<button type="button" className={styles.kbNoticeClose} aria-label="关闭提示" onClick={() => setNotice(null)}><X size={12} /></button>
|
|
1273
|
+
</p>}
|
|
1274
|
+
|
|
1275
|
+
{/* 分栏内容按当前值渲染:切换器是步骤轨(不是 Radix Tabs),所以这里自己按值渲染。 */}
|
|
1276
|
+
{tab === 'source-raw' && renderSourceRaw()}
|
|
1277
|
+
{tab === 'wiki' && renderWiki()}
|
|
1278
|
+
{tab === 'graph' && renderGraph()}
|
|
1279
|
+
{tab === 'recall' && renderRecall()}
|
|
1280
|
+
|
|
1281
|
+
{/* 一个上传入口。accept 是**转换内核自己报的扩展名** ∪ 可直读的扩展名。
|
|
1282
|
+
0029 之前它来自每个库那份「文件类型规则」表:那张表会和内核真实支持的格式
|
|
1283
|
+
漂移(登记了 .docx 而内核不收,或反过来),于是选择框灰掉一个其实能转的文件。
|
|
1284
|
+
问内核就没有这个问题 —— 它就是判定的那一方。拿不到就不限制:让内核给出准确的
|
|
1285
|
+
FORMAT_UNSUPPORTED,比前端猜一张表强。 */}
|
|
1286
|
+
<input ref={importInput} type="file" multiple hidden
|
|
1287
|
+
{...(acceptExts.length > 0 ? { accept: acceptExts.join(',') } : {})}
|
|
1288
|
+
onChange={event => void handleImport(event)} />
|
|
1289
|
+
|
|
1290
|
+
<Dialog open={editOpen} onOpenChange={setEditOpen}>
|
|
1291
|
+
{editOpen && <DialogContent>
|
|
1292
|
+
<DialogHeader><DialogTitle>{draft.id ? '编辑知识库' : '新建知识库'}</DialogTitle></DialogHeader>
|
|
1293
|
+
<div className={styles.kbForm}>
|
|
1294
|
+
<label className={styles.kbField}><Label>名称</Label>
|
|
1295
|
+
<Input value={draft.name} onChange={(event: ChangeEvent<HTMLInputElement>) => setDraft({ ...draft, name: event.target.value })} placeholder="例如:产品文档" />
|
|
1296
|
+
</label>
|
|
1297
|
+
<label className={styles.kbField}><Label>描述</Label>
|
|
1298
|
+
<Input value={draft.description} onChange={(event: ChangeEvent<HTMLInputElement>) => setDraft({ ...draft, description: event.target.value })} placeholder="这个知识库放什么" />
|
|
1299
|
+
</label>
|
|
1300
|
+
<p className={styles.kbHint}>每个知识库有自己的根目录、来源分类与 Wiki 类型注册表;默认知识库沿用现有的知识库根目录。</p>
|
|
1301
|
+
</div>
|
|
1302
|
+
<DialogFooter>
|
|
1303
|
+
<Button variant="ghost" onClick={() => setEditOpen(false)}>取消</Button>
|
|
1304
|
+
<Button variant="default" disabled={!draft.name.trim() || busy !== null} onClick={() => void saveBase()}><Check size={13} />保存</Button>
|
|
1305
|
+
</DialogFooter>
|
|
1306
|
+
</DialogContent>}
|
|
1307
|
+
</Dialog>
|
|
1308
|
+
|
|
1309
|
+
{/* 本库的词汇表(0034):来源分类注册表 + Wiki 卡片 schema,一个抽屉、一个入口。
|
|
1310
|
+
它**不是**设置抽屉里的第七块 —— 那两个注册表按库持久化,而设置是整个方案一份。
|
|
1311
|
+
弹窗状态在这个组件内部;写路径仍走上面那几个 `patch*` / `saveSourceType`。 */}
|
|
1312
|
+
{active && <TypeDefinitionDrawer
|
|
1313
|
+
open={typeDefOpen}
|
|
1314
|
+
onOpenChange={setTypeDefOpen}
|
|
1315
|
+
kbId={active.id}
|
|
1316
|
+
kbName={active.name}
|
|
1317
|
+
sourceTypes={active.sourceTypes ?? []}
|
|
1318
|
+
wikiTypes={active.wikiTypes ?? []}
|
|
1319
|
+
schemaMode={active.wikiSchemaMode ?? 'report'}
|
|
1320
|
+
customized={active.wikiTypesCustomized === true}
|
|
1321
|
+
catalog={catalog}
|
|
1322
|
+
busy={busy}
|
|
1323
|
+
onSaveSourceType={saveSourceType}
|
|
1324
|
+
onToggleSourceType={toggleSourceType}
|
|
1325
|
+
onRemoveSourceType={removeSourceType}
|
|
1326
|
+
onApplyWikiTypes={patchWikiTypes}
|
|
1327
|
+
onModeChange={patchWikiSchemaMode}
|
|
1328
|
+
onResetWikiTypes={resetWikiTypes}
|
|
1329
|
+
/>}
|
|
1330
|
+
|
|
1331
|
+
<RestoreDialog
|
|
1332
|
+
open={restoreTarget !== null}
|
|
1333
|
+
onOpenChange={open => { if (!open) setRestoreTarget(null) }}
|
|
1334
|
+
targetLabel={restoreTarget ? `V${restoreTarget.number}` : ''}
|
|
1335
|
+
targetSummary={restoreTarget?.summary}
|
|
1336
|
+
artifactLabel="知识文档"
|
|
1337
|
+
pending={busy === 'restore'}
|
|
1338
|
+
onConfirm={() => void restoreVersion()}
|
|
1339
|
+
/>
|
|
1340
|
+
|
|
1341
|
+
<RestoreDialog
|
|
1342
|
+
open={cardRestore !== null}
|
|
1343
|
+
onOpenChange={open => { if (!open) setCardRestore(null) }}
|
|
1344
|
+
targetLabel={cardRestore ? `V${cardRestore.number}` : ''}
|
|
1345
|
+
targetSummary={cardRestore?.summary}
|
|
1346
|
+
artifactLabel="wiki 卡片"
|
|
1347
|
+
pending={busy === 'card-revert'}
|
|
1348
|
+
onConfirm={() => void revertCard()}
|
|
1349
|
+
/>
|
|
1350
|
+
|
|
1351
|
+
{/* **两个预览抽屉互斥**(0030)。
|
|
1352
|
+
它们原来是两个独立的 Radix 模态,`open` 只跟自己的 state 走 —— 于是「原文预览
|
|
1353
|
+
开着,又从别处点开一张卡片」就真的能叠成两层:两个遮罩、两套 Esc 监听,点一下
|
|
1354
|
+
叉号或空白处两个都关掉。把互斥写在 `open` 上,而不是指望每个入口都记得清理另
|
|
1355
|
+
一个 state —— 「同时开两个」在这里变成不可表达的状态。 */}
|
|
1356
|
+
<Drawer open={detail !== null && cardDetail === null} onOpenChange={next => { if (!next) setDetail(null) }}>
|
|
1357
|
+
{detail && <DrawerContent className={styles.drawer} resizable resizeKey="tita-source-preview">
|
|
1358
|
+
<DrawerHeader>
|
|
1359
|
+
<DrawerTitle>{detail.document.title}</DrawerTitle>
|
|
1360
|
+
<DrawerDescription>{detail.document.relativePath}</DrawerDescription>
|
|
1361
|
+
</DrawerHeader>
|
|
1362
|
+
<DrawerBody className={styles.drawerStack}>
|
|
1363
|
+
{/* 0035:两种预览是**同一套分块**(元信息 → 内容 → 版本),SourceRaw 只多一块
|
|
1364
|
+
「编译产物」。顺序不是随手排的:先确认「这是什么」(元信息,含 hash 与分类),
|
|
1365
|
+
再读「它写了什么」(内容),再看「它编译出了什么」(派生结论),最后才是
|
|
1366
|
+
「改过几次」(版本)。0030 那条「原文在编译产物之前」在这里仍然成立。
|
|
1367
|
+
时间一律用标准格式(`formatDateTime`)—— 抽屉里是逐字核对事实的地方。 */}
|
|
1368
|
+
<PreviewMeta rows={[
|
|
1369
|
+
{ label: '类型', value: `${detail.document.ext} · ${detail.document.sourceFormat || 'unknown'}` },
|
|
1370
|
+
{ label: '解析器', value: detail.document.parser || '—' },
|
|
1371
|
+
{ label: '来源分类', value: `${sourceTypeLabel(detail.document.sourceType)} · ${detail.document.sourceType}` },
|
|
1372
|
+
{ label: 'external_id', value: detail.document.externalId || '—', code: true },
|
|
1373
|
+
{ label: 'content_hash', value: detail.document.contentHash || '—', code: true },
|
|
1374
|
+
{ label: '大小', value: formatBytes(detail.document.bytes) },
|
|
1375
|
+
{ label: '创建', value: formatDateTime(detail.document.createdAt) },
|
|
1376
|
+
{ label: '更新', value: formatDateTime(detail.document.updatedAt) },
|
|
1377
|
+
]} />
|
|
1378
|
+
|
|
1379
|
+
<PreviewContent content={detail.document.content} assets={detail.document.assets} />
|
|
1380
|
+
|
|
1381
|
+
<div className={styles.drawerSection}>
|
|
1382
|
+
{/* WeKnora 的详情页在文件下面列分块;这里列的是这个 SourceRaw 编译出的 wiki 卡片。 */}
|
|
1383
|
+
<div className={styles.kbSubHead}><h4><Layers3 size={13} />编译产物</h4><span>{detailCards.length} 张卡片</span></div>
|
|
1384
|
+
{detailCards.length === 0
|
|
1385
|
+
? <p className={styles.kbHint}>还没有从这个文件编译出 wiki 卡片。先「编译」,或在概览里让 Agent 用 <b>write_knowledge_card</b> 引用它。</p>
|
|
1386
|
+
: <ul className={styles.cardList}>
|
|
1387
|
+
{detailCards.map(card => <li key={card.entityId} className={styles.cardRow}>
|
|
1388
|
+
<button type="button" className={styles.cardRowMain} onClick={() => void openCard(card)}>
|
|
1389
|
+
<div className={styles.cardRowHead}>
|
|
1390
|
+
<Badge tone={card.status === 'active' || card.status === 'reviewed' ? 'success' : 'neutral'}>{card.status}</Badge>
|
|
1391
|
+
<span className={styles.cardRowTitle} title={card.entityId}>{card.title}</span>
|
|
1392
|
+
<span className={styles.kbTypeCount}>{formatDateTime(card.updatedAt)}</span>
|
|
1393
|
+
</div>
|
|
1394
|
+
<p className={styles.cardRowText}>{card.summary || card.content.replace(/!\[[^\]]*\]\([^)]*\)/g, ' ').replace(/\s+/g, ' ').slice(0, 160) || '(空卡片)'}</p>
|
|
1395
|
+
<span className={styles.kbCode}>{card.entityType} · {card.entityId}</span>
|
|
1396
|
+
</button>
|
|
1397
|
+
</li>)}
|
|
1398
|
+
</ul>}
|
|
1399
|
+
</div>
|
|
1400
|
+
|
|
1401
|
+
<PreviewVersions
|
|
1402
|
+
entries={versionEntries}
|
|
1403
|
+
loading={detailLoading}
|
|
1404
|
+
emptyLabel="这个文件还没有历史版本(每次内容变化都会自动留一份快照)。"
|
|
1405
|
+
actions={{
|
|
1406
|
+
restorableId: currentVersionId,
|
|
1407
|
+
onRestore: entry => setRestoreTarget(entry),
|
|
1408
|
+
}}
|
|
1409
|
+
/>
|
|
1410
|
+
</DrawerBody>
|
|
1411
|
+
</DrawerContent>}
|
|
1412
|
+
</Drawer>
|
|
1413
|
+
|
|
1414
|
+
{/* wiki 卡片的侧边栏:与 SourceRaw 详情**同一种形态** —— 标题、元信息、内容、版本。 */}
|
|
1415
|
+
<Drawer open={cardDetail !== null} onOpenChange={next => { if (!next) setCardDetail(null) }}>
|
|
1416
|
+
{cardDetail && <DrawerContent className={styles.drawer} resizable resizeKey="tita-wiki-preview">
|
|
1417
|
+
<DrawerHeader>
|
|
1418
|
+
<DrawerTitle>{cardDetail.card.title}</DrawerTitle>
|
|
1419
|
+
<DrawerDescription>{cardDetail.card.entityType} · {cardDetail.card.entityId}</DrawerDescription>
|
|
1420
|
+
</DrawerHeader>
|
|
1421
|
+
<DrawerBody className={styles.drawerStack}>
|
|
1422
|
+
<PreviewMeta rows={[
|
|
1423
|
+
{ label: '状态', value: cardDetail.card.status },
|
|
1424
|
+
{ label: '类型', value: cardDetail.card.entityType },
|
|
1425
|
+
{ label: 'entity_id', value: cardDetail.card.entityId, code: true },
|
|
1426
|
+
{ label: '更新', value: formatDateTime(cardDetail.card.updatedAt) },
|
|
1427
|
+
{ label: '出处', value: cardDetail.card.sourceRefs.join(', ') || '—', code: true },
|
|
1428
|
+
]} />
|
|
1429
|
+
|
|
1430
|
+
{/* schema 待补紧跟在元信息后面:它是对「状态」那一行的展开,不是正文的一部分。 */}
|
|
1431
|
+
{schemaIssuesOf(cardDetail.card).length > 0 && <details className={styles.kbPrompt} open>
|
|
1432
|
+
<summary><ShieldCheck size={12} /> schema 待补 {schemaIssuesOf(cardDetail.card).length} 项(report 模式:只标记不拦截)</summary>
|
|
1433
|
+
<ul className={styles.kbPromptSteps}>
|
|
1434
|
+
{schemaIssuesOf(cardDetail.card).map((issue, index) => <li key={`${issue.message ?? index}`}><code>{issue.level ?? 'warning'}</code> {issue.message}</li>)}
|
|
1435
|
+
</ul>
|
|
1436
|
+
</details>}
|
|
1437
|
+
|
|
1438
|
+
<PreviewContent content={cardDetail.card.content} />
|
|
1439
|
+
|
|
1440
|
+
<PreviewVersions
|
|
1441
|
+
hint="卡片正文每次变化前都会留一份快照;回滚是往前的写入,所以回滚前的那一版也会被快照 —— 回滚本身也可以回滚。"
|
|
1442
|
+
entries={cardVersionEntries}
|
|
1443
|
+
loading={cardLoading}
|
|
1444
|
+
emptyLabel="这张卡片还没有历史版本(正文变化时才会生成快照;只改状态不会造版本)。"
|
|
1445
|
+
actions={{
|
|
1446
|
+
restorableId: currentCardVersionId,
|
|
1447
|
+
onRestore: entry => setCardRestore(entry),
|
|
1448
|
+
}}
|
|
1449
|
+
/>
|
|
1450
|
+
</DrawerBody>
|
|
1451
|
+
</DrawerContent>}
|
|
1452
|
+
</Drawer>
|
|
1453
|
+
</div>
|
|
1454
|
+
}
|