@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/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
+ }
@@ -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
+ }