@p-dsh-market/conversation-knowledge-map 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +22 -0
- package/cordis.patch.yml +4 -0
- package/lib/client.js +575 -0
- package/lib/generation-orchestrator.js +401 -0
- package/lib/index.js +286 -0
- package/lib/knowledge-graph-schema.js +89 -0
- package/lib/mind-map-schema.js +94 -0
- package/lib/protocol.js +104 -0
- package/lib/session-source.js +188 -0
- package/lib/workspace-storage.js +224 -0
- package/package.json +74 -0
- package/skills/conversation-knowledge-map/SKILL.md +13 -0
package/lib/index.js
ADDED
|
@@ -0,0 +1,286 @@
|
|
|
1
|
+
import { readFileSync } from 'node:fs'
|
|
2
|
+
import { fileURLToPath } from 'node:url'
|
|
3
|
+
import { randomUUID } from 'node:crypto'
|
|
4
|
+
|
|
5
|
+
import { KnowledgeGenerationOrchestrator } from './generation-orchestrator.js'
|
|
6
|
+
import { errorMessage, jsonResponse, methodOf, parseUrl, readJson, safeId, sseWrite, shortText } from './protocol.js'
|
|
7
|
+
import { listWorkspaceSessions, normalizeWorkspacePath, resolveAnchorSession } from './session-source.js'
|
|
8
|
+
import { WorkspaceRevisionError, WorkspaceStorage } from './workspace-storage.js'
|
|
9
|
+
|
|
10
|
+
const BASE_PATH = '/conversation-knowledge-map'
|
|
11
|
+
const PLUGIN_ID = '@p-dsh-market/conversation-knowledge-map'
|
|
12
|
+
const SKILL_PATH = fileURLToPath(new URL('../skills/conversation-knowledge-map/SKILL.md', import.meta.url))
|
|
13
|
+
const SKILL_DIR_PATH = fileURLToPath(new URL('../skills/conversation-knowledge-map/', import.meta.url))
|
|
14
|
+
const CONFIRMATION_TTL = 5 * 60 * 1000
|
|
15
|
+
|
|
16
|
+
function getService(ctx, name) {
|
|
17
|
+
return ctx?.get?.(name) ?? ctx?.[name]
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function registerEffect(ctx, service, value) {
|
|
21
|
+
if (!service?.register) return
|
|
22
|
+
const register = () => service.register(value)
|
|
23
|
+
if (typeof ctx?.effect === 'function') ctx.effect(register)
|
|
24
|
+
else register()
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function writeError(res, error, status = 400) {
|
|
28
|
+
return jsonResponse(res, status, { ok: false, error: errorMessage(error) })
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function parsePath(req) {
|
|
32
|
+
const pathname = parseUrl(req).pathname
|
|
33
|
+
if (pathname === BASE_PATH || pathname === `${BASE_PATH}/`) return []
|
|
34
|
+
if (!pathname.startsWith(`${BASE_PATH}/`)) return null
|
|
35
|
+
return pathname.slice(`${BASE_PATH}/`.length).split('/').filter(Boolean).map((item) => decodeURIComponent(item))
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function normalizeOutputMode(value) {
|
|
39
|
+
const mode = String(value || 'both').trim()
|
|
40
|
+
if (!['mind-map', 'knowledge-graph', 'both'].includes(mode)) throw new Error('生成内容必须是 mind-map、knowledge-graph 或 both。')
|
|
41
|
+
return mode
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function normalizeGenerationInput(body = {}) {
|
|
45
|
+
const selectedSessionIds = [...new Set((Array.isArray(body.selectedSessionIds) ? body.selectedSessionIds : []).map((id) => String(id || '').trim()).filter(Boolean))]
|
|
46
|
+
if (!selectedSessionIds.length) throw new Error('至少选择一个对话。')
|
|
47
|
+
const prompt = String(body.prompt || '').trim()
|
|
48
|
+
if (prompt.length > 4000) throw new Error('额外 Prompt 不能超过 4000 个字符。')
|
|
49
|
+
const expectedRevision = Number(body.expectedRevision)
|
|
50
|
+
return {
|
|
51
|
+
anchorSessionId: safeId(body.anchorSessionId, '锚点 Session ID'),
|
|
52
|
+
selectedSessionIds,
|
|
53
|
+
outputMode: normalizeOutputMode(body.outputMode),
|
|
54
|
+
prompt,
|
|
55
|
+
strict: body.strict !== false,
|
|
56
|
+
includeSubagents: body.includeSubagents === true,
|
|
57
|
+
expectedRevision: Number.isInteger(expectedRevision) && expectedRevision >= 0 ? expectedRevision : 0
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function canonicalPayload(value) {
|
|
62
|
+
return JSON.stringify({
|
|
63
|
+
anchorSessionId: value.anchorSessionId,
|
|
64
|
+
cwd: normalizeWorkspacePath(value.cwd),
|
|
65
|
+
selectedSessionIds: [...value.selectedSessionIds].map(String).sort(),
|
|
66
|
+
outputMode: value.outputMode,
|
|
67
|
+
prompt: value.prompt,
|
|
68
|
+
strict: value.strict === true,
|
|
69
|
+
includeSubagents: value.includeSubagents === true,
|
|
70
|
+
expectedRevision: Number(value.expectedRevision || 0)
|
|
71
|
+
})
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function statusForError(error) {
|
|
75
|
+
if (error instanceof WorkspaceRevisionError) return 409
|
|
76
|
+
if (/不存在|无效|不能为空|至少|不属于|没有|必须|不能|超出|缺少|已过期|确认/.test(errorMessage(error))) return 400
|
|
77
|
+
return 409
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export function createHost(options = {}) {
|
|
81
|
+
const host = {
|
|
82
|
+
inject: ['agentDefaultModel', 'agents', 'sessionQuery', 'sessions', 'skills', 'webServer'],
|
|
83
|
+
|
|
84
|
+
apply(ctx) {
|
|
85
|
+
const sessionQuery = options.sessionQuery || getService(ctx, 'sessionQuery')
|
|
86
|
+
const sessions = options.sessions || getService(ctx, 'sessions')
|
|
87
|
+
const agents = options.agents || getService(ctx, 'agents')
|
|
88
|
+
const agentDefaultModel = options.agentDefaultModel || getService(ctx, 'agentDefaultModel')
|
|
89
|
+
const skills = options.skills || getService(ctx, 'skills')
|
|
90
|
+
const webServer = options.webServer || getService(ctx, 'webServer')
|
|
91
|
+
const storage = options.storage || new WorkspaceStorage(options.storageOptions)
|
|
92
|
+
const orchestrator = options.orchestrator || new KnowledgeGenerationOrchestrator({
|
|
93
|
+
sessionQuery,
|
|
94
|
+
sessions,
|
|
95
|
+
agents,
|
|
96
|
+
agentDefaultModel,
|
|
97
|
+
storage,
|
|
98
|
+
modelRunner: options.modelRunner
|
|
99
|
+
})
|
|
100
|
+
const confirmations = new Map()
|
|
101
|
+
const sseClients = new Set()
|
|
102
|
+
|
|
103
|
+
async function contextFor(sessionId) {
|
|
104
|
+
const anchor = await resolveAnchorSession({ sessionQuery, sessions }, sessionId)
|
|
105
|
+
const header = anchor?.header || anchor?.session || null
|
|
106
|
+
if (!header?.id) return { ready: false, state: 'no-session', sessionId: '', cwd: '' }
|
|
107
|
+
const cwd = normalizeWorkspacePath(header.cwd)
|
|
108
|
+
if (!cwd) return { ready: false, state: 'session-without-cwd', sessionId: String(header.id), cwd: '' }
|
|
109
|
+
return { ready: true, state: 'ready', sessionId: String(header.id), cwd, origin: String(header.origin || '') }
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
async function sessionsFor(body) {
|
|
113
|
+
const context = await contextFor(body.anchorSessionId)
|
|
114
|
+
if (!context.ready) throw new Error('请先打开一个有明确工作路径的已有对话。')
|
|
115
|
+
return { context, sessions: await listWorkspaceSessions({ sessionQuery, sessions }, context.cwd, context.sessionId, body.includeSubagents === true) }
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
async function confirmGeneration(body) {
|
|
119
|
+
const input = normalizeGenerationInput(body)
|
|
120
|
+
const { context, sessions: available } = await sessionsFor(input)
|
|
121
|
+
const allowed = new Set(available.map((item) => item.id))
|
|
122
|
+
for (const id of input.selectedSessionIds) if (!allowed.has(id)) throw new Error(`所选对话不属于当前工作路径或已不可用:${id}`)
|
|
123
|
+
const state = await storage.readState(context.cwd)
|
|
124
|
+
if (input.expectedRevision !== state.revision) throw new WorkspaceRevisionError(input.expectedRevision, state.revision)
|
|
125
|
+
const payload = { ...input, anchorSessionId: context.sessionId, cwd: context.cwd, expectedRevision: state.revision }
|
|
126
|
+
const token = randomUUID()
|
|
127
|
+
confirmations.set(token, { payload, expiresAt: Date.now() + CONFIRMATION_TTL, used: false })
|
|
128
|
+
return {
|
|
129
|
+
token,
|
|
130
|
+
expiresAt: Date.now() + CONFIRMATION_TTL,
|
|
131
|
+
revision: state.revision,
|
|
132
|
+
context,
|
|
133
|
+
selectedSessions: available.filter((item) => input.selectedSessionIds.includes(item.id)),
|
|
134
|
+
outputMode: input.outputMode,
|
|
135
|
+
strict: input.strict,
|
|
136
|
+
promptSummary: shortText(input.prompt, 300),
|
|
137
|
+
overwrite: state.exists
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
function consumeConfirmation(token, body) {
|
|
142
|
+
const value = confirmations.get(String(token || ''))
|
|
143
|
+
if (!value || value.used || value.expiresAt < Date.now()) throw new Error('生成确认已过期,请返回配置重新确认。')
|
|
144
|
+
const supplied = normalizeGenerationInput({ ...body, anchorSessionId: body.anchorSessionId || value.payload.anchorSessionId, selectedSessionIds: body.selectedSessionIds || value.payload.selectedSessionIds, outputMode: body.outputMode || value.payload.outputMode, prompt: body.prompt ?? value.payload.prompt, strict: body.strict ?? value.payload.strict, includeSubagents: body.includeSubagents ?? value.payload.includeSubagents, expectedRevision: body.expectedRevision ?? value.payload.expectedRevision })
|
|
145
|
+
const expected = canonicalPayload(value.payload)
|
|
146
|
+
const actual = canonicalPayload({ ...supplied, cwd: value.payload.cwd })
|
|
147
|
+
if (expected !== actual) throw new Error('确认内容已变化,请返回配置重新确认。')
|
|
148
|
+
value.used = true
|
|
149
|
+
return { ...value.payload }
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
async function handleGeneration(req, res) {
|
|
153
|
+
const path = parsePath(req)
|
|
154
|
+
const method = methodOf(req)
|
|
155
|
+
if (!path) return jsonResponse(res, 404, { ok: false, error: 'Not found' })
|
|
156
|
+
try {
|
|
157
|
+
if (path.length === 0 && method === 'GET') {
|
|
158
|
+
return jsonResponse(res, 200, { ok: true, plugin: PLUGIN_ID })
|
|
159
|
+
}
|
|
160
|
+
if (path[0] === 'health' && method === 'GET') {
|
|
161
|
+
return jsonResponse(res, 200, { ok: true, agentsAvailable: Boolean(agents?.create), sessionQueryAvailable: Boolean(sessionQuery), activeGenerations: sseClients.size })
|
|
162
|
+
}
|
|
163
|
+
if (path[0] === 'context' && method === 'GET') {
|
|
164
|
+
const sessionId = parseUrl(req).searchParams.get('sessionId') || ''
|
|
165
|
+
return jsonResponse(res, 200, { ok: true, context: await contextFor(sessionId) })
|
|
166
|
+
}
|
|
167
|
+
if (path[0] === 'sessions' && method === 'GET') {
|
|
168
|
+
const anchorSessionId = parseUrl(req).searchParams.get('anchorSessionId') || ''
|
|
169
|
+
const includeSubagents = parseUrl(req).searchParams.get('includeSubagents') === 'true'
|
|
170
|
+
const { context, sessions: available } = await sessionsFor({ anchorSessionId, includeSubagents })
|
|
171
|
+
return jsonResponse(res, 200, { ok: true, context, sessions: available })
|
|
172
|
+
}
|
|
173
|
+
if (path[0] === 'state' && method === 'GET') {
|
|
174
|
+
const context = await contextFor(parseUrl(req).searchParams.get('anchorSessionId') || '')
|
|
175
|
+
if (!context.ready) return jsonResponse(res, 200, { ok: true, context, state: null })
|
|
176
|
+
const state = await storage.readState(context.cwd)
|
|
177
|
+
return jsonResponse(res, 200, { ok: true, context, state })
|
|
178
|
+
}
|
|
179
|
+
if (path[0] === 'confirm' && method === 'POST') {
|
|
180
|
+
return jsonResponse(res, 200, { ok: true, confirmation: await confirmGeneration(await readJson(req)) })
|
|
181
|
+
}
|
|
182
|
+
if (path[0] === 'generations' && path.length === 1 && method === 'POST') {
|
|
183
|
+
const body = await readJson(req)
|
|
184
|
+
const request = consumeConfirmation(body.token, body)
|
|
185
|
+
const task = orchestrator.start(request)
|
|
186
|
+
return jsonResponse(res, 202, { ok: true, generation: task })
|
|
187
|
+
}
|
|
188
|
+
if (path[0] === 'generations' && path[1] && path[2] === 'events' && method === 'GET') {
|
|
189
|
+
return handleEvents(req, res, path[1], orchestrator, sseClients)
|
|
190
|
+
}
|
|
191
|
+
if (path[0] === 'generations' && path[1] && path[2] === 'cancel' && method === 'POST') {
|
|
192
|
+
return jsonResponse(res, 200, { ok: true, generation: orchestrator.cancel(path[1]) })
|
|
193
|
+
}
|
|
194
|
+
if (path[0] === 'generations' && path[1] && path.length === 2 && method === 'GET') {
|
|
195
|
+
const generation = orchestrator.get(path[1])
|
|
196
|
+
return generation ? jsonResponse(res, 200, { ok: true, generation }) : jsonResponse(res, 404, { ok: false, error: '生成任务不存在。' })
|
|
197
|
+
}
|
|
198
|
+
if (path[0] === 'mind-map' && path[1] === 'follow-up-question' && method === 'POST') {
|
|
199
|
+
const body = await readJson(req)
|
|
200
|
+
const context = await contextFor(body.anchorSessionId)
|
|
201
|
+
if (!context.ready) throw new Error('当前对话没有可用工作路径。')
|
|
202
|
+
const state = await storage.readState(context.cwd)
|
|
203
|
+
const node = state.mindMap?.nodes?.find((item) => item.id === body.nodeId)
|
|
204
|
+
if (!node) throw new Error('思维导图节点不存在。')
|
|
205
|
+
const targetSessionId = safeId(body.targetSessionId || node.primarySourceSessionId || node.sourceRefs?.[0]?.sessionId, '目标 Session ID')
|
|
206
|
+
if (!state.manifest?.sourceSessionIds?.includes(targetSessionId)) throw new Error('目标对话不是本次生成的来源对话。')
|
|
207
|
+
const result = await orchestrator.formFollowUp({ cwd: context.cwd, node, targetSessionId, strict: state.manifest.strict !== false })
|
|
208
|
+
return jsonResponse(res, 200, { ok: true, followUp: result, context, revision: state.revision })
|
|
209
|
+
}
|
|
210
|
+
if (path[0] === 'navigation' && path[1] === 'confirm' && method === 'POST') {
|
|
211
|
+
const body = await readJson(req)
|
|
212
|
+
const context = await contextFor(body.anchorSessionId)
|
|
213
|
+
if (!context.ready) throw new Error('当前对话没有可用工作路径。')
|
|
214
|
+
const state = await storage.readState(context.cwd)
|
|
215
|
+
const targetSessionId = safeId(body.targetSessionId, '目标 Session ID')
|
|
216
|
+
if (!state.manifest?.sourceSessionIds?.includes(targetSessionId)) throw new Error('目标对话不是本次生成的来源对话。')
|
|
217
|
+
const question = String(body.question || '').trim()
|
|
218
|
+
if (!question || question.length > 2000) throw new Error('后续问题不能为空且不能超过 2000 个字符。')
|
|
219
|
+
const navigation = await storage.appendNavigation({
|
|
220
|
+
cwd: context.cwd,
|
|
221
|
+
expectedRevision: state.revision,
|
|
222
|
+
navigation: { id: randomUUID(), nodeId: body.nodeId, targetSessionId, question }
|
|
223
|
+
})
|
|
224
|
+
return jsonResponse(res, 200, { ok: true, navigation: { ...navigation.navigation, question, targetSessionId }, context })
|
|
225
|
+
}
|
|
226
|
+
return jsonResponse(res, 404, { ok: false, error: 'Not found' })
|
|
227
|
+
} catch (error) {
|
|
228
|
+
return writeError(res, error, statusForError(error))
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
registerEffect(ctx, webServer, { kind: 'prefix', path: BASE_PATH, handler: handleGeneration })
|
|
233
|
+
registerEffect(ctx, skills, {
|
|
234
|
+
name: 'conversation-knowledge-map',
|
|
235
|
+
description: '从同一工作路径的多个历史对话生成思维导图和静态知识图谱。',
|
|
236
|
+
whenToUse: '用户明确要求整理多个对话、生成脑图、知识图谱或从脑图节点继续探索时。',
|
|
237
|
+
source: 'runtime',
|
|
238
|
+
content: readFileSync(SKILL_PATH, 'utf8').replace(/^---\s*\r?\n[\s\S]*?\r?\n---\s*\r?\n?/, ''),
|
|
239
|
+
resourceBase: { kind: 'directory', path: SKILL_DIR_PATH }
|
|
240
|
+
})
|
|
241
|
+
if (typeof ctx?.effect === 'function') {
|
|
242
|
+
ctx.effect(() => () => {
|
|
243
|
+
for (const client of sseClients.values()) client.close?.()
|
|
244
|
+
sseClients.clear()
|
|
245
|
+
for (const task of orchestrator.tasks?.values?.() || []) task.controller?.abort?.(new Error('插件停止。'))
|
|
246
|
+
})
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
return host
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
async function handleEvents(req, res, id, orchestrator, sseClients) {
|
|
254
|
+
const snapshot = orchestrator.get(id)
|
|
255
|
+
if (!snapshot) return jsonResponse(res, 404, { ok: false, error: '生成任务不存在。' })
|
|
256
|
+
res.writeHead?.(200, {
|
|
257
|
+
'content-type': 'text/event-stream; charset=utf-8',
|
|
258
|
+
'cache-control': 'no-cache, no-transform',
|
|
259
|
+
connection: 'keep-alive'
|
|
260
|
+
})
|
|
261
|
+
const since = Number(parseUrl(req).searchParams.get('since') || 0)
|
|
262
|
+
const client = {
|
|
263
|
+
closed: false,
|
|
264
|
+
unsubscribe: null,
|
|
265
|
+
close() {
|
|
266
|
+
if (client.closed) return
|
|
267
|
+
client.closed = true
|
|
268
|
+
client.unsubscribe?.()
|
|
269
|
+
sseClients.delete(client)
|
|
270
|
+
try { res.end?.() } catch { /* response already closed */ }
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
sseClients.add(client)
|
|
274
|
+
client.unsubscribe = orchestrator.subscribe(id, (event, current) => {
|
|
275
|
+
if (event.id <= since || client.closed) return
|
|
276
|
+
try { sseWrite(res, 'update', { event, generation: current }, event.id) } catch { client.close() }
|
|
277
|
+
}, since)
|
|
278
|
+
sseWrite(res, 'snapshot', { generation: orchestrator.get(id) }, snapshot.events?.at(-1)?.id || 0)
|
|
279
|
+
req.on?.('close', () => client.close())
|
|
280
|
+
res.on?.('close', () => client.close())
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
const host = createHost()
|
|
284
|
+
host.pluginId = PLUGIN_ID
|
|
285
|
+
export { BASE_PATH }
|
|
286
|
+
export default host
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
export const KNOWLEDGE_GRAPH_SCHEMA_VERSION = 1
|
|
2
|
+
export const MAX_ENTITIES = 150
|
|
3
|
+
export const MAX_RELATIONS = 300
|
|
4
|
+
|
|
5
|
+
export class KnowledgeGraphValidationError extends Error {
|
|
6
|
+
constructor(message) {
|
|
7
|
+
super(message)
|
|
8
|
+
this.name = 'KnowledgeGraphValidationError'
|
|
9
|
+
}
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
function asText(value, label, max = 4000) {
|
|
13
|
+
const text = String(value ?? '').trim()
|
|
14
|
+
if (!text) throw new KnowledgeGraphValidationError(`${label} 不能为空。`)
|
|
15
|
+
if (text.length > max) throw new KnowledgeGraphValidationError(`${label} 超出长度限制。`)
|
|
16
|
+
return text
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function refsOf(raw, selectedSessionIds, label) {
|
|
20
|
+
const refs = raw?.sourceRefs ?? raw?.evidence ?? []
|
|
21
|
+
if (!Array.isArray(refs)) return []
|
|
22
|
+
const selected = new Set(selectedSessionIds || [])
|
|
23
|
+
return refs.map((ref) => {
|
|
24
|
+
const sessionId = asText(ref?.sessionId, `${label}来源 Session ID`, 256)
|
|
25
|
+
if (selected.size && !selected.has(sessionId)) throw new KnowledgeGraphValidationError(`来源 Session 不在本次选择中:${sessionId}`)
|
|
26
|
+
const eventSeqs = Array.isArray(ref?.eventSeqs)
|
|
27
|
+
? [...new Set(ref.eventSeqs.filter((seq) => Number.isInteger(seq) && seq >= 0))]
|
|
28
|
+
: []
|
|
29
|
+
if (!eventSeqs.length) throw new KnowledgeGraphValidationError(`${label}来源缺少事件序号。`)
|
|
30
|
+
return { sessionId, eventSeqs }
|
|
31
|
+
}).slice(0, 12)
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function confidenceOf(value) {
|
|
35
|
+
const confidence = String(value || 'confirmed').trim()
|
|
36
|
+
if (!['confirmed', 'inferred', 'conflicted'].includes(confidence)) throw new KnowledgeGraphValidationError(`不支持的置信度:${confidence}`)
|
|
37
|
+
return confidence
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export function validateKnowledgeGraph(input, { selectedSessionIds = [], strict = false } = {}) {
|
|
41
|
+
if (!input || typeof input !== 'object') throw new KnowledgeGraphValidationError('知识图谱结果必须是对象。')
|
|
42
|
+
const rawEntities = Array.isArray(input.entities) ? input.entities : []
|
|
43
|
+
const rawRelations = Array.isArray(input.relations) ? input.relations : []
|
|
44
|
+
if (!rawEntities.length) throw new KnowledgeGraphValidationError('知识图谱至少需要一个实体。')
|
|
45
|
+
if (rawEntities.length > MAX_ENTITIES) throw new KnowledgeGraphValidationError(`实体不能超过 ${MAX_ENTITIES} 个。`)
|
|
46
|
+
if (rawRelations.length > MAX_RELATIONS) throw new KnowledgeGraphValidationError(`关系不能超过 ${MAX_RELATIONS} 条。`)
|
|
47
|
+
const ids = new Set()
|
|
48
|
+
const entities = rawEntities.map((raw, index) => {
|
|
49
|
+
const id = asText(raw?.id || `entity-${index + 1}`, '实体 ID', 256)
|
|
50
|
+
if (ids.has(id)) throw new KnowledgeGraphValidationError(`实体 ID 重复:${id}`)
|
|
51
|
+
ids.add(id)
|
|
52
|
+
const sourceRefs = refsOf(raw, selectedSessionIds, `实体 ${id}`)
|
|
53
|
+
if (strict && !sourceRefs.length) throw new KnowledgeGraphValidationError(`严格约束要求实体 ${id} 带来源引用。`)
|
|
54
|
+
return {
|
|
55
|
+
id,
|
|
56
|
+
type: asText(raw?.type || 'concept', `实体 ${id} 类型`, 80),
|
|
57
|
+
name: asText(raw?.name || raw?.label, `实体 ${id} 名称`, 240),
|
|
58
|
+
summary: asText(raw?.summary || raw?.description, `实体 ${id} 摘要`, 1600),
|
|
59
|
+
confidence: confidenceOf(raw?.confidence),
|
|
60
|
+
sourceRefs
|
|
61
|
+
}
|
|
62
|
+
})
|
|
63
|
+
const relations = rawRelations.map((raw, index) => {
|
|
64
|
+
const from = asText(raw?.from, `关系 ${index + 1} from`, 256)
|
|
65
|
+
const to = asText(raw?.to, `关系 ${index + 1} to`, 256)
|
|
66
|
+
if (!ids.has(from) || !ids.has(to)) throw new KnowledgeGraphValidationError(`关系 ${index + 1} 引用了不存在的实体。`)
|
|
67
|
+
if (from === to) throw new KnowledgeGraphValidationError(`关系 ${index + 1} 不能连接实体自身。`)
|
|
68
|
+
const evidence = refsOf(raw, selectedSessionIds, `关系 ${index + 1}`)
|
|
69
|
+
if (strict && !evidence.length) throw new KnowledgeGraphValidationError(`严格约束要求关系 ${index + 1} 带证据。`)
|
|
70
|
+
return {
|
|
71
|
+
id: String(raw?.id || `relation-${index + 1}`),
|
|
72
|
+
from,
|
|
73
|
+
to,
|
|
74
|
+
type: asText(raw?.type || 'related_to', `关系 ${index + 1} 类型`, 80),
|
|
75
|
+
confidence: confidenceOf(raw?.confidence),
|
|
76
|
+
evidence
|
|
77
|
+
}
|
|
78
|
+
})
|
|
79
|
+
const relationIds = new Set()
|
|
80
|
+
for (const relation of relations) {
|
|
81
|
+
if (relationIds.has(relation.id)) throw new KnowledgeGraphValidationError(`关系 ID 重复:${relation.id}`)
|
|
82
|
+
relationIds.add(relation.id)
|
|
83
|
+
}
|
|
84
|
+
return {
|
|
85
|
+
schemaVersion: KNOWLEDGE_GRAPH_SCHEMA_VERSION,
|
|
86
|
+
entities,
|
|
87
|
+
relations
|
|
88
|
+
}
|
|
89
|
+
}
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
export const MIND_MAP_SCHEMA_VERSION = 1
|
|
2
|
+
export const MAX_MIND_MAP_NODES = 80
|
|
3
|
+
|
|
4
|
+
export class MindMapValidationError extends Error {
|
|
5
|
+
constructor(message) {
|
|
6
|
+
super(message)
|
|
7
|
+
this.name = 'MindMapValidationError'
|
|
8
|
+
}
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
function asText(value, label, max = 20000) {
|
|
12
|
+
const text = String(value ?? '').trim()
|
|
13
|
+
if (!text) throw new MindMapValidationError(`${label} 不能为空。`)
|
|
14
|
+
if (text.length > max) throw new MindMapValidationError(`${label} 超出长度限制。`)
|
|
15
|
+
return text
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function normalizeRefs(refs, selectedSessionIds) {
|
|
19
|
+
if (!Array.isArray(refs)) return []
|
|
20
|
+
const selected = new Set(selectedSessionIds || [])
|
|
21
|
+
return refs.map((ref) => {
|
|
22
|
+
const sessionId = asText(ref?.sessionId, '来源 Session ID', 256)
|
|
23
|
+
if (selected.size && !selected.has(sessionId)) throw new MindMapValidationError(`来源 Session 不在本次选择中:${sessionId}`)
|
|
24
|
+
const eventSeqs = Array.isArray(ref?.eventSeqs)
|
|
25
|
+
? [...new Set(ref.eventSeqs.filter((seq) => Number.isInteger(seq) && seq >= 0))]
|
|
26
|
+
: []
|
|
27
|
+
if (!eventSeqs.length) throw new MindMapValidationError(`来源 ${sessionId} 缺少事件序号。`)
|
|
28
|
+
return { sessionId, eventSeqs }
|
|
29
|
+
}).slice(0, 12)
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function detectCycle(nodes) {
|
|
33
|
+
const byId = new Map(nodes.map((node) => [node.id, node]))
|
|
34
|
+
const visiting = new Set()
|
|
35
|
+
const visited = new Set()
|
|
36
|
+
function visit(id) {
|
|
37
|
+
if (visited.has(id)) return
|
|
38
|
+
if (visiting.has(id)) throw new MindMapValidationError('思维导图层级存在循环。')
|
|
39
|
+
visiting.add(id)
|
|
40
|
+
const parentId = byId.get(id)?.parentId
|
|
41
|
+
if (parentId) visit(parentId)
|
|
42
|
+
visiting.delete(id)
|
|
43
|
+
visited.add(id)
|
|
44
|
+
}
|
|
45
|
+
for (const node of nodes) visit(node.id)
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export function validateMindMap(input, { selectedSessionIds = [], strict = false } = {}) {
|
|
49
|
+
if (!input || typeof input !== 'object') throw new MindMapValidationError('思维导图结果必须是对象。')
|
|
50
|
+
const rawNodes = Array.isArray(input.nodes) ? input.nodes : []
|
|
51
|
+
if (!rawNodes.length) throw new MindMapValidationError('思维导图至少需要一个节点。')
|
|
52
|
+
if (rawNodes.length > MAX_MIND_MAP_NODES) throw new MindMapValidationError(`思维导图节点不能超过 ${MAX_MIND_MAP_NODES} 个。`)
|
|
53
|
+
const rootId = asText(input.rootId || rawNodes[0]?.id, '思维导图 rootId', 256)
|
|
54
|
+
const ids = new Set()
|
|
55
|
+
const nodes = rawNodes.map((raw, index) => {
|
|
56
|
+
const id = asText(raw?.id || `mind-node-${index + 1}`, '思维导图节点 ID', 256)
|
|
57
|
+
if (ids.has(id)) throw new MindMapValidationError(`思维导图节点 ID 重复:${id}`)
|
|
58
|
+
ids.add(id)
|
|
59
|
+
const parentId = raw?.parentId === null || raw?.parentId === undefined || raw?.parentId === '' ? null : asText(raw.parentId, 'parentId', 256)
|
|
60
|
+
const title = asText(raw?.title ?? raw?.label, `节点 ${id} 标题`, 120)
|
|
61
|
+
const narrative = asText(raw?.narrative ?? raw?.summary, `节点 ${id} 阶段性说明`, 2000)
|
|
62
|
+
if (narrative.length < 20) throw new MindMapValidationError(`节点 ${id} 的阶段性说明过短,不能只使用关键词。`)
|
|
63
|
+
const sourceRefs = normalizeRefs(raw?.sourceRefs, selectedSessionIds)
|
|
64
|
+
if (strict && !sourceRefs.length) throw new MindMapValidationError(`严格约束要求节点 ${id} 带来源引用。`)
|
|
65
|
+
const primarySourceSessionId = raw?.primarySourceSessionId ? asText(raw.primarySourceSessionId, '主要来源 Session ID', 256) : (sourceRefs[0]?.sessionId || '')
|
|
66
|
+
if (primarySourceSessionId && selectedSessionIds.length && !selectedSessionIds.includes(primarySourceSessionId)) {
|
|
67
|
+
throw new MindMapValidationError(`节点 ${id} 的主要来源不在本次选择中。`)
|
|
68
|
+
}
|
|
69
|
+
const openQuestions = Array.isArray(raw?.openQuestions)
|
|
70
|
+
? raw.openQuestions.map((question) => String(question || '').trim()).filter(Boolean).slice(0, 3)
|
|
71
|
+
: []
|
|
72
|
+
return {
|
|
73
|
+
id,
|
|
74
|
+
parentId,
|
|
75
|
+
type: asText(raw?.type || 'stage', `节点 ${id} 类型`, 40),
|
|
76
|
+
title,
|
|
77
|
+
narrative,
|
|
78
|
+
primarySourceSessionId,
|
|
79
|
+
sourceRefs,
|
|
80
|
+
openQuestions
|
|
81
|
+
}
|
|
82
|
+
})
|
|
83
|
+
if (!ids.has(rootId)) throw new MindMapValidationError(`rootId 不存在:${rootId}`)
|
|
84
|
+
const rootNodes = nodes.filter((node) => node.parentId === null)
|
|
85
|
+
if (rootNodes.length !== 1 || rootNodes[0].id !== rootId) throw new MindMapValidationError('思维导图必须只有一个根节点。')
|
|
86
|
+
for (const node of nodes) if (node.parentId && !ids.has(node.parentId)) throw new MindMapValidationError(`节点 ${node.id} 的父节点不存在。`)
|
|
87
|
+
detectCycle(nodes)
|
|
88
|
+
return {
|
|
89
|
+
schemaVersion: MIND_MAP_SCHEMA_VERSION,
|
|
90
|
+
rootId,
|
|
91
|
+
nodes,
|
|
92
|
+
edges: nodes.filter((node) => node.parentId).map((node) => ({ from: node.parentId, to: node.id }))
|
|
93
|
+
}
|
|
94
|
+
}
|
package/lib/protocol.js
ADDED
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
export const DATA_DIR = '.g-dsh-market-knowledge'
|
|
2
|
+
export const OUTPUT_MODES = new Set(['mind-map', 'knowledge-graph', 'both'])
|
|
3
|
+
export const GENERATION_STATUSES = [
|
|
4
|
+
'created',
|
|
5
|
+
'confirming',
|
|
6
|
+
'reading-sources',
|
|
7
|
+
'summarizing',
|
|
8
|
+
'building-mind-map',
|
|
9
|
+
'building-knowledge-graph',
|
|
10
|
+
'validating',
|
|
11
|
+
'saving',
|
|
12
|
+
'completed',
|
|
13
|
+
'failed',
|
|
14
|
+
'cancelled'
|
|
15
|
+
]
|
|
16
|
+
|
|
17
|
+
export function errorMessage(error) {
|
|
18
|
+
return error instanceof Error ? error.message : String(error || '未知错误')
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function shortText(value, max = 8000) {
|
|
22
|
+
const text = String(value ?? '').replace(/\s+/g, ' ').trim()
|
|
23
|
+
return text.length > max ? `${text.slice(0, Math.max(0, max - 1))}…` : text
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export function clone(value) {
|
|
27
|
+
return value === undefined ? undefined : JSON.parse(JSON.stringify(value))
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export function parseUrl(req) {
|
|
31
|
+
if (req?.url instanceof URL) return req.url
|
|
32
|
+
return new URL(String(req?.url || '/'), 'http://dsh.local')
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export async function readJson(req, maxBytes = 512 * 1024) {
|
|
36
|
+
if (req?.body && typeof req.body === 'object') return req.body
|
|
37
|
+
if (typeof req?.body === 'string') return JSON.parse(req.body || '{}')
|
|
38
|
+
const chunks = []
|
|
39
|
+
let size = 0
|
|
40
|
+
for await (const chunk of req || []) {
|
|
41
|
+
size += Buffer.byteLength(chunk)
|
|
42
|
+
if (size > maxBytes) throw new Error('请求体过大。')
|
|
43
|
+
chunks.push(Buffer.from(chunk))
|
|
44
|
+
}
|
|
45
|
+
const text = Buffer.concat(chunks).toString('utf8').trim()
|
|
46
|
+
return text ? JSON.parse(text) : {}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export function jsonResponse(res, status, body) {
|
|
50
|
+
const text = JSON.stringify(body)
|
|
51
|
+
res.writeHead?.(status, {
|
|
52
|
+
'content-type': 'application/json; charset=utf-8',
|
|
53
|
+
'cache-control': 'no-store'
|
|
54
|
+
})
|
|
55
|
+
res.end?.(text)
|
|
56
|
+
return body
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export function sseWrite(res, eventName, body, id) {
|
|
60
|
+
if (id !== undefined) res.write(`id: ${id}\n`)
|
|
61
|
+
res.write(`event: ${eventName}\n`)
|
|
62
|
+
res.write(`data: ${JSON.stringify(body)}\n\n`)
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export function methodOf(req) {
|
|
66
|
+
return String(req?.method || 'GET').toUpperCase()
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export function safeId(value, label = 'ID') {
|
|
70
|
+
const result = String(value || '').trim()
|
|
71
|
+
if (!result || result.length > 256 || /[\u0000-\u001f]/.test(result)) throw new Error(`${label} 无效。`)
|
|
72
|
+
return result
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export function contentText(value) {
|
|
76
|
+
if (value === undefined || value === null) return ''
|
|
77
|
+
if (typeof value === 'string') return value
|
|
78
|
+
if (Array.isArray(value)) {
|
|
79
|
+
return value.map((item) => {
|
|
80
|
+
if (item && typeof item === 'object' && item.type === 'reasoning') return ''
|
|
81
|
+
return contentText(item)
|
|
82
|
+
}).filter(Boolean).join('')
|
|
83
|
+
}
|
|
84
|
+
if (typeof value === 'object') {
|
|
85
|
+
if (typeof value.text === 'string') return value.text
|
|
86
|
+
if (typeof value.content !== 'undefined') return contentText(value.content)
|
|
87
|
+
if (typeof value.value === 'string') return value.value
|
|
88
|
+
}
|
|
89
|
+
return ''
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
export function eventMessageText(event) {
|
|
93
|
+
const data = event?.data || event || {}
|
|
94
|
+
const message = data.message || data
|
|
95
|
+
return contentText(message.content ?? message.text ?? message)
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
export function makeUserMessage(text, messageId = `knowledge-map-${Date.now()}`) {
|
|
99
|
+
return {
|
|
100
|
+
id: messageId,
|
|
101
|
+
role: 'user',
|
|
102
|
+
content: [{ type: 'text', text: String(text || '') }]
|
|
103
|
+
}
|
|
104
|
+
}
|