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
package/src/index.ts
ADDED
|
@@ -0,0 +1,1091 @@
|
|
|
1
|
+
import type { IncomingMessage, ServerResponse } from 'node:http'
|
|
2
|
+
import { createReadStream, existsSync } from 'node:fs'
|
|
3
|
+
import { stat } from 'node:fs/promises'
|
|
4
|
+
import { extname, isAbsolute, join, resolve, sep } from 'node:path'
|
|
5
|
+
import type { Context } from '@deepseek-ai/cordis'
|
|
6
|
+
import z from '@deepseek-ai/schemastery'
|
|
7
|
+
import { defineTool, type PreToolDecision, type ToolRunContext } from '@deepseek-ai/dsh-tools'
|
|
8
|
+
import type { LineageContext } from 'dsh-data-contracts/refs'
|
|
9
|
+
import {
|
|
10
|
+
DEFAULT_KNOWLEDGE_CONFIG, type Envelope, type KnowledgeBase, type KnowledgeConfig,
|
|
11
|
+
type KnowledgeStatus, type RecallMode, type SchemaType, type SourceType, type SourceTypeDef, type SourceTypeDraft,
|
|
12
|
+
type WikiSchemaMode, type WikiTypeDef,
|
|
13
|
+
} from './types.js'
|
|
14
|
+
import { defaultKnowledgeRoot, KnowledgeRuntime, success, failure, type KnowledgeRuntimeOptions, type WikiSchemaProvider } from './runtime.js'
|
|
15
|
+
import { unwrapGroup } from './settings-volatile.js'
|
|
16
|
+
import type { ModelCall } from './extract-model.js'
|
|
17
|
+
import {
|
|
18
|
+
agentLayout, defaultAgentRoot, ensureAgentLayout, intentRoot, knowledgeBaseRoot, legacyDefaultKnowledgeRoot,
|
|
19
|
+
memoryRoot, presetSourceDir, resourceRegistry, resourceRoot, type AgentLayout,
|
|
20
|
+
} from './layout.js'
|
|
21
|
+
import { migrateLegacyKnowledgeTree, migrateLegacyMemory, migrateLegacyResources, type MigrationOutcome } from './migrate.js'
|
|
22
|
+
import { legacyResourceHome } from './types.js'
|
|
23
|
+
import {
|
|
24
|
+
PickerError, createDirectory, listDirectory, pickDirectory, pickerKind, pickerOf,
|
|
25
|
+
} from './directory-picker.js'
|
|
26
|
+
import { DiagnosisStore } from './diagnosis/store.js'
|
|
27
|
+
import { KNOWLEDGE_SETTINGS_SCHEMA, SETTINGS_NAMESPACE } from './settings-schema.js'
|
|
28
|
+
import { DEFAULT_KB_ID, KnowledgeBases } from './knowledge-bases.js'
|
|
29
|
+
import {
|
|
30
|
+
applyConvert, CONVERT_SETTINGS_NAMESPACE, CONVERT_SETTINGS_SCHEMA, MOUNT_PATH as CONVERT_MOUNT_PATH,
|
|
31
|
+
type ConvertHost,
|
|
32
|
+
} from './convert/host.js'
|
|
33
|
+
import type { ConvertPluginConfig } from './convert/types.js'
|
|
34
|
+
import {
|
|
35
|
+
applyIntent, INTENT_MOUNT_PATH, INTENT_SERVICE, INTENT_SETTINGS_SCHEMA,
|
|
36
|
+
SETTINGS_NAMESPACE as INTENT_SETTINGS_NAMESPACE,
|
|
37
|
+
type IntentHost,
|
|
38
|
+
type IntentLineageService,
|
|
39
|
+
} from './intent/host.js'
|
|
40
|
+
import type { IntentSettings } from './intent/types.js'
|
|
41
|
+
|
|
42
|
+
export * from './types.js'
|
|
43
|
+
export * from './runtime.js'
|
|
44
|
+
export * from './knowledge-bases.js'
|
|
45
|
+
export * from './intent/types.js'
|
|
46
|
+
export * from './intent/engine.js'
|
|
47
|
+
export { applyConvert as applyConvertHost, CONVERT_SETTINGS_NAMESPACE, CONVERT_MOUNT_PATH, type ConvertHost }
|
|
48
|
+
export { applyIntent as applyIntentHost, INTENT_MOUNT_PATH, INTENT_SERVICE, INTENT_SETTINGS_NAMESPACE, type IntentHost, type IntentLineageService }
|
|
49
|
+
|
|
50
|
+
export const name = 'dsh-data-domain-knowledge'
|
|
51
|
+
/**
|
|
52
|
+
* `systemPrompt` is here because the 意图 module injects the category-routing
|
|
53
|
+
* section; the intent Host used to declare it in its own `inject` list before
|
|
54
|
+
* 0020 merged the two mount units.
|
|
55
|
+
*
|
|
56
|
+
* `settings` 在 0.1.7 之后**不再需要**:设置已经是本 entry 的 config
|
|
57
|
+
* (见下面的 `Config`),`settings.installSection` 那条路已经整个删除。
|
|
58
|
+
*/
|
|
59
|
+
export const inject = ['tools', 'webServer', 'systemPrompt'] as const
|
|
60
|
+
// schema 与命名空间搬到了 `settings-schema.ts`(可测)。这里再导出一次,保持
|
|
61
|
+
// `dsh-data-domain-knowledge` 的既有出口不变。
|
|
62
|
+
export { KNOWLEDGE_SETTINGS_SCHEMA, SETTINGS_NAMESPACE }
|
|
63
|
+
export { CONVERT_SETTINGS_SCHEMA, INTENT_SETTINGS_SCHEMA }
|
|
64
|
+
/**
|
|
65
|
+
* 组合 schema 与它的取值形态住在 `./config-schema.js`:那里**没有工具层依赖**,
|
|
66
|
+
* 所以 `tests/settings-schema.test.mjs` 能 import 它并把三组设置解析一遍(装载器
|
|
67
|
+
* 仍然要从**本模块**读到 `Config`,所以这里 import 之后再 re-export)。
|
|
68
|
+
*
|
|
69
|
+
* 0.1.7 删掉了「设置命名空间 + `settings.installSection`」这一整套 API,改成
|
|
70
|
+
* **设置就是插件自己 entry 的 config**:schema 在 `config-schema.js` 里声明,初值由
|
|
71
|
+
* profile patch 的 `config:` 提供,设置页里的改动由 `configEditor` 写回同一处,
|
|
72
|
+
* 客户端按**路径**寻址(`{op:'set', path:['intent','categories']}`)。
|
|
73
|
+
*/
|
|
74
|
+
import { Config, type KnowledgePluginConfig } from './config-schema.js'
|
|
75
|
+
export { Config, type KnowledgePluginConfig }
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* The live multi-knowledge-base registry, set by `apply()`.
|
|
79
|
+
*
|
|
80
|
+
* The convert host (owned by another module) needs to resolve a knowledge base
|
|
81
|
+
* by id without importing this plugin's `apply` scope, so the instance is
|
|
82
|
+
* published here. `undefined` before `apply()` runs (or in unit tests that only
|
|
83
|
+
* import the runtime).
|
|
84
|
+
*/
|
|
85
|
+
let activeKnowledgeBases: KnowledgeBases | undefined
|
|
86
|
+
export function currentKnowledgeBases(): KnowledgeBases | undefined { return activeKnowledgeBases }
|
|
87
|
+
|
|
88
|
+
type Args = Record<string, any>
|
|
89
|
+
type WebServerLike = { register(route: { kind: 'exact' | 'prefix'; path: string; handler: (req: IncomingMessage, res: ServerResponse) => Promise<void> }): () => unknown }
|
|
90
|
+
|
|
91
|
+
function output() { return { schema: { type: 'json' } as const, render: (_args: unknown, value: unknown) => [{ type: 'text' as const, text: JSON.stringify(value) }] } }
|
|
92
|
+
function tool(name: string, description: string, parameters: Record<string, unknown>, execute: (args: Args, exec: ToolRunContext) => Promise<unknown>) {
|
|
93
|
+
return defineTool({ name, description, parameters: parameters as any, output: output(), execute: execute as any } as any)
|
|
94
|
+
}
|
|
95
|
+
function envelopeError(code: string, message: string, suggestion?: string) { return failure({}, code, message, false, suggestion) }
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* 目录选择的失败码。`PickerError` 自带业务码就直接用,别的异常归到
|
|
99
|
+
* `PICKER_UNAVAILABLE` —— 不是目录选择器的问题,就是它没挂上。
|
|
100
|
+
*/
|
|
101
|
+
function pickerCode(cause: unknown): string {
|
|
102
|
+
return cause instanceof PickerError ? cause.code : 'PICKER_UNAVAILABLE'
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* 家当有没有真的落在 `resource/` 下(0029 的目标)。
|
|
107
|
+
*
|
|
108
|
+
* 三种情况要分开说,因为处理方式完全不同:
|
|
109
|
+
* - 在 `resource/` 里 → 什么都不用做(不在返回值里);
|
|
110
|
+
* - `outside`:是绝对路径但在别处(典型是旧家当目录 `~/.tita-data-agent`)——
|
|
111
|
+
* 迁移就是为它写的,重启后会被搬进来;
|
|
112
|
+
* - `bare`:根本不是一个路径(`python3`、`BAAI/bge-reranker-base`)—— 迁移搬不了它,
|
|
113
|
+
* 因为它从来不是一份「家当」,得先把它放成 `resource/models/<名字>`,或显式配一个路径。
|
|
114
|
+
*
|
|
115
|
+
* 面板靠它说人话:**「模型不在家当目录里」比「语义检索可能不可用」可操作得多。**
|
|
116
|
+
*/
|
|
117
|
+
function outsideResource(resources: { python: string; embeddingModel: string; rerankModel: string }, resourceDir: string): Array<{ key: string; value: string; kind: 'outside' | 'bare' }> {
|
|
118
|
+
const prefix = resourceDir.endsWith(sep) ? resourceDir : `${resourceDir}${sep}`
|
|
119
|
+
const outside: Array<{ key: string; value: string; kind: 'outside' | 'bare' }> = []
|
|
120
|
+
for (const key of ['python', 'embeddingModel', 'rerankModel'] as const) {
|
|
121
|
+
const value = resources[key]
|
|
122
|
+
if (!value || value === resourceDir || value.startsWith(prefix)) continue
|
|
123
|
+
outside.push({ key, value, kind: isAbsolute(value) ? 'outside' : 'bare' })
|
|
124
|
+
}
|
|
125
|
+
return outside
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
function lineageOf(args: Args, exec?: ToolRunContext): LineageContext | undefined {
|
|
129
|
+
const lineage: LineageContext = {}
|
|
130
|
+
const sessionId = typeof args.session_key === 'string' && args.session_key.trim() ? args.session_key.trim() : exec?.agent?.session.header.id
|
|
131
|
+
if (sessionId) lineage.sessionId = sessionId
|
|
132
|
+
const fields: Array<[keyof LineageContext, string]> = [
|
|
133
|
+
['turnId', 'turn_id'], ['intentId', 'intent_id'], ['taskId', 'task_id'],
|
|
134
|
+
['subtaskId', 'subtask_id'], ['parentSubtaskId', 'parent_subtask_id'], ['runId', 'run_id'], ['traceId', 'trace_id'], ['domain', 'domain'],
|
|
135
|
+
]
|
|
136
|
+
for (const [target, source] of fields) if (typeof args[source] === 'string' && args[source].trim()) lineage[target] = args[source].trim().slice(0, 160)
|
|
137
|
+
return Object.keys(lineage).length > 0 ? lineage : undefined
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
function lineageFromUrl(url: URL): LineageContext | undefined {
|
|
141
|
+
return lineageOf({
|
|
142
|
+
session_key: url.searchParams.get('session_key') ?? undefined,
|
|
143
|
+
turn_id: url.searchParams.get('turn_id') ?? undefined,
|
|
144
|
+
intent_id: url.searchParams.get('intent_id') ?? undefined,
|
|
145
|
+
task_id: url.searchParams.get('task_id') ?? undefined,
|
|
146
|
+
subtask_id: url.searchParams.get('subtask_id') ?? undefined,
|
|
147
|
+
parent_subtask_id: url.searchParams.get('parent_subtask_id') ?? undefined,
|
|
148
|
+
run_id: url.searchParams.get('run_id') ?? undefined,
|
|
149
|
+
trace_id: url.searchParams.get('trace_id') ?? undefined,
|
|
150
|
+
domain: url.searchParams.get('domain') ?? undefined,
|
|
151
|
+
})
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
async function readBody(req: IncomingMessage, maxBytes = 256 * 1024): Promise<any> {
|
|
155
|
+
const chunks: Buffer[] = []
|
|
156
|
+
let bytes = 0
|
|
157
|
+
for await (const chunk of req) {
|
|
158
|
+
const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(String(chunk))
|
|
159
|
+
bytes += buffer.length
|
|
160
|
+
if (bytes > maxBytes) throw new Error('REQUEST_TOO_LARGE')
|
|
161
|
+
chunks.push(buffer)
|
|
162
|
+
}
|
|
163
|
+
if (!chunks.length) return {}
|
|
164
|
+
return JSON.parse(Buffer.concat(chunks).toString('utf8'))
|
|
165
|
+
}
|
|
166
|
+
function send(res: ServerResponse, status: number, body: unknown): void { res.statusCode = status; res.setHeader('content-type', 'application/json; charset=utf-8'); res.setHeader('cache-control', 'no-store'); res.end(JSON.stringify(body)) }
|
|
167
|
+
function query(req: IncomingMessage): URL { return new URL(req.url ?? '/', 'http://127.0.0.1') }
|
|
168
|
+
|
|
169
|
+
/** URL-decoded path segments. `docId` contains `:`, so decoding is mandatory. */
|
|
170
|
+
function pathSegments(pathname: string): string[] {
|
|
171
|
+
return pathname.split('/').filter(Boolean).map(segment => {
|
|
172
|
+
try { return decodeURIComponent(segment) } catch { return segment }
|
|
173
|
+
})
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
function clampInt(raw: string | null, fallback: number, min: number, max: number): number {
|
|
177
|
+
if (raw === null || raw.trim() === '') return fallback
|
|
178
|
+
const value = Number(raw)
|
|
179
|
+
if (!Number.isInteger(value)) return fallback
|
|
180
|
+
return Math.min(max, Math.max(min, value))
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/** 删除守卫的 HTTP 状态:这三类冲突必须是 409,前端据此提示用户。 */
|
|
184
|
+
const CONFLICT_CODES = new Set([
|
|
185
|
+
'FILE_TYPE_IN_USE', 'BASE_IS_DEFAULT', 'BASE_IN_USE',
|
|
186
|
+
'SOURCE_TYPE_IN_USE', 'SOURCE_TYPE_EXISTS', 'SOURCE_TYPE_BUILTIN_LOCKED',
|
|
187
|
+
// 0024 卡片类型注册表
|
|
188
|
+
'WIKI_TYPE_IN_USE', 'WIKI_TYPE_EXISTS', 'WIKI_TYPE_BUILTIN_LOCKED',
|
|
189
|
+
])
|
|
190
|
+
const BAD_REQUEST_CODES = new Set([
|
|
191
|
+
'REQUEST_INVALID', 'NAME_REQUIRED', 'FILE_TYPES_INVALID', 'VERSION_ID_REQUIRED', 'PAYLOAD_INVALID',
|
|
192
|
+
// 0013 注册表:分类草稿不合规一律 400;「在用 / 内置 / 兜底」这类不可变更状态走 409。
|
|
193
|
+
'SOURCE_TYPES_INVALID', 'SOURCE_TYPE_ID_INVALID', 'SOURCE_TYPE_LABEL_REQUIRED', 'SOURCE_TYPE_DESCRIPTION_REQUIRED',
|
|
194
|
+
'SOURCE_TYPE_DESCRIPTION_BY_FORMAT', 'SOURCE_TYPE_SIGNALS_REQUIRED', 'SOURCE_TYPE_EXCLUDES_REQUIRED',
|
|
195
|
+
'SOURCE_TYPE_EXCLUDE_DANGLING', 'SOURCE_TYPE_LIMIT', 'SOURCE_TYPE_FALLBACK_REQUIRED',
|
|
196
|
+
// 0024 卡片类型注册表:草稿不合规一律 400
|
|
197
|
+
'WIKI_TYPES_INVALID', 'WIKI_TYPE_ID_INVALID', 'WIKI_TYPE_LABEL_REQUIRED', 'WIKI_TYPE_DESCRIPTION_REQUIRED',
|
|
198
|
+
'WIKI_TYPE_SECTIONS_REQUIRED', 'WIKI_TYPE_SECTION_REQUIRED_MISSING', 'WIKI_TYPE_FIELD_DUPLICATE',
|
|
199
|
+
'CARD_SCHEMA_INVALID',
|
|
200
|
+
])
|
|
201
|
+
|
|
202
|
+
function envelopeStatus(envelope: Envelope<unknown>): number {
|
|
203
|
+
const code = envelope?.error?.code
|
|
204
|
+
if (!code) return 200
|
|
205
|
+
if (CONFLICT_CODES.has(code)) return 409
|
|
206
|
+
if (code === 'METHOD_NOT_ALLOWED') return 405
|
|
207
|
+
if (BAD_REQUEST_CODES.has(code)) return 400
|
|
208
|
+
if (code === 'NOT_FOUND' || code.endsWith('_NOT_FOUND')) return 404
|
|
209
|
+
return 200
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
/**
|
|
213
|
+
* 只给**新增**的 base / document 路由用:按错误码给出真实的 HTTP 状态
|
|
214
|
+
* (409 / 404 / 400),并在守卫冲突时把 `code`/`message` 同时提到顶层,
|
|
215
|
+
* 前端既能读 envelope 也能直接读冲突码。既有路由仍保持 200 + envelope。
|
|
216
|
+
*/
|
|
217
|
+
function sendEnvelope(res: ServerResponse, envelope: Envelope<unknown>): void {
|
|
218
|
+
const status = envelopeStatus(envelope)
|
|
219
|
+
if (status === 200 || !envelope?.error) { send(res, status, envelope); return }
|
|
220
|
+
send(res, status, { ...envelope, code: envelope.error.code, message: envelope.error.message })
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
const ASSET_CONTENT_TYPES: Record<string, string> = {
|
|
224
|
+
'.png': 'image/png', '.jpg': 'image/jpeg', '.jpeg': 'image/jpeg', '.gif': 'image/gif',
|
|
225
|
+
'.webp': 'image/webp', '.svg': 'image/svg+xml', '.bmp': 'image/bmp', '.avif': 'image/avif',
|
|
226
|
+
'.pdf': 'application/pdf', '.json': 'application/json; charset=utf-8',
|
|
227
|
+
'.txt': 'text/plain; charset=utf-8', '.md': 'text/markdown; charset=utf-8',
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
/**
|
|
231
|
+
* 把 entry config 里的 `knowledge` 组补成**完整且合法**的配置。
|
|
232
|
+
*
|
|
233
|
+
* 组合层正常情况下会把 schema 默认值合进来,但这个函数也会被单元测试和别的装配
|
|
234
|
+
* 方式调用,那时给回来的可能是缺字段的对象(甚至 `undefined`)。而这两个路径字段
|
|
235
|
+
* 一旦是 `undefined`,`layout.ts` 里的 `resolve()` 会直接抛 —— 一个配置疏漏不该把
|
|
236
|
+
* 整个插件打挂,所以在这里补,而不是在每个用它的地方都判一次。
|
|
237
|
+
*
|
|
238
|
+
* `unwrapGroup` 那一层是 0.1.7 的 volatile 契约:schema 解析出来的字段是引用
|
|
239
|
+
* (`{get, write}`),不是值。见 `settings-volatile.ts`。
|
|
240
|
+
*/
|
|
241
|
+
function normalizeKnowledge(next: Partial<KnowledgeConfig> | undefined): KnowledgeConfig {
|
|
242
|
+
const plain = unwrapGroup<KnowledgeConfig>(next)
|
|
243
|
+
return {
|
|
244
|
+
...DEFAULT_KNOWLEDGE_CONFIG,
|
|
245
|
+
...plain,
|
|
246
|
+
agentRoot: typeof plain.agentRoot === 'string' && plain.agentRoot.trim() ? plain.agentRoot : defaultAgentRoot(),
|
|
247
|
+
rootDir: typeof plain.rootDir === 'string' ? plain.rootDir : '',
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
|
|
252
|
+
export function apply(ctx: Context, pluginConfig: KnowledgePluginConfig = {}): void {
|
|
253
|
+
/**
|
|
254
|
+
* 0029:配置里现在有两个路径字段,但只有 `agentRoot` 是「设置」。
|
|
255
|
+
*
|
|
256
|
+
* `rootDir` 是旧布局的知识树根,只作为迁移来源存在。初始值给空串而不是旧默认值
|
|
257
|
+
* ——迁移的来源由下面的 `legacySource()` 决定,它还要考虑「没设过 rootDir 的
|
|
258
|
+
* 用户,旧树在旧默认位置」这一种情况。
|
|
259
|
+
*
|
|
260
|
+
* 0.1.7:值来自本 entry 的 `config`(`apply` 的第二个参数),不再是
|
|
261
|
+
* `settings.installSection` 推回来的命名空间。这里先 `normalizeKnowledge` 补齐,
|
|
262
|
+
* 之后的迁移与 runtime 都只认这一份配置。
|
|
263
|
+
*/
|
|
264
|
+
let config: KnowledgeConfig = normalizeKnowledge(pluginConfig.knowledge)
|
|
265
|
+
/**
|
|
266
|
+
* entry config 的另外两组,**按需求值**。
|
|
267
|
+
*
|
|
268
|
+
* 为什么是函数而不是先解包成一个值:0.1.7 的设置字段全标了 volatile,schema
|
|
269
|
+
* 解析出来是引用;解包一次就是一张快照,而 volatile 改动**不会重载插件**
|
|
270
|
+
* (loader 只就地改引用、再发一个 `loader/volatile-update`)。函数让下游每次
|
|
271
|
+
* 「重灌」都从同一个引用重新读值,改设置才真的会生效。
|
|
272
|
+
*
|
|
273
|
+
* `knowledge` 那组不需要函数:`normalizeKnowledge` 本来就在 volatile 事件里重跑一遍。
|
|
274
|
+
*/
|
|
275
|
+
const intentGroup = (): Partial<IntentSettings> => unwrapGroup<IntentSettings>(pluginConfig.intent)
|
|
276
|
+
const convertGroup = (): Partial<ConvertPluginConfig> => unwrapGroup<ConvertPluginConfig>(pluginConfig.convert)
|
|
277
|
+
/**
|
|
278
|
+
* 这两组的把手,`loader/volatile-update` 时调 `reprocess()` 按新值重灌。
|
|
279
|
+
*
|
|
280
|
+
* 先声明后赋值:事件只在运行时触发,而两组的装配在下面(`applyConvert` /
|
|
281
|
+
* `applyIntent`)。
|
|
282
|
+
*/
|
|
283
|
+
let intentHost: IntentHost | undefined
|
|
284
|
+
let convertHost: ConvertHost | undefined
|
|
285
|
+
/** 知识树迁移的结论。`/knowledge/layout` 会带上它,界面据此如实交代数据搬没搬。 */
|
|
286
|
+
let migration: MigrationOutcome = { status: 'skipped', reason: '尚未解析设置' }
|
|
287
|
+
/** 家当(模型 / Python 环境 / 注册表)迁移的结论。 */
|
|
288
|
+
let resourceMigration: MigrationOutcome = { status: 'skipped', reason: '尚未解析设置' }
|
|
289
|
+
/** 记忆段(知识树 → `<agentRoot>/memory/<库 id>`)迁移的结论(0032)。 */
|
|
290
|
+
let memoryMigration: MigrationOutcome = { status: 'skipped', reason: '尚未解析设置' }
|
|
291
|
+
/**
|
|
292
|
+
* 初始化失败的原因(如果有)。字段名保持 `settingsError`:`/knowledge/layout`
|
|
293
|
+
* 与面板上的告警都按这个名字读,改名的收益不如改语义的成本高。
|
|
294
|
+
*
|
|
295
|
+
* 旧的理由是「设置分区注册失败会被静默吞掉」(v0.7.5 的
|
|
296
|
+
* `z.string().min(1).default('')` 让整个命名空间注册不上)。0.1.7 之后 schema
|
|
297
|
+
* 错误会在装载 entry 时就抛、插件直接加载失败,不再是静默的;但**迁移**仍可能
|
|
298
|
+
* 因为一个权限问题失败,而那条失败不该让插件起不来,所以这里依旧接住并上报。
|
|
299
|
+
*/
|
|
300
|
+
let settingsError: string | undefined
|
|
301
|
+
/**
|
|
302
|
+
* 迁移只该发生一次 —— 实际含义是「每次 apply 最多一次」。
|
|
303
|
+
*
|
|
304
|
+
* 0.1.7 下改设置会让插件**整个重载**,apply 因此会再跑一遍。用「已经跑过」而不是
|
|
305
|
+
* 「结果如何」做闸门,是为了不让一个失败的迁移在重载时被反复重试。
|
|
306
|
+
*/
|
|
307
|
+
let migrationDone = false
|
|
308
|
+
|
|
309
|
+
/**
|
|
310
|
+
* 迁移的旧位置来源。
|
|
311
|
+
*
|
|
312
|
+
* 显式设过 `rootDir` 就用它;否则退回旧默认位置(`DSH_KNOWLEDGE_ROOT` 或
|
|
313
|
+
* `~/Desktop/titaagent/domain_knowledge`)。判据交给 `migrateLegacyKnowledgeTree`,
|
|
314
|
+
* 这里只负责「谁可能是旧树」。
|
|
315
|
+
*/
|
|
316
|
+
const legacySource = (): string => config.rootDir.trim() || legacyDefaultKnowledgeRoot()
|
|
317
|
+
|
|
318
|
+
/**
|
|
319
|
+
* 默认库的来源分类注册表提供者(0013)。
|
|
320
|
+
*
|
|
321
|
+
* `bases` 在下面几行之后才创建,所以先用一个可变槽位接住;真正读取发生在
|
|
322
|
+
* 调用时(`add_source` / `list_source_types`),那时 `bases` 一定已就绪。
|
|
323
|
+
* 用 provider 而不是快照,是为了让用户改完分类**立刻**对 Agent 生效。
|
|
324
|
+
*/
|
|
325
|
+
let defaultBaseSourceTypes: (() => SourceTypeDef[]) | undefined
|
|
326
|
+
let defaultBaseWikiSchema: WikiSchemaProvider | undefined
|
|
327
|
+
/**
|
|
328
|
+
* #3b:把 **DSH 的默认模型**包成运行时认识的纯文本调用。
|
|
329
|
+
*
|
|
330
|
+
* 这里刻意**不复制一份模型选择**。`provider` / `model` / `reasoningEffort` 的真源是
|
|
331
|
+
* DSH 自己的 `agentDefaultModel`(设置里那一项)。插件再存一份,就会出现「设置里显示
|
|
332
|
+
* A、实际调的是 B」—— 0029 想收拢的正是这类两处说法。
|
|
333
|
+
*
|
|
334
|
+
* 取不到 `llm` 或默认模型时返回 undefined,链路**退回纯规则抽取**:模型是增强,
|
|
335
|
+
* 不是前提。
|
|
336
|
+
*/
|
|
337
|
+
const modelCallFor = (): ModelCall | undefined => {
|
|
338
|
+
const llm = ctx.get('llm') as { stream(options: Record<string, unknown>): AsyncIterable<{ type?: string; text?: string }> } | undefined
|
|
339
|
+
/**
|
|
340
|
+
* `agentDefaultModel` 是一个**服务实例**(`dsh-agent-default-model` 的
|
|
341
|
+
* `AgentDefaultModelConfig extends Service`),默认模型要从它的
|
|
342
|
+
* `currentSelection()` 拿。
|
|
343
|
+
*
|
|
344
|
+
* 直接读 `.provider` 是取不到的:它的 Config 字段标了 volatile,解析出来是引用,
|
|
345
|
+
* 所以那个类自己也是用 `this.config.provider.get()` 读的。第一版就是按普通属性读的,
|
|
346
|
+
* 结果是永远拿到 undefined —— 模型接缝静默失效。
|
|
347
|
+
*/
|
|
348
|
+
const service = ctx.get('agentDefaultModel') as { currentSelection?: () => { provider?: string; model?: string; reasoningEffort?: string } } | undefined
|
|
349
|
+
const route = service?.currentSelection?.()
|
|
350
|
+
if (!llm || !route?.provider || !route?.model) return undefined
|
|
351
|
+
const { provider, model, reasoningEffort } = route
|
|
352
|
+
return async ({ system, prompt }) => {
|
|
353
|
+
let text = ''
|
|
354
|
+
const request = { provider, model, ...(reasoningEffort ? { reasoningEffort } : {}), system, messages: [{ role: 'user', content: prompt }] }
|
|
355
|
+
for await (const chunk of llm.stream(request)) {
|
|
356
|
+
if (chunk?.type === 'text-delta' && typeof chunk.text === 'string') text += chunk.text
|
|
357
|
+
}
|
|
358
|
+
return text
|
|
359
|
+
}
|
|
360
|
+
}
|
|
361
|
+
const runtimeOptions = (): KnowledgeRuntimeOptions => {
|
|
362
|
+
const modelCall = modelCallFor()
|
|
363
|
+
return {
|
|
364
|
+
...(defaultBaseSourceTypes ? { sourceTypes: defaultBaseSourceTypes } : {}),
|
|
365
|
+
...(defaultBaseWikiSchema ? { wikiSchema: defaultBaseWikiSchema } : {}),
|
|
366
|
+
// 0029:家当目录。配置里三个路径为空时,运行时从这里解析模型与解释器。
|
|
367
|
+
resourceRoot: resourceRoot(config.agentRoot),
|
|
368
|
+
// 0032:记忆段。默认库的记忆落在 `<agentRoot>/memory/default`,与知识树平级。
|
|
369
|
+
memoryRoot: memoryRoot(config.agentRoot, DEFAULT_KB_ID),
|
|
370
|
+
// #3b:模型接缝(实体抽取与归并)。没有默认模型时不提供,链路自动退回规则抽取。
|
|
371
|
+
...(modelCall ? { modelCall } : {}),
|
|
372
|
+
}
|
|
373
|
+
}
|
|
374
|
+
/** 默认知识库的根:`<agentRoot>/domain_knowledge/default`。 */
|
|
375
|
+
const defaultBaseRoot = (): string => knowledgeBaseRoot(config.agentRoot, 'default')
|
|
376
|
+
let runtime = new KnowledgeRuntime(defaultBaseRoot(), config, runtimeOptions())
|
|
377
|
+
ensureAgentLayout(config.agentRoot)
|
|
378
|
+
|
|
379
|
+
// Multi-knowledge-base registry (0021). The default base reuses the
|
|
380
|
+
// settings-driven runtime above, so `runtimeOf('default')` — and therefore
|
|
381
|
+
// every legacy route/tool that omits `kb` — stays byte-identical to before.
|
|
382
|
+
// 0029: 新建的库落在 `<agentRoot>/domain_knowledge/<id>`,不再是各写各的自由路径。
|
|
383
|
+
const bases = new KnowledgeBases({
|
|
384
|
+
defaultRootDir: defaultBaseRoot,
|
|
385
|
+
agentRoot: () => config.agentRoot,
|
|
386
|
+
// 0029:注册表也进根路径 —— 「这个 Agent 的全部磁盘足迹」应该是一个目录。
|
|
387
|
+
registryFile: () => resourceRegistry(config.agentRoot),
|
|
388
|
+
defaultRuntime: () => runtime,
|
|
389
|
+
config: () => config,
|
|
390
|
+
})
|
|
391
|
+
runtime.baseId = bases.defaultId
|
|
392
|
+
defaultBaseSourceTypes = () => bases.sourceTypesOf(bases.defaultId)
|
|
393
|
+
defaultBaseWikiSchema = () => ({ types: bases.wikiTypesOf(bases.defaultId), mode: bases.wikiSchemaModeOf(bases.defaultId) })
|
|
394
|
+
activeKnowledgeBases = bases
|
|
395
|
+
|
|
396
|
+
const kbIdOf = (value: unknown): string => {
|
|
397
|
+
if (value === undefined || value === null) return bases.defaultId
|
|
398
|
+
const id = String(value).trim()
|
|
399
|
+
return id || bases.defaultId
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
/**
|
|
403
|
+
* 0.1.7 的 volatile 配置改动:**宿主不重载插件,插件必须自己用上去。**
|
|
404
|
+
*
|
|
405
|
+
* `Entry._commitVolatile()` 只调 `updateVolatile()` 就地改引用,再发一个
|
|
406
|
+
* `loader/volatile-update` 事件。所以「设置存下了但没生效」这件事只能在这里兜住 ——
|
|
407
|
+
* 设置 schema 里的字段全部标了 volatile(见 `settings-volatile.ts`),这两件事必须成对。
|
|
408
|
+
*
|
|
409
|
+
* 就地更新之所以够用,是因为 `pluginConfig` 就是宿主那份被改过的对象:重新
|
|
410
|
+
* `normalizeKnowledge` 一下就能拿到新值。
|
|
411
|
+
*
|
|
412
|
+
* 分两种改动处理:
|
|
413
|
+
*
|
|
414
|
+
* - **非结构性**(阈值、topK、provider、开关……):这些字段运行时是**每次调用现读**
|
|
415
|
+
* `this.config` 的,合并进去就生效。
|
|
416
|
+
* - **`agentRoot` 是结构性的**:它决定知识树在磁盘的哪里。根一变,已经建好的 runtime
|
|
417
|
+
* 和注册表缓存都还指着旧目录 —— 只合并配置会得到最坏的结果「设置显示新路径、实际
|
|
418
|
+
* 还在读写旧路径」。所以这里重建默认 runtime、重指注册表、重新铺一次布局。
|
|
419
|
+
*/
|
|
420
|
+
// `loader/volatile-update` 是 loader 运行时真实发出的事件,但没进 cordis 的 `Events`
|
|
421
|
+
// 类型表(它是 loader 内部约定),所以这里显式放宽一次。
|
|
422
|
+
const volatileCtx = ctx as unknown as { on(name: string, listener: () => void): () => void }
|
|
423
|
+
ctx.effect(() => volatileCtx.on('loader/volatile-update', () => {
|
|
424
|
+
const previousAgentRoot = config.agentRoot
|
|
425
|
+
config = normalizeKnowledge(pluginConfig.knowledge)
|
|
426
|
+
runtime.updateConfig(config)
|
|
427
|
+
/**
|
|
428
|
+
* 意图与转换两组挂在**同一个 entry** 上(`config-schema.ts` 里那三组),所以
|
|
429
|
+
* volatile 事件也会带着它们的改动。它们不会因此被重载,只能自己就地重灌 ——
|
|
430
|
+
* 漏掉这一步的后果是「设置页保存了、界面显示新值、Agent 还在用旧值」。
|
|
431
|
+
*
|
|
432
|
+
* 放在 `agentRoot` 那条提前返回**之前**:根没变不代表另外两组没变。
|
|
433
|
+
*/
|
|
434
|
+
intentHost?.reprocess()
|
|
435
|
+
convertHost?.reprocess()
|
|
436
|
+
if (config.agentRoot === previousAgentRoot) return
|
|
437
|
+
runtime.close()
|
|
438
|
+
runtime = new KnowledgeRuntime(defaultBaseRoot(), config, runtimeOptions())
|
|
439
|
+
runtime.baseId = bases.defaultId
|
|
440
|
+
ensureAgentLayout(config.agentRoot)
|
|
441
|
+
bases.invalidate()
|
|
442
|
+
void bases.repoint()
|
|
443
|
+
}), 'tita knowledge: apply volatile config')
|
|
444
|
+
|
|
445
|
+
/** Runtime behind an optional `kb`; `undefined` when the id is unknown. */
|
|
446
|
+
const runtimeOf = (id: string): KnowledgeRuntime | undefined => {
|
|
447
|
+
if (id === bases.defaultId) return runtime
|
|
448
|
+
return bases.get(id) ? bases.runtimeOf(id) : undefined
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
/** Tool-facing wrapper: an unknown `kb` becomes a BASE_NOT_FOUND envelope. */
|
|
452
|
+
const withRuntime = async <T>(kb: unknown, run: (target: KnowledgeRuntime) => Promise<Envelope<T>>): Promise<Envelope<T>> => {
|
|
453
|
+
const id = kbIdOf(kb)
|
|
454
|
+
const target = runtimeOf(id)
|
|
455
|
+
if (!target) {
|
|
456
|
+
// 与 `SOURCE_TYPE_UNKNOWN` 同一条纪律:把可用值直接写进自愈建议,调用方不必再跑
|
|
457
|
+
// 一趟才发现自己拿的是错 id。这里原先是「先列出 /knowledge/bases」——那是给浏览器
|
|
458
|
+
// 面板读的 HTTP 路由,模型照着做不了。
|
|
459
|
+
const available = bases.list().map(base => base.id).join(' / ')
|
|
460
|
+
return failure({} as T, 'BASE_NOT_FOUND', `知识库不存在: ${id}`, true, `先调 list_knowledge_bases 确认 id;当前可用: ${available}(不传 kb 表示默认库 ${bases.defaultId})。`)
|
|
461
|
+
}
|
|
462
|
+
return run(target)
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
/**
|
|
466
|
+
* 给每个知识库搬记忆段(0032)。
|
|
467
|
+
*
|
|
468
|
+
* 结论只保留**最后一个非 skipped 的**:默认库才是用户实际在用的那个,而面板上
|
|
469
|
+
* 一行位置有限。真有多个库搬失败时,`/knowledge/layout` 的 `resourceMigration`
|
|
470
|
+
* 与知识库列表里的路径仍然能对上账。
|
|
471
|
+
*/
|
|
472
|
+
const migrateAllMemory = (): MigrationOutcome => {
|
|
473
|
+
const bases = activeKnowledgeBases
|
|
474
|
+
if (!bases) return { status: 'skipped', reason: '知识库注册表尚未就绪' }
|
|
475
|
+
let outcome: MigrationOutcome = { status: 'skipped', reason: '每个知识库的记忆都在新位置' }
|
|
476
|
+
const roots = bases.list()
|
|
477
|
+
const defaultBase = roots.find(item => item.id === bases.defaultId)
|
|
478
|
+
// 默认库的根由配置推出来,可能还没出现在注册表里(首启);补上它。
|
|
479
|
+
const plan = defaultBase ? roots : [{ id: bases.defaultId, rootDir: defaultBaseRoot() }, ...roots]
|
|
480
|
+
for (const base of plan) {
|
|
481
|
+
try {
|
|
482
|
+
const moved = migrateLegacyMemory({ agentRoot: config.agentRoot, kbRoot: base.rootDir, kbId: base.id })
|
|
483
|
+
if (moved.status !== 'skipped') outcome = moved
|
|
484
|
+
} catch (cause) {
|
|
485
|
+
outcome = {
|
|
486
|
+
status: 'failed',
|
|
487
|
+
from: join(base.rootDir, '04_memory'),
|
|
488
|
+
to: memoryRoot(config.agentRoot, base.id),
|
|
489
|
+
reason: cause instanceof Error ? cause.message : String(cause),
|
|
490
|
+
}
|
|
491
|
+
}
|
|
492
|
+
}
|
|
493
|
+
return outcome
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
/**
|
|
497
|
+
* 初始化:迁移 → 布局 → 构造 runtime。
|
|
498
|
+
*
|
|
499
|
+
* 顺序是**硬要求**:配置已由外层的 `normalizeKnowledge` 补齐,这里先迁移(迁移要读
|
|
500
|
+
* 新的 `agentRoot` 与旧的 `rootDir`),最后才构造 runtime。反过来的话,runtime 会在
|
|
501
|
+
* 数据搬完之前就指着新路径,于是「刚启动时知识库是空的」——而那正是最容易让人以为
|
|
502
|
+
* 数据丢了的时刻。
|
|
503
|
+
*
|
|
504
|
+
* 0.1.7:这段以前挂在 `settings.installSection` 的 `setSource`/`onChange` 回调里,
|
|
505
|
+
* 因为配置要等设置层推回来。现在配置是 `apply` 的入参,所以直接在 apply 时跑一次;
|
|
506
|
+
* 改设置会让插件**整个重载**,于是它会随重载再跑,由 `migrationDone` 挡住重复迁移。
|
|
507
|
+
*/
|
|
508
|
+
const initialize = (): void => {
|
|
509
|
+
if (!migrationDone) {
|
|
510
|
+
migrationDone = true
|
|
511
|
+
// 顺序:先把知识树搬进新根路径,再把家当搬进来。家当要在 runtime 构造**之前**
|
|
512
|
+
// 到位 —— 否则模型与解释器会先按旧位置解析一次,那一轮跑的是旧环境。
|
|
513
|
+
migration = migrateLegacyKnowledgeTree({ agentRoot: config.agentRoot, legacyRoot: legacySource() })
|
|
514
|
+
resourceMigration = migrateLegacyResources({ agentRoot: config.agentRoot, legacyHome: legacyResourceHome() })
|
|
515
|
+
/**
|
|
516
|
+
* 0032:记忆段从知识树搬出去。**每个知识库各搬一次** —— 它按库分叉,而
|
|
517
|
+
* 非默认库的 runtime 是懒建的(`runtimeOf`),懒建里做迁移会把「打开一个库」
|
|
518
|
+
* 变成一次可能的搬盘。放在这里,顺序与另外两个迁移一致:搬完才构造 runtime。
|
|
519
|
+
*
|
|
520
|
+
* 逐个 try:一个库搬不动不该让其余的也不搬,更不该让插件起不来。
|
|
521
|
+
*/
|
|
522
|
+
memoryMigration = migrateAllMemory()
|
|
523
|
+
// 注册表可能刚被搬进来:让它按新位置重新读一遍。
|
|
524
|
+
void bases.repoint()
|
|
525
|
+
}
|
|
526
|
+
ensureAgentLayout(config.agentRoot)
|
|
527
|
+
runtime.close()
|
|
528
|
+
runtime = new KnowledgeRuntime(defaultBaseRoot(), config, runtimeOptions())
|
|
529
|
+
runtime.baseId = bases.defaultId
|
|
530
|
+
// 非默认库的 runtime 用新配置重建;默认 runtime 已由上一行替换。
|
|
531
|
+
bases.invalidate()
|
|
532
|
+
}
|
|
533
|
+
|
|
534
|
+
/**
|
|
535
|
+
* 初始化失败必须留下痕迹。
|
|
536
|
+
*
|
|
537
|
+
* 迁移会因为权限、占用、半个快照这类原因失败;而它抛出的异常如果没人接,就只是
|
|
538
|
+
* 一次没人看见的失败 —— 面板照常显示,数据却没搬。抓住它、记下来、让
|
|
539
|
+
* `/knowledge/layout` 报出去,界面于是能说「为什么不对」,而不是只显示症状。
|
|
540
|
+
*
|
|
541
|
+
* 注意这里**不吞配置错误**:schema 不合法的 config 在装载 entry 时就已经让插件
|
|
542
|
+
* 加载失败了(那是响亮的)。这里接住的只是初始化期的数据问题。
|
|
543
|
+
*/
|
|
544
|
+
try {
|
|
545
|
+
initialize()
|
|
546
|
+
settingsError = undefined
|
|
547
|
+
} catch (cause) {
|
|
548
|
+
settingsError = cause instanceof Error ? cause.message : String(cause)
|
|
549
|
+
}
|
|
550
|
+
|
|
551
|
+
// Conversion host (issue 0018). It shares this plugin's runtime so ingestion is
|
|
552
|
+
// an in-process `addSource` call, and it owns the `convert` config group, the
|
|
553
|
+
// `/convert/**` route and the `convert_*` tools.
|
|
554
|
+
// The kernel behind it stays an optional dependency, loaded lazily.
|
|
555
|
+
convertHost = applyConvert(ctx, {
|
|
556
|
+
runtime: () => runtime,
|
|
557
|
+
knowledgeRoot: () => runtime.rootDir,
|
|
558
|
+
/**
|
|
559
|
+
* 0029:转换内核的目录也从同一个根路径推出来 —— 交付物落
|
|
560
|
+
* `<agentRoot>/output/<日期>/`,工作区与任务状态落 `<agentRoot>/tmp/`。
|
|
561
|
+
* 转换因此**不再有自己的路径设置**:一个 Agent 只有一个根路径。
|
|
562
|
+
*/
|
|
563
|
+
agentRoot: () => config.agentRoot,
|
|
564
|
+
/**
|
|
565
|
+
* 交付物落在**工作项目**下:`<工作区>/output/<日期>/`,不是 Agent 根路径。
|
|
566
|
+
*
|
|
567
|
+
* 根路径下的四段(知识 / 记忆 / 意图 / 家当)是**这个 Agent 自己的东西**,跨项目
|
|
568
|
+
* 累积;交付物是**某个项目的产物**,换一个项目打开就不该再看见。两者判据不同,
|
|
569
|
+
* 所以落点也不同。
|
|
570
|
+
*
|
|
571
|
+
* 这里给的是宿主进程的工作目录:转换内核是宿主级单例,拿不到会话,也就拿不到
|
|
572
|
+
* 「这个会话的工作项目」。单项目部署下两者一致;多项目共用宿主时交付物会集中到
|
|
573
|
+
* 宿主启动目录 —— 见 `ConvertHostDeps.workspace` 的说明。
|
|
574
|
+
*/
|
|
575
|
+
workspace: () => process.cwd(),
|
|
576
|
+
// 0.1.7:转换的设置不再走命名空间,随本 entry 的 config 一起下来。
|
|
577
|
+
// 给函数而不是值:字段是 volatile 引用,解包一次就是快照(见 `convertGroup`)。
|
|
578
|
+
config: convertGroup,
|
|
579
|
+
// 0019: a delivery can target one knowledge base (the panel imports into the
|
|
580
|
+
// base the user selected). Unknown/absent id falls back to the default base
|
|
581
|
+
// instead of failing the ingest.
|
|
582
|
+
resolveTarget: (kbId?: string) => {
|
|
583
|
+
const target = kbId ? runtimeOf(kbId) : undefined
|
|
584
|
+
return target ? { runtime: target, knowledgeRoot: target.rootDir } : { runtime, knowledgeRoot: runtime.rootDir }
|
|
585
|
+
},
|
|
586
|
+
})
|
|
587
|
+
|
|
588
|
+
// 意图记录模块 (issue 0020). The 意图 view was already one of this plugin's
|
|
589
|
+
// five panel tabs, but its Host lived in a second mount unit; 0020 folds it in
|
|
590
|
+
// so the declared information architecture and the code layout agree again.
|
|
591
|
+
// It keeps the three `intent_*` tool names, the `/intent/**` read-only surface,
|
|
592
|
+
// and — critically — the `titaDataAgentIntent` Cordis service that `data-table`
|
|
593
|
+
// resolves by name.
|
|
594
|
+
// 0032/0033:意图的配置与留痕落进 `<agentRoot>/intent`(与知识、记忆平级)。
|
|
595
|
+
// 0.1.7:意图的设置不再走命名空间,随本 entry 的 config 一起下来。
|
|
596
|
+
intentHost = applyIntent(ctx, { intentRoot: () => intentRoot(config.agentRoot), config: intentGroup })
|
|
597
|
+
|
|
598
|
+
const writeTools = new Set(['add_source', 'write_knowledge_card', 'set_knowledge_status', 'rebuild_knowledge', 'record_feedback'])
|
|
599
|
+
ctx.on('tools/pre-execute', (exec, next): Promise<PreToolDecision> => {
|
|
600
|
+
if (writeTools.has(exec.name)) return Promise.resolve({ kind: 'ask', reason: '该操作会写入 Tita Knowledge Store,需要本轮用户审批。' })
|
|
601
|
+
return next()
|
|
602
|
+
})
|
|
603
|
+
ctx.tools.register(tool('recall_knowledge', 'Recall compiled knowledge: multi-route recall over wikiNotes (top_k, default from settings), resolve each hit to its wiki and 1-hop graph, BERT-rerank, then return the top_n context pack.', { query: { type: 'string', required: true }, scope: { type: 'string' }, limit: { type: 'integer' }, top_k: { type: 'integer' }, top_n: { type: 'integer' }, rerank: { type: 'boolean' }, source_types: { type: 'array' }, include_problems: { type: 'boolean' }, mode: { type: 'string', enum: ['fts', 'vector', 'graph', 'hybrid'] }, kb: { type: 'string' }, session_key: { type: 'string' }, task_id: { type: 'string' }, subtask_id: { type: 'string' }, parent_subtask_id: { type: 'string' }, run_id: { type: 'string' }, turn_id: { type: 'string' }, trace_id: { type: 'string' }, domain: { type: 'string' } }, async (args, exec) => withRuntime(args.kb, target => target.recall(args.query, args.top_k ?? args.limit, args.source_types ?? [], args.include_problems ?? false, args.mode as RecallMode | undefined, lineageOf(args, exec), { contextTopN: args.top_n, rerank: args.rerank }))))
|
|
604
|
+
ctx.tools.register(tool('check_knowledge_embeddings', 'Inspect the local embedding provider and binary/int8 vector index without loading the model.', { kb: { type: 'string' }, session_key: { type: 'string' } }, async args => withRuntime(args.kb, target => target.embeddingHealth())))
|
|
605
|
+
ctx.tools.register(tool('query_knowledge_graph', 'Query entities, neighbors, paths, lists and relation types in the compiled knowledge graph.', { action: { type: 'string', enum: ['entity', 'neighbors', 'path', 'list', 'types'], required: true }, entity_id: { type: 'string' }, id: { type: 'string' }, entity_type: { type: 'string' }, from_id: { type: 'string' }, to_id: { type: 'string' }, type: { type: 'string' }, status: { type: 'string' }, include_problem: { type: 'boolean' }, limit: { type: 'integer' }, depth: { type: 'integer' }, kb: { type: 'string' }, session_key: { type: 'string' }, task_id: { type: 'string' }, subtask_id: { type: 'string' }, parent_subtask_id: { type: 'string' }, run_id: { type: 'string' }, turn_id: { type: 'string' }, trace_id: { type: 'string' }, domain: { type: 'string' } }, async (args, exec) => withRuntime(args.kb, target => target.graphQuery(args.action, args.entity_id ?? args.id, args.entity_type ?? args.type, args.depth ?? 1, { fromId: args.from_id, toId: args.to_id, status: args.status, includeProblem: args.include_problem ?? false, limit: args.limit ?? 50 }, lineageOf(args, exec)))))
|
|
606
|
+
ctx.tools.register(tool('query_source_truth', 'Read SourceRaw (原始可追溯文档) with access audit. Returns the top max_chars characters (default and hard max 40000) shared across the matched sources. Each source carries its absolute file `path`, `returnedChars` and `totalChars`, and the envelope carries `truncated`. When truncated is true (or returnedChars < totalChars) the content is INCOMPLETE — read that `path` directly to get the rest rather than answering from a partial document.', { query: { type: 'string', required: true }, source_types: { type: 'array' }, limit: { type: 'integer' }, max_chars: { type: 'integer' }, kb: { type: 'string' }, session_key: { type: 'string' }, task_id: { type: 'string' }, subtask_id: { type: 'string' }, run_id: { type: 'string' }, turn_id: { type: 'string' }, trace_id: { type: 'string' }, domain: { type: 'string' } }, async (args, exec) => withRuntime(args.kb, target => target.sourceTruth(args.query, args.source_types ?? [], args.limit ?? 20, args.max_chars ?? 40000, lineageOf(args, exec)))))
|
|
607
|
+
/**
|
|
608
|
+
* 知识库 id 的**发现入口**。
|
|
609
|
+
*
|
|
610
|
+
* 在这之前,「有哪些库」对模型是一个答不出来的问题:`/knowledge/bases` 是给浏览器
|
|
611
|
+
* 面板读的 HTTP 路由,而 `withRuntime` 在 id 未知时的自愈建议正是让调用方「先列出
|
|
612
|
+
* /knowledge/bases」。于是非默认库只能靠猜,猜错得到一句它无法执行的建议。这个工具
|
|
613
|
+
* 补上那一跳,`withRuntime` 的建议也改成指向它。
|
|
614
|
+
*
|
|
615
|
+
* 返回的是**投影**而不是 `KnowledgeBase` 整体:`sourceTypes` 与 `wikiTypes` 两张
|
|
616
|
+
* 注册表各有几十条,而这里要回答的是「有哪些库、哪个是默认、各有多少东西」;注册表
|
|
617
|
+
* 本身由 `list_source_types` / `list_wiki_types` 按需读,不在这里重复下发。
|
|
618
|
+
*
|
|
619
|
+
* 也**不调 `refresh()`**:它要遍历整棵树重算统计并落盘,而这个工具是只读的发现入口,
|
|
620
|
+
* 读落盘缓存就够,代价是一次同步的 `list()`。
|
|
621
|
+
*/
|
|
622
|
+
ctx.tools.register(tool('list_knowledge_bases', 'List the knowledge bases on this deployment: id, name, description, which one is the default, and how much each holds. Read-only. Call this before passing a `kb` you have not seen this session, and never invent a base id.', {}, async () => success({
|
|
623
|
+
defaultBaseId: bases.defaultId,
|
|
624
|
+
bases: bases.list().map(base => ({
|
|
625
|
+
id: base.id,
|
|
626
|
+
name: base.name,
|
|
627
|
+
description: base.description,
|
|
628
|
+
isDefault: base.isDefault,
|
|
629
|
+
sourceCount: base.sourceCount,
|
|
630
|
+
cardCount: base.cardCount,
|
|
631
|
+
noteCount: base.noteCount,
|
|
632
|
+
buildVersion: base.buildVersion,
|
|
633
|
+
})),
|
|
634
|
+
})))
|
|
635
|
+
ctx.tools.register(tool('list_source_types', 'List the SourceRaw business classifications (the per-knowledge-base source_type registry) together with the built-in read-only classifier prompt. Call this before add_source whenever the classification is not obvious; the registry is editable per knowledge base, so custom ids such as test_unit may exist.', { kb: { type: 'string', description: '目标知识库 id;省略则用默认库' }, session_key: { type: 'string' } }, async args => withRuntime(args.kb, target => Promise.resolve(target.sourceTypeCatalog()))))
|
|
636
|
+
ctx.tools.register(tool('list_wiki_types', 'List the Wiki card types (the per-knowledge-base card schema) with their required sections, required fields, expected source types and template card. Call this before write_knowledge_card: entity_type must be one of these ids, and the required sections/fields are what makes the card usable.', { kb: { type: 'string', description: '目标知识库 id;省略则用默认库' }, session_key: { type: 'string' } }, async args => withRuntime(args.kb, target => target.wikiTypeCatalog())))
|
|
637
|
+
ctx.tools.register(tool('add_source', 'Add or update a SourceRaw document (原始可追溯文档). source_type is a per-knowledge-base business classification: call list_source_types first when unsure, and never guess silently. DSH requests one user approval before execution.', { source_type: { type: 'string', required: true, description: '来源业务分类 id(按业务场景,不是文件格式)。先调 list_source_types 拿注册表;拿不准就返回候选问用户,不要默认 document。' }, title: { type: 'string' }, content: { type: 'string', required: true }, external_id: { type: 'string' }, metadata: { type: 'json' }, source_id: { type: 'string' }, kb: { type: 'string' }, session_key: { type: 'string' }, task_id: { type: 'string' }, subtask_id: { type: 'string' }, run_id: { type: 'string' }, turn_id: { type: 'string' }, trace_id: { type: 'string' }, domain: { type: 'string' } }, async (args, exec) => withRuntime(args.kb, target => target.addSource({ sourceType: args.source_type, title: args.title, content: args.content, externalId: args.external_id, metadata: args.metadata, sourceId: args.source_id, lineage: lineageOf(args, exec) }))))
|
|
638
|
+
ctx.tools.register(tool('write_knowledge_card', 'Write a Wiki knowledge card with mandatory source_refs. New cards default to unreviewed. DSH requests one user approval before execution.', { entity_type: { type: 'string', required: true, description: 'Wiki 卡片类型 id:先按 list_wiki_types 的类型定义与「例如」判断,命中就直接用它;必填小节与必填字段见该类型的 schema' }, entity_id: { type: 'string', required: true }, title: { type: 'string', required: true }, content: { type: 'string', required: true }, source_refs: { type: 'array', required: true }, status: { type: 'string', enum: ['unreviewed', 'draft', 'reviewed', 'active', 'deprecated', 'problem'] }, summary: { type: 'string' }, metadata: { type: 'json' }, kb: { type: 'string' }, session_key: { type: 'string' }, task_id: { type: 'string' }, subtask_id: { type: 'string' }, run_id: { type: 'string' }, turn_id: { type: 'string' }, trace_id: { type: 'string' }, domain: { type: 'string' } }, async (args, exec) => withRuntime(args.kb, target => target.writeCard({ entityType: args.entity_type, entityId: args.entity_id, title: args.title, content: args.content, sourceRefs: args.source_refs, status: args.status, summary: args.summary, metadata: args.metadata, lineage: lineageOf(args, exec) }))))
|
|
639
|
+
ctx.tools.register(tool('set_knowledge_status', 'Change a Wiki card status. problem requires problem_note and recovery must pass through unreviewed. DSH requests one user approval before execution.', { entity_id: { type: 'string', required: true }, status: { type: 'string', enum: ['unreviewed', 'draft', 'reviewed', 'active', 'deprecated', 'problem'], required: true }, problem_note: { type: 'string' }, kb: { type: 'string' }, session_key: { type: 'string' }, task_id: { type: 'string' }, subtask_id: { type: 'string' }, run_id: { type: 'string' }, turn_id: { type: 'string' }, trace_id: { type: 'string' }, domain: { type: 'string' } }, async (args, exec) => withRuntime(args.kb, target => target.setStatus(args.entity_id, args.status as KnowledgeStatus, args.problem_note, lineageOf(args, exec)))))
|
|
640
|
+
ctx.tools.register(tool('rebuild_knowledge', 'Rebuild schema/wiki/compiled knowledge indexes. DSH requests one user approval before execution.', { only: { type: 'string', enum: ['all', 'schema', 'wiki', 'embeddings'] }, kb: { type: 'string' }, session_key: { type: 'string' }, task_id: { type: 'string' }, subtask_id: { type: 'string' }, run_id: { type: 'string' }, turn_id: { type: 'string' }, trace_id: { type: 'string' }, domain: { type: 'string' } }, async (args, exec) => withRuntime(args.kb, target => target.rebuild(args.only ?? 'all', lineageOf(args, exec)))))
|
|
641
|
+
ctx.tools.register(tool('record_feedback', 'Record bounded feedback for a knowledge result. DSH requests one user approval before execution.', { feedback_id: { type: 'string' }, unit_id: { type: 'string' }, rating: { type: 'integer', required: true }, comment: { type: 'string' }, kb: { type: 'string' }, session_key: { type: 'string' }, task_id: { type: 'string' }, subtask_id: { type: 'string' }, run_id: { type: 'string' }, turn_id: { type: 'string' }, trace_id: { type: 'string' }, domain: { type: 'string' } }, async (args, exec) => withRuntime(args.kb, target => target.recordFeedback({ feedbackId: args.feedback_id, unitId: args.unit_id, rating: args.rating, comment: args.comment, sessionKey: args.session_key, lineage: lineageOf(args, exec) }))))
|
|
642
|
+
|
|
643
|
+
ctx.inject(['webServer'], webCtx => {
|
|
644
|
+
const web = webCtx as unknown as Context & { webServer: WebServerLike }
|
|
645
|
+
const register = (route: { kind: 'exact' | 'prefix'; path: string; handler: (req: IncomingMessage, res: ServerResponse) => Promise<void> }) => web.webServer.register(route)
|
|
646
|
+
|
|
647
|
+
/**
|
|
648
|
+
* Resolve the knowledge base behind an optional `kb` query/body field.
|
|
649
|
+
* Older routes stay byte-compatible: an absent `kb` is the default base.
|
|
650
|
+
*/
|
|
651
|
+
const resolveBase = (kb: unknown): { runtime: KnowledgeRuntime; base: KnowledgeBase } | { failure: Envelope<Record<string, unknown>> } => {
|
|
652
|
+
const id = kbIdOf(kb)
|
|
653
|
+
const base = bases.get(id)
|
|
654
|
+
if (!base) return { failure: failure({}, 'BASE_NOT_FOUND', `知识库不存在: ${id}`, true, '先 GET /knowledge/bases 拿到可用 id;不传 kb 表示默认库。') }
|
|
655
|
+
const target = runtimeOf(id)
|
|
656
|
+
if (!target) return { failure: failure({}, 'BASE_NOT_FOUND', `知识库不存在: ${id}`, true) }
|
|
657
|
+
return { runtime: target, base }
|
|
658
|
+
}
|
|
659
|
+
|
|
660
|
+
/**
|
|
661
|
+
* 智能归因图谱的存储句柄,按 rootDir 缓存。
|
|
662
|
+
*
|
|
663
|
+
* 每个知识库有自己的 `05_diagnosis/`,和它的 wiki / 编译产物同源;缓存键用
|
|
664
|
+
* rootDir 而不是 baseId,因为独立 runtime(未挂到知识库列表上的)也要能读写。
|
|
665
|
+
*/
|
|
666
|
+
const diagnosisStores = new Map<string, DiagnosisStore>()
|
|
667
|
+
const storeOf = (target: KnowledgeRuntime): DiagnosisStore => {
|
|
668
|
+
const cached = diagnosisStores.get(target.rootDir)
|
|
669
|
+
if (cached) return cached
|
|
670
|
+
// 卡片要经运行时的 writeCard 落盘(它同时校验 schema 与「出处必须真实存在」),
|
|
671
|
+
// 所以 store 拿的是运行时本身,不只是 rootDir。
|
|
672
|
+
const created = new DiagnosisStore(target)
|
|
673
|
+
diagnosisStores.set(target.rootDir, created)
|
|
674
|
+
return created
|
|
675
|
+
}
|
|
676
|
+
|
|
677
|
+
const routes = [
|
|
678
|
+
{ kind: 'exact' as const, path: '/knowledge/stats', handler: async (req: IncomingMessage, res: ServerResponse) => {
|
|
679
|
+
const resolved = resolveBase(query(req).searchParams.get('kb'))
|
|
680
|
+
if ('failure' in resolved) return sendEnvelope(res, resolved.failure)
|
|
681
|
+
return send(res, 200, success(await resolved.runtime.stats()))
|
|
682
|
+
} },
|
|
683
|
+
{ kind: 'exact' as const, path: '/knowledge/search', handler: async (req: IncomingMessage, res: ServerResponse) => {
|
|
684
|
+
const url = query(req)
|
|
685
|
+
const resolved = resolveBase(url.searchParams.get('kb'))
|
|
686
|
+
if ('failure' in resolved) return sendEnvelope(res, resolved.failure)
|
|
687
|
+
const sourceTypes = (url.searchParams.get('source_types') ?? '').split(',').map(value => value.trim()).filter(Boolean)
|
|
688
|
+
const topK = url.searchParams.get('top_k') ?? url.searchParams.get('limit')
|
|
689
|
+
const topN = url.searchParams.get('top_n')
|
|
690
|
+
const rerankParam = url.searchParams.get('rerank')
|
|
691
|
+
// 响应里始终带 routes + backend,前端据此显示「关键词 / 向量 / 知识图谱」三路状态。
|
|
692
|
+
return send(res, 200, await resolved.runtime.recall(
|
|
693
|
+
url.searchParams.get('query') ?? '',
|
|
694
|
+
topK === null ? undefined : Number(topK),
|
|
695
|
+
sourceTypes,
|
|
696
|
+
url.searchParams.get('include_problems') === 'true',
|
|
697
|
+
(url.searchParams.get('mode') ?? undefined) as RecallMode | undefined,
|
|
698
|
+
lineageFromUrl(url),
|
|
699
|
+
{ ...(topN === null ? {} : { contextTopN: Number(topN) }), ...(rerankParam === null ? {} : { rerank: rerankParam !== 'false' && rerankParam !== '0' }) },
|
|
700
|
+
))
|
|
701
|
+
} },
|
|
702
|
+
{ kind: 'exact' as const, path: '/knowledge/embedding/health', handler: async (req: IncomingMessage, res: ServerResponse) => {
|
|
703
|
+
const resolved = resolveBase(query(req).searchParams.get('kb'))
|
|
704
|
+
if ('failure' in resolved) return sendEnvelope(res, resolved.failure)
|
|
705
|
+
return send(res, 200, await resolved.runtime.embeddingHealth())
|
|
706
|
+
} },
|
|
707
|
+
{ kind: 'exact' as const, path: '/knowledge/graph', handler: async (req: IncomingMessage, res: ServerResponse) => {
|
|
708
|
+
const url = query(req)
|
|
709
|
+
const resolved = resolveBase(url.searchParams.get('kb'))
|
|
710
|
+
if ('failure' in resolved) return sendEnvelope(res, resolved.failure)
|
|
711
|
+
send(res, 200, await resolved.runtime.graphQuery((url.searchParams.get('action') ?? 'list') as any, url.searchParams.get('entity_id') ?? undefined, url.searchParams.get('entity_type') ?? undefined, Number(url.searchParams.get('depth') ?? 1), { fromId: url.searchParams.get('from_id') ?? undefined, toId: url.searchParams.get('to_id') ?? undefined, status: url.searchParams.get('status') ?? undefined, includeProblem: url.searchParams.get('include_problem') === 'true', limit: Number(url.searchParams.get('limit') ?? 50) }, lineageFromUrl(url)))
|
|
712
|
+
} },
|
|
713
|
+
{ kind: 'exact' as const, path: '/knowledge/cards', handler: async (req: IncomingMessage, res: ServerResponse) => {
|
|
714
|
+
const resolved = resolveBase(query(req).searchParams.get('kb'))
|
|
715
|
+
if ('failure' in resolved) return sendEnvelope(res, resolved.failure)
|
|
716
|
+
return send(res, 200, success({ cards: await resolved.runtime.loadCards() }))
|
|
717
|
+
} },
|
|
718
|
+
{ kind: 'exact' as const, path: '/knowledge/rebuild', handler: async (req: IncomingMessage, res: ServerResponse) => {
|
|
719
|
+
try {
|
|
720
|
+
if (req.method !== 'POST') return send(res, 405, envelopeError('METHOD_NOT_ALLOWED', '该 knowledge endpoint 只接受 POST'))
|
|
721
|
+
const body = await readBody(req)
|
|
722
|
+
const resolved = resolveBase(body.kb ?? query(req).searchParams.get('kb'))
|
|
723
|
+
if ('failure' in resolved) return sendEnvelope(res, resolved.failure)
|
|
724
|
+
return send(res, 200, await resolved.runtime.rebuild(body.only ?? 'all', lineageOf(body)))
|
|
725
|
+
} catch (cause) {
|
|
726
|
+
return send(res, 400, envelopeError('REQUEST_INVALID', cause instanceof Error ? cause.message : String(cause)))
|
|
727
|
+
}
|
|
728
|
+
} },
|
|
729
|
+
{ kind: 'exact' as const, path: '/knowledge/sources', handler: async (req: IncomingMessage, res: ServerResponse) => {
|
|
730
|
+
try {
|
|
731
|
+
if (req.method === 'POST') {
|
|
732
|
+
const body = await readBody(req, 1_200_000)
|
|
733
|
+
const resolved = resolveBase(body.kb ?? query(req).searchParams.get('kb'))
|
|
734
|
+
if ('failure' in resolved) return sendEnvelope(res, resolved.failure)
|
|
735
|
+
return send(res, 200, await resolved.runtime.addSource({ sourceType: body.source_type ?? 'document', title: body.title, content: body.content, externalId: body.external_id, metadata: body.metadata, sourceId: body.source_id, lineage: lineageOf(body) }))
|
|
736
|
+
}
|
|
737
|
+
if (req.method !== 'GET') return send(res, 405, envelopeError('METHOD_NOT_ALLOWED', '该 knowledge endpoint 只接受 GET 或 POST'))
|
|
738
|
+
const url = query(req)
|
|
739
|
+
const resolved = resolveBase(url.searchParams.get('kb'))
|
|
740
|
+
if ('failure' in resolved) return sendEnvelope(res, resolved.failure)
|
|
741
|
+
return send(res, 200, await resolved.runtime.sourceTruth(url.searchParams.get('query') ?? '', [], 20, 40000, lineageFromUrl(url)))
|
|
742
|
+
} catch (cause) {
|
|
743
|
+
return send(res, 400, envelopeError('REQUEST_INVALID', cause instanceof Error ? cause.message : String(cause)))
|
|
744
|
+
}
|
|
745
|
+
} },
|
|
746
|
+
// ---------------------------------------------------------- 目录选择(0029)
|
|
747
|
+
/**
|
|
748
|
+
* 根路径的**只读**视图:四段布局、两次迁移的结论、家当解析结果、宿主提供的是哪种选择交互。
|
|
749
|
+
*
|
|
750
|
+
* 设置面板要显示「你这个根路径下会长出哪几段」,而那是服务端算出来的
|
|
751
|
+
* (`layout.ts` 是唯一真源),所以不能让前端自己拼字符串。
|
|
752
|
+
*/
|
|
753
|
+
{ kind: 'exact' as const, path: '/knowledge/layout', handler: async (req: IncomingMessage, res: ServerResponse) => {
|
|
754
|
+
const dirs = agentLayout(config.agentRoot)
|
|
755
|
+
const resources = runtime.resourcePaths()
|
|
756
|
+
// preset 的真源目录。它随插件分发,所以路径由插件自己算 —— 客户端不该拼这个
|
|
757
|
+
// 字符串,也不该猜包装在哪。`exists` 是给界面用的:路径不在时如实说「这个安装
|
|
758
|
+
// 里没有 preset 真源」,而不是给一个点了弹空目录的按钮。
|
|
759
|
+
const presetDir = presetSourceDir()
|
|
760
|
+
return send(res, 200, success({
|
|
761
|
+
...dirs,
|
|
762
|
+
presetDir,
|
|
763
|
+
presetDirExists: existsSync(presetDir),
|
|
764
|
+
migration,
|
|
765
|
+
resourceMigration,
|
|
766
|
+
memoryMigration,
|
|
767
|
+
// 三个「家当」路径**解析后**的值。设置面板用它显示「自动」到底指向哪里 ——
|
|
768
|
+
// 配置里是空串(= 自动),但用户要看到的是真实路径。
|
|
769
|
+
resources,
|
|
770
|
+
// 家当有没有真的落在 `resource/` 下(0029 的目标)。`resource` 是家当目录本身,
|
|
771
|
+
// 用它做前缀判断:不在里面的就是「还在旧位置」或「是一个裸的 HuggingFace 仓库名」。
|
|
772
|
+
resourcesOutsideResource: outsideResource(resources, dirs.resource),
|
|
773
|
+
// 设置分区没注册上时的原因。有它,「设置服务不可用」才不是一句空话。
|
|
774
|
+
...(settingsError ? { settingsError } : {}),
|
|
775
|
+
pickerKind: pickerKind(pickerOf(ctx)),
|
|
776
|
+
}))
|
|
777
|
+
} },
|
|
778
|
+
/**
|
|
779
|
+
* 开一次目录选择。
|
|
780
|
+
*
|
|
781
|
+
* native 形态下这个请求会一直挂到用户关掉系统对话框(关掉 = `path: null`)。
|
|
782
|
+
* 不用超时打断它:对话框是**用户在看**的,任何超时都会变成「我还没选完,
|
|
783
|
+
* 页面就说失败了」。
|
|
784
|
+
*/
|
|
785
|
+
{ kind: 'exact' as const, path: '/knowledge/pick-directory', handler: async (req: IncomingMessage, res: ServerResponse) => {
|
|
786
|
+
if (req.method !== 'POST') return send(res, 405, envelopeError('METHOD_NOT_ALLOWED', '该 endpoint 只接受 POST'))
|
|
787
|
+
const controller = new AbortController()
|
|
788
|
+
// 客户端断开(关页面/刷新)时终止对话框,别让一个没人要的对话框留在屏幕上。
|
|
789
|
+
req.on('close', () => controller.abort())
|
|
790
|
+
try {
|
|
791
|
+
return send(res, 200, success(await pickDirectory(pickerOf(ctx), controller.signal)))
|
|
792
|
+
} catch (cause) {
|
|
793
|
+
return send(res, 200, failure({ kind: null }, pickerCode(cause), cause instanceof Error ? cause.message : String(cause), true, '检查 DSH 是否挂载了目录选择后端(dsh-host-directory-picker-*)。'))
|
|
794
|
+
}
|
|
795
|
+
} },
|
|
796
|
+
/** 列一层目录。只在 browse 形态下有内容。 */
|
|
797
|
+
{ kind: 'exact' as const, path: '/knowledge/browse-directory', handler: async (req: IncomingMessage, res: ServerResponse) => {
|
|
798
|
+
const url = query(req)
|
|
799
|
+
const target = url.searchParams.get('path') ?? undefined
|
|
800
|
+
try {
|
|
801
|
+
return send(res, 200, success(await listDirectory(pickerOf(ctx), target, new AbortController().signal)))
|
|
802
|
+
} catch (cause) {
|
|
803
|
+
return send(res, 200, failure({ path: target ?? '' }, pickerCode(cause), cause instanceof Error ? cause.message : String(cause), true))
|
|
804
|
+
}
|
|
805
|
+
} },
|
|
806
|
+
/** 在某个目录下建一个子目录。只在 browse 形态下有内容。 */
|
|
807
|
+
{ kind: 'exact' as const, path: '/knowledge/create-directory', handler: async (req: IncomingMessage, res: ServerResponse) => {
|
|
808
|
+
if (req.method !== 'POST') return send(res, 405, envelopeError('METHOD_NOT_ALLOWED', '该 endpoint 只接受 POST'))
|
|
809
|
+
try {
|
|
810
|
+
const body = await readBody(req)
|
|
811
|
+
return send(res, 200, success({ path: await createDirectory(pickerOf(ctx), String(body.path ?? ''), String(body.name ?? '')) }))
|
|
812
|
+
} catch (cause) {
|
|
813
|
+
return send(res, 200, failure({ path: '' }, pickerCode(cause), cause instanceof Error ? cause.message : String(cause), true))
|
|
814
|
+
}
|
|
815
|
+
} },
|
|
816
|
+
// 智能归因:与 /knowledge/* 平级而不是挂在其下 —— 它读的是 05_diagnosis,
|
|
817
|
+
// 不是知识库本身的编译产物,路径上不该假装是 knowledge 的子资源。
|
|
818
|
+
{ kind: 'exact' as const, path: '/diagnosis/graph', handler: async (req: IncomingMessage, res: ServerResponse) => {
|
|
819
|
+
const resolved = resolveBase(query(req).searchParams.get('kb'))
|
|
820
|
+
if ('failure' in resolved) return sendEnvelope(res, resolved.failure)
|
|
821
|
+
const snapshot = await storeOf(resolved.runtime).snapshot()
|
|
822
|
+
// issues 一并返回:三层校验的结果要能直接显示在界面上,而不是只写进日志。
|
|
823
|
+
// source / migrated / problems 一并返回:界面必须能说清「现在读的是卡片还是旧 jsonl」,
|
|
824
|
+
// 以及迁移有没有生效 —— 这类状态靠猜就会变成一句含糊的「05_diagnosis」。
|
|
825
|
+
return send(res, 200, success({
|
|
826
|
+
graph: snapshot.graph, cases: snapshot.cases, issues: snapshot.issues,
|
|
827
|
+
seeded: snapshot.seeded, migrated: snapshot.migrated, source: snapshot.source, problems: snapshot.problems,
|
|
828
|
+
}))
|
|
829
|
+
} },
|
|
830
|
+
{ kind: 'exact' as const, path: '/diagnosis/cases', handler: async (req: IncomingMessage, res: ServerResponse) => {
|
|
831
|
+
const resolved = resolveBase(query(req).searchParams.get('kb'))
|
|
832
|
+
if ('failure' in resolved) return sendEnvelope(res, resolved.failure)
|
|
833
|
+
return send(res, 200, success({ cases: (await storeOf(resolved.runtime).snapshot()).cases }))
|
|
834
|
+
} },
|
|
835
|
+
{ kind: 'prefix' as const, path: '/knowledge', handler: async (req: IncomingMessage, res: ServerResponse) => {
|
|
836
|
+
const method = req.method ?? 'GET'
|
|
837
|
+
try {
|
|
838
|
+
const url = query(req)
|
|
839
|
+
const segments = pathSegments(url.pathname)
|
|
840
|
+
const rest = segments[0] === 'knowledge' ? segments.slice(1) : []
|
|
841
|
+
if (rest.length === 0) return send(res, 404, envelopeError('NOT_FOUND', 'knowledge endpoint 不存在'))
|
|
842
|
+
|
|
843
|
+
// 兼容旧路径:POST /knowledge/feedback、/knowledge/rebuild
|
|
844
|
+
if (rest.length === 1 && rest[0] === 'feedback') {
|
|
845
|
+
if (method !== 'POST') return send(res, 405, envelopeError('METHOD_NOT_ALLOWED', '该 knowledge endpoint 只接受 POST'))
|
|
846
|
+
const body = await readBody(req)
|
|
847
|
+
return sendEnvelope(res, await withRuntime(body.kb, target => target.recordFeedback(body)))
|
|
848
|
+
}
|
|
849
|
+
if (rest.length === 1 && rest[0] === 'rebuild') {
|
|
850
|
+
if (method !== 'POST') return send(res, 405, envelopeError('METHOD_NOT_ALLOWED', '该 knowledge endpoint 只接受 POST'))
|
|
851
|
+
const body = await readBody(req)
|
|
852
|
+
return sendEnvelope(res, await withRuntime(body.kb, target => target.rebuild(body.only ?? 'all', lineageOf(body))))
|
|
853
|
+
}
|
|
854
|
+
|
|
855
|
+
// ---------------------------------------------- /knowledge/bases
|
|
856
|
+
if (rest[0] === 'bases') {
|
|
857
|
+
if (rest.length === 1) {
|
|
858
|
+
if (method === 'GET') {
|
|
859
|
+
await bases.refresh()
|
|
860
|
+
return send(res, 200, success({ bases: bases.list(), defaultBaseId: bases.defaultId }))
|
|
861
|
+
}
|
|
862
|
+
if (method === 'POST') {
|
|
863
|
+
const body = await readBody(req)
|
|
864
|
+
try {
|
|
865
|
+
const base = await bases.create({ name: body.name, description: body.description, rootDir: body.rootDir })
|
|
866
|
+
await bases.refresh(base.id)
|
|
867
|
+
return send(res, 200, success({ base: bases.get(base.id) ?? base }))
|
|
868
|
+
} catch (cause) {
|
|
869
|
+
const code = cause instanceof Error ? cause.message : String(cause)
|
|
870
|
+
if (code === 'NAME_REQUIRED') return sendEnvelope(res, failure({}, 'NAME_REQUIRED', 'name 不能为空', false))
|
|
871
|
+
return send(res, 400, envelopeError('REQUEST_INVALID', code))
|
|
872
|
+
}
|
|
873
|
+
}
|
|
874
|
+
return send(res, 405, envelopeError('METHOD_NOT_ALLOWED', '该 knowledge endpoint 支持 GET 或 POST'))
|
|
875
|
+
}
|
|
876
|
+
const baseId = rest[1]!
|
|
877
|
+
if (rest.length === 2) {
|
|
878
|
+
if (method === 'PATCH') {
|
|
879
|
+
const body = await readBody(req)
|
|
880
|
+
const updated = await bases.update(baseId, { name: body.name, description: body.description, sourceTypes: body.sourceTypes as SourceTypeDef[] | undefined, wikiTypes: body.wikiTypes as WikiTypeDef[] | undefined, wikiSchemaMode: body.wikiSchemaMode as WikiSchemaMode | undefined, resetWikiTypes: body.resetWikiTypes === true })
|
|
881
|
+
if (!updated.ok) return sendEnvelope(res, updated)
|
|
882
|
+
return send(res, 200, success({ base: updated.data }, updated.warnings))
|
|
883
|
+
}
|
|
884
|
+
if (method === 'DELETE') {
|
|
885
|
+
const removed = await bases.remove(baseId)
|
|
886
|
+
if (!removed.ok) return sendEnvelope(res, removed)
|
|
887
|
+
return send(res, 200, success({ deleted: true as const }))
|
|
888
|
+
}
|
|
889
|
+
return send(res, 405, envelopeError('METHOD_NOT_ALLOWED', '该 knowledge endpoint 支持 PATCH 或 DELETE'))
|
|
890
|
+
}
|
|
891
|
+
if (rest.length === 4 && rest[2] === 'source-types') {
|
|
892
|
+
if (method !== 'DELETE') return send(res, 405, envelopeError('METHOD_NOT_ALLOWED', '该 knowledge endpoint 只接受 DELETE'))
|
|
893
|
+
const removed = await bases.removeSourceType(baseId, rest[3]!)
|
|
894
|
+
if (!removed.ok) return sendEnvelope(res, removed)
|
|
895
|
+
return send(res, 200, success({ deleted: true as const }))
|
|
896
|
+
}
|
|
897
|
+
return send(res, 404, envelopeError('NOT_FOUND', 'knowledge endpoint 不存在'))
|
|
898
|
+
}
|
|
899
|
+
|
|
900
|
+
// ----------------------------------------- /knowledge/source-types
|
|
901
|
+
// 来源业务分类注册表(0013)。读接口给设置面板和 Agent 共用:返回注册表
|
|
902
|
+
// 加上那段**内置只读**的判定提示词;写接口只动当前知识库的注册表。
|
|
903
|
+
if (rest[0] === 'source-types') {
|
|
904
|
+
const kbParam = url.searchParams.get('kb')
|
|
905
|
+
if (rest.length === 1) {
|
|
906
|
+
if (method === 'GET') {
|
|
907
|
+
const resolved = resolveBase(kbParam)
|
|
908
|
+
if ('failure' in resolved) return sendEnvelope(res, resolved.failure)
|
|
909
|
+
return send(res, 200, resolved.runtime.sourceTypeCatalog())
|
|
910
|
+
}
|
|
911
|
+
if (method === 'POST') {
|
|
912
|
+
const body = await readBody(req)
|
|
913
|
+
const id = kbIdOf(body.kb ?? kbParam)
|
|
914
|
+
const created = await bases.addSourceType(id, body as SourceTypeDraft)
|
|
915
|
+
if (!created.ok) return sendEnvelope(res, created)
|
|
916
|
+
return send(res, 200, success({ type: created.data }))
|
|
917
|
+
}
|
|
918
|
+
return send(res, 405, envelopeError('METHOD_NOT_ALLOWED', '该 knowledge endpoint 支持 GET 或 POST'))
|
|
919
|
+
}
|
|
920
|
+
const typeId = rest[1]!
|
|
921
|
+
if (method === 'PATCH') {
|
|
922
|
+
const body = await readBody(req)
|
|
923
|
+
const id = kbIdOf(body.kb ?? kbParam)
|
|
924
|
+
const updated = await bases.updateSourceType(id, typeId, body as SourceTypeDraft)
|
|
925
|
+
if (!updated.ok) return sendEnvelope(res, updated)
|
|
926
|
+
return send(res, 200, success({ type: updated.data }))
|
|
927
|
+
}
|
|
928
|
+
if (method === 'DELETE') {
|
|
929
|
+
const id = kbIdOf(kbParam)
|
|
930
|
+
const removed = await bases.removeSourceType(id, typeId)
|
|
931
|
+
if (!removed.ok) return sendEnvelope(res, removed)
|
|
932
|
+
return send(res, 200, success({ deleted: true as const }))
|
|
933
|
+
}
|
|
934
|
+
return send(res, 405, envelopeError('METHOD_NOT_ALLOWED', '该 knowledge endpoint 支持 PATCH 或 DELETE'))
|
|
935
|
+
}
|
|
936
|
+
|
|
937
|
+
// -------------------------------------------- /knowledge/wiki-types
|
|
938
|
+
// Wiki 卡片类型注册表(0024)。读接口给设置面板和 Agent 共用:返回类型定义、
|
|
939
|
+
// 模板卡、内置只读判定规则,以及现有卡片的三层校验结果。
|
|
940
|
+
if (rest[0] === 'wiki-types') {
|
|
941
|
+
const kbParam = url.searchParams.get('kb')
|
|
942
|
+
if (rest.length === 1) {
|
|
943
|
+
if (method === 'GET') {
|
|
944
|
+
const resolved = resolveBase(kbParam)
|
|
945
|
+
if ('failure' in resolved) return sendEnvelope(res, resolved.failure)
|
|
946
|
+
return send(res, 200, await resolved.runtime.wikiTypeCatalog())
|
|
947
|
+
}
|
|
948
|
+
if (method === 'POST') {
|
|
949
|
+
const body = await readBody(req)
|
|
950
|
+
const id = kbIdOf(body.kb ?? kbParam)
|
|
951
|
+
const created = await bases.addWikiType(id, body)
|
|
952
|
+
if (!created.ok) return sendEnvelope(res, created)
|
|
953
|
+
return send(res, 200, success({ type: created.data }))
|
|
954
|
+
}
|
|
955
|
+
return send(res, 405, envelopeError('METHOD_NOT_ALLOWED', '该 knowledge endpoint 支持 GET 或 POST'))
|
|
956
|
+
}
|
|
957
|
+
const typeId = rest[1]!
|
|
958
|
+
if (method === 'PATCH') {
|
|
959
|
+
const body = await readBody(req)
|
|
960
|
+
const id = kbIdOf(body.kb ?? kbParam)
|
|
961
|
+
const updated = await bases.updateWikiType(id, typeId, body)
|
|
962
|
+
if (!updated.ok) return sendEnvelope(res, updated)
|
|
963
|
+
return send(res, 200, success({ type: updated.data }))
|
|
964
|
+
}
|
|
965
|
+
if (method === 'DELETE') {
|
|
966
|
+
const id = kbIdOf(kbParam)
|
|
967
|
+
const removed = await bases.removeWikiType(id, typeId)
|
|
968
|
+
if (!removed.ok) return sendEnvelope(res, removed)
|
|
969
|
+
return send(res, 200, success({ deleted: true as const }))
|
|
970
|
+
}
|
|
971
|
+
return send(res, 405, envelopeError('METHOD_NOT_ALLOWED', '该 knowledge endpoint 支持 PATCH 或 DELETE'))
|
|
972
|
+
}
|
|
973
|
+
|
|
974
|
+
// ----------------------------------------------- /knowledge/cards
|
|
975
|
+
// wiki 卡片详情与版本(0025)。列表走上面的 exact 路由;这里只处理
|
|
976
|
+
// `/cards/:entityId` 及其 `/versions`、`/revert` 子路径 —— 卡片和
|
|
977
|
+
// SourceRaw 一样有历史,用户点开一张卡片要能看到它被改过几次。
|
|
978
|
+
if (rest[0] === 'cards' && rest.length >= 2) {
|
|
979
|
+
const kbParam = url.searchParams.get('kb')
|
|
980
|
+
const resolved = resolveBase(kbParam)
|
|
981
|
+
if ('failure' in resolved) return sendEnvelope(res, resolved.failure)
|
|
982
|
+
const entityId = decodeURIComponent(rest[1]!)
|
|
983
|
+
const entityType = (url.searchParams.get('entity_type') ?? undefined) as SchemaType | undefined
|
|
984
|
+
if (rest.length === 2) {
|
|
985
|
+
if (method !== 'GET') return send(res, 405, envelopeError('METHOD_NOT_ALLOWED', '该 knowledge endpoint 只接受 GET'))
|
|
986
|
+
const card = await resolved.runtime.readCard(entityId, entityType)
|
|
987
|
+
if (!card) return sendEnvelope(res, failure({}, 'CARD_NOT_FOUND', `知识卡片不存在: ${entityId}`, true))
|
|
988
|
+
return send(res, 200, success({ card }))
|
|
989
|
+
}
|
|
990
|
+
if (rest.length === 3 && rest[2] === 'versions') {
|
|
991
|
+
if (method !== 'GET') return send(res, 405, envelopeError('METHOD_NOT_ALLOWED', '该 knowledge endpoint 只接受 GET'))
|
|
992
|
+
const versions = await resolved.runtime.listCardVersions(entityId, entityType)
|
|
993
|
+
if (versions.length === 0) return sendEnvelope(res, failure({}, 'CARD_NOT_FOUND', `知识卡片不存在: ${entityId}`, true))
|
|
994
|
+
return send(res, 200, success({ versions }))
|
|
995
|
+
}
|
|
996
|
+
if (rest.length === 3 && rest[2] === 'revert') {
|
|
997
|
+
if (method !== 'POST') return send(res, 405, envelopeError('METHOD_NOT_ALLOWED', '该 knowledge endpoint 只接受 POST'))
|
|
998
|
+
const body = await readBody(req)
|
|
999
|
+
const reverted = await resolved.runtime.revertCard(entityId, String(body.versionId ?? ''), entityType)
|
|
1000
|
+
if (!reverted.ok) return sendEnvelope(res, reverted)
|
|
1001
|
+
return send(res, 200, success({ card: reverted.data }))
|
|
1002
|
+
}
|
|
1003
|
+
return send(res, 404, envelopeError('NOT_FOUND', 'knowledge endpoint 不存在'))
|
|
1004
|
+
}
|
|
1005
|
+
|
|
1006
|
+
// ------------------------------------------- /knowledge/documents
|
|
1007
|
+
if (rest[0] === 'documents') {
|
|
1008
|
+
const kbParam = url.searchParams.get('kb')
|
|
1009
|
+
if (rest.length === 1) {
|
|
1010
|
+
if (method !== 'GET') return send(res, 405, envelopeError('METHOD_NOT_ALLOWED', '该 knowledge endpoint 只接受 GET'))
|
|
1011
|
+
const resolved = resolveBase(kbParam)
|
|
1012
|
+
if ('failure' in resolved) return sendEnvelope(res, resolved.failure)
|
|
1013
|
+
let documents = await resolved.runtime.listDocuments()
|
|
1014
|
+
const sourceType = url.searchParams.get('source_type')
|
|
1015
|
+
if (sourceType) documents = documents.filter(item => item.sourceType === sourceType)
|
|
1016
|
+
const search = (url.searchParams.get('query') ?? '').trim().toLocaleLowerCase()
|
|
1017
|
+
if (search) documents = documents.filter(item => `${item.docId}\n${item.title}\n${item.externalId}`.toLocaleLowerCase().includes(search))
|
|
1018
|
+
const total = documents.length
|
|
1019
|
+
const offset = clampInt(url.searchParams.get('offset'), 0, 0, Number.MAX_SAFE_INTEGER)
|
|
1020
|
+
const limit = clampInt(url.searchParams.get('limit'), 50, 1, 500)
|
|
1021
|
+
return send(res, 200, success({ documents: documents.slice(offset, offset + limit), total }))
|
|
1022
|
+
}
|
|
1023
|
+
const docId = rest[1]!
|
|
1024
|
+
const resolved = resolveBase(kbParam)
|
|
1025
|
+
if ('failure' in resolved) return sendEnvelope(res, resolved.failure)
|
|
1026
|
+
if (rest.length === 2) {
|
|
1027
|
+
if (method === 'GET') {
|
|
1028
|
+
const document = await resolved.runtime.readDocument(docId)
|
|
1029
|
+
if (!document) return sendEnvelope(res, failure({}, 'DOCUMENT_NOT_FOUND', `文档不存在: ${docId}`, true))
|
|
1030
|
+
return send(res, 200, success({ document }))
|
|
1031
|
+
}
|
|
1032
|
+
if (method === 'DELETE') {
|
|
1033
|
+
const deleted = await resolved.runtime.deleteDocument(docId)
|
|
1034
|
+
if (!deleted.ok) return sendEnvelope(res, deleted)
|
|
1035
|
+
return send(res, 200, success({ deleted: true as const }))
|
|
1036
|
+
}
|
|
1037
|
+
return send(res, 405, envelopeError('METHOD_NOT_ALLOWED', '该 knowledge endpoint 支持 GET 或 DELETE'))
|
|
1038
|
+
}
|
|
1039
|
+
if (rest.length === 3 && rest[2] === 'versions') {
|
|
1040
|
+
if (method !== 'GET') return send(res, 405, envelopeError('METHOD_NOT_ALLOWED', '该 knowledge endpoint 只接受 GET'))
|
|
1041
|
+
const versions = await resolved.runtime.listVersions(docId)
|
|
1042
|
+
if (versions.length === 0) return sendEnvelope(res, failure({}, 'DOCUMENT_NOT_FOUND', `文档不存在: ${docId}`, true))
|
|
1043
|
+
return send(res, 200, success({ versions }))
|
|
1044
|
+
}
|
|
1045
|
+
if (rest.length === 3 && rest[2] === 'revert') {
|
|
1046
|
+
if (method !== 'POST') return send(res, 405, envelopeError('METHOD_NOT_ALLOWED', '该 knowledge endpoint 只接受 POST'))
|
|
1047
|
+
const body = await readBody(req)
|
|
1048
|
+
const reverted = await resolved.runtime.revertDocument(docId, String(body.versionId ?? ''))
|
|
1049
|
+
if (!reverted.ok) return sendEnvelope(res, reverted)
|
|
1050
|
+
return send(res, 200, success({ document: reverted.data }))
|
|
1051
|
+
}
|
|
1052
|
+
return send(res, 404, envelopeError('NOT_FOUND', 'knowledge endpoint 不存在'))
|
|
1053
|
+
}
|
|
1054
|
+
|
|
1055
|
+
// ---------------------------------------------- /knowledge/assets
|
|
1056
|
+
if (rest[0] === 'assets') {
|
|
1057
|
+
if (method !== 'GET') return send(res, 405, envelopeError('METHOD_NOT_ALLOWED', '该 knowledge endpoint 只接受 GET'))
|
|
1058
|
+
if (rest.length < 3) return send(res, 404, envelopeError('NOT_FOUND', 'asset 路径不完整'))
|
|
1059
|
+
const base = bases.get(rest[1]!)
|
|
1060
|
+
if (!base) return sendEnvelope(res, failure({}, 'BASE_NOT_FOUND', `知识库不存在: ${rest[1]}`, true))
|
|
1061
|
+
const rootDir = resolve(base.rootDir)
|
|
1062
|
+
const absolute = resolve(rootDir, ...rest.slice(2))
|
|
1063
|
+
const withinRoot = absolute === rootDir || absolute.startsWith(rootDir.endsWith(sep) ? rootDir : `${rootDir}${sep}`)
|
|
1064
|
+
if (!withinRoot) return sendEnvelope(res, failure({}, 'ASSET_NOT_FOUND', 'asset 路径越界', false))
|
|
1065
|
+
try {
|
|
1066
|
+
const details = await stat(absolute)
|
|
1067
|
+
if (!details.isFile()) throw new Error('NOT_A_FILE')
|
|
1068
|
+
res.statusCode = 200
|
|
1069
|
+
res.setHeader('content-type', ASSET_CONTENT_TYPES[extname(absolute).toLowerCase()] ?? 'application/octet-stream')
|
|
1070
|
+
res.setHeader('content-length', String(details.size))
|
|
1071
|
+
res.setHeader('cache-control', 'private, max-age=60')
|
|
1072
|
+
await new Promise<void>(resolvePromise => {
|
|
1073
|
+
const stream = createReadStream(absolute)
|
|
1074
|
+
stream.on('error', () => { res.destroy(); resolvePromise() })
|
|
1075
|
+
stream.on('close', () => resolvePromise())
|
|
1076
|
+
stream.pipe(res)
|
|
1077
|
+
})
|
|
1078
|
+
return
|
|
1079
|
+
} catch {
|
|
1080
|
+
return sendEnvelope(res, failure({}, 'ASSET_NOT_FOUND', `asset 不存在: ${rest.slice(2).join('/')}`, true))
|
|
1081
|
+
}
|
|
1082
|
+
}
|
|
1083
|
+
|
|
1084
|
+
return send(res, 404, envelopeError('NOT_FOUND', 'knowledge endpoint 不存在'))
|
|
1085
|
+
} catch (cause) { return send(res, 400, envelopeError('REQUEST_INVALID', cause instanceof Error ? cause.message : String(cause))) }
|
|
1086
|
+
} },
|
|
1087
|
+
]
|
|
1088
|
+
const disposers = routes.map(register)
|
|
1089
|
+
return () => { runtime.close(); for (const dispose of disposers) void dispose() }
|
|
1090
|
+
})
|
|
1091
|
+
}
|