/** * Canvas ↔ DSH Session binding. * Web input → agent.followup; session/event → push to mind-map WS mirror. */ import { randomUUID } from 'node:crypto' import { homedir } from 'node:os' import { join } from 'node:path' const PLUGIN = 'dsh-mind-map' function dshHome() { return process.env.DSH_HOME || join(homedir(), '.dsh') } function textOf(message) { const content = message?.content if (typeof content === 'string') return content if (!Array.isArray(content)) return '' return content .filter((b) => b && b.type === 'text' && typeof b.text === 'string') .map((b) => b.text) .join('') } function tryGet(ctx, name) { try { return ctx.get?.(name) ?? ctx[name] } catch { return undefined } } /** Same default model a New Session in DSH Web would use. */ function currentDefaultModel(ctx) { try { const selection = tryGet(ctx, 'agentDefaultModel')?.currentSelection?.() const provider = typeof selection?.provider === 'string' ? selection.provider.trim() : '' const model = typeof selection?.model === 'string' ? selection.model.trim() : '' if (!provider || !model) return null return { provider, model, ...(selection.reasoningEffort === undefined ? {} : { reasoningEffort: selection.reasoningEffort }), } } catch { return null } } /** Drop harness context notices / tool rows — only real chat turns for the mirror. */ function isMirrorableChatMessage(message) { if (!message || typeof message !== 'object') return false const source = message.source || {} const kind = source.kind const form = source.form if (form === 'snapshot' || form === 'notice' || form === 'instructions' || form === 'catalog') { return false } if (kind === 'tool') return false if (message.role === 'assistant') return kind === 'model' || kind == null || kind === undefined if (message.role === 'user') { return kind === 'user' || kind === 'plugin' || kind == null || kind === undefined } return false } function isRuntimeContextText(text) { return ( typeof text === 'string' && (text.includes('Current runtime context') || text.includes('Current DSH file policy') || text.includes('This snapshot supersedes')) ) } export function createSessionBridge({ ctx, getMindMapBase, getInternalToken, logger }) { /** @type {Map} */ const byCanvas = new Map() /** @type {Map} mirror agent session → canvas */ const canvasBySession = new Map() /** @type {Map} DSH chat session → currently displayed canvas */ const activeBySession = new Map() /** @type {Map} */ const uiOpenBySession = new Map() function setActiveCanvas(sessionId, canvasId) { const sid = String(sessionId || '').trim() if (!sid) return null const cid = String(canvasId || '').trim() if (!cid) { activeBySession.delete(sid) return null } activeBySession.set(sid, cid) return cid } function getActiveCanvas(sessionId) { const sid = String(sessionId || '').trim() if (!sid) return null return activeBySession.get(sid) || canvasBySession.get(sid) || null } function clearActiveCanvas(sessionId) { const sid = String(sessionId || '').trim() if (sid) activeBySession.delete(sid) } function clearActiveForCanvas(canvasId) { const cid = String(canvasId || '') for (const [sid, id] of activeBySession.entries()) { if (id === cid) activeBySession.delete(sid) } } /** Ask the DSH client to open the 思维导图 tab on this canvas. */ function queueUiOpen(sessionId, { canvasId, title } = {}) { const sid = String(sessionId || '').trim() const cid = String(canvasId || '').trim() if (!sid || !cid) return false setActiveCanvas(sid, cid) const prev = uiOpenBySession.get(sid) // Coalesce: same canvas already queued → just refresh title/time (don't churn). if (prev && prev.canvasId === cid) { prev.title = String(title || prev.title || 'Mind Map') prev.at = Date.now() return true } uiOpenBySession.set(sid, { canvasId: cid, title: String(title || 'Mind Map'), at: Date.now(), }) return true } function consumeUiOpen(sessionId) { const sid = String(sessionId || '').trim() if (!sid) return null const cmd = uiOpenBySession.get(sid) || null if (cmd) uiOpenBySession.delete(sid) return cmd } async function ensureBinding(canvasId) { const existing = byCanvas.get(canvasId) if (existing?.agent) return existing const agents = tryGet(ctx, 'agents') if (!agents || typeof agents.create !== 'function') { throw new Error('DSH agents service unavailable') } const selection = currentDefaultModel(ctx) if (!selection) { throw new Error( 'DSH 尚未选择默认模型。请先在 DSH 设置 → Models 选好模型,再在画布对话。', ) } const sessionId = randomUUID() const cwd = process.env.MINDMAP_SESSION_CWD || join(dshHome(), 'user-workspaces', 'administrator') const handle = await agents.create({ sessionId, agentOptions: { provider: selection.provider, model: selection.model, ...selection.reasoningEffort === undefined ? {} : { reasoningEffort: selection.reasoningEffort }, }, meta: { cwd, title: `Mind Map · ${String(canvasId).slice(0, 8)}`, }, setup: (agentCtx) => { // Fill {{model}} / {{provider}} for persona assembly (same as dsh-ops-cron). if (!agentCtx || typeof agentCtx.on !== 'function') return const snapshot = { provider: selection.provider, model: selection.model, ...selection.reasoningEffort === undefined ? {} : { reasoningEffort: selection.reasoningEffort }, } agentCtx.on('system-prompt/assemble', async (...args) => { const next = args.find((arg) => typeof arg === 'function') const assembled = next ? await next() : (args[0] || {}) return { ...assembled, variables: { ...assembled.variables, provider: snapshot.provider, model: snapshot.model, }, } }) agentCtx.on('agent/request', async (...args) => { const next = args.find((arg) => typeof arg === 'function') const resolved = next ? await next() : (args[0] || {}) const { reasoningEffort: _inherited, ...rest } = resolved || {} return { ...rest, provider: snapshot.provider, model: snapshot.model, ...snapshot.reasoningEffort === undefined ? {} : { reasoningEffort: snapshot.reasoningEffort }, } }) }, }) const agent = handle?.agent if (!agent || typeof agent.followup !== 'function') { try { handle?.dispose?.() } catch { /* ignore */ } throw new Error('agents.create did not return a usable agent') } try { if (typeof agent.session?.append === 'function') { agent.session.append('session/title', { title: `Mind Map · ${String(canvasId).slice(0, 8)}`, source: 'plugin', }) } } catch { /* best-effort */ } // Best-effort: show session in workspace list try { const registry = tryGet(ctx, 'workspaceRegistry') const list = typeof registry?.list === 'function' ? registry.list() : typeof registry?.workspaces === 'function' ? registry.workspaces() : [] const rows = Array.isArray(list) ? list : [] const ws = rows.find((w) => { const p = String(w?.path || w?.cwd || '') return p && p.replace(/\\/g, '/') === cwd.replace(/\\/g, '/') }) || rows[0] if (ws && typeof ws.attachSession === 'function') { await ws.attachSession(sessionId) } } catch (e) { logger?.warn?.('[dsh-mind-map] workspace attach skipped: %s', e?.message || e) } const binding = { sessionId, handle, agent, model: selection } byCanvas.set(canvasId, binding) canvasBySession.set(sessionId, canvasId) logger?.info?.( '[dsh-mind-map] bound canvas %s → session %s (%s/%s)', canvasId, sessionId, selection.provider, selection.model, ) return binding } async function prompt(canvasId, text) { const trimmed = String(text || '').trim() if (!canvasId || !trimmed) throw new Error('canvasId and text required') const binding = await ensureBinding(canvasId) // Match dsh-ops-cron: plain message object + plugin source (reliable wake). const message = { id: randomUUID(), role: 'user', content: [{ type: 'text', text: trimmed }], source: { kind: 'plugin', plugin: PLUGIN }, } binding.agent.followup(message) return { ok: true, sessionId: binding.sessionId, model: binding.model || null, } } async function history(canvasId) { const binding = byCanvas.get(canvasId) if (!binding) return { sessionId: null, messages: [], model: null } const session = binding.agent?.session let messages = [] try { const derived = typeof session?.deriveMessages === 'function' ? session.deriveMessages() : session?.messages || [] const list = Array.isArray(derived) ? derived : [] messages = list .filter(isMirrorableChatMessage) .map((m) => { const role = m?.role === 'assistant' ? 'assistant' : m?.role === 'user' ? 'user' : null if (!role) return null const content = textOf(m) if (!content || isRuntimeContextText(content)) return null // Skip our own plugin echoes if duplicated; keep user-visible text. return { role, content } }) .filter(Boolean) } catch { messages = [] } return { sessionId: binding.sessionId, messages, model: binding.model || null } } function status(canvasId) { const binding = canvasId ? byCanvas.get(canvasId) : null const selection = currentDefaultModel(ctx) return { ok: true, mode: 'dsh-session', bound: Boolean(binding), sessionId: binding?.sessionId || null, agents: Boolean(tryGet(ctx, 'agents')), model: binding?.model || selection, hasDefaultModel: Boolean(selection), } } async function pushMirror(canvasId, payload) { const base = getMindMapBase?.() if (!base) return const token = getInternalToken?.() || '' try { await fetch(`${base}/api/internal/session-event`, { method: 'POST', headers: { 'content-type': 'application/json', 'x-mindmap-token': token, }, body: JSON.stringify({ canvasId, ...payload }), }) } catch (e) { logger?.warn?.('[dsh-mind-map] mirror push failed: %s', e?.message || e) } } function onSessionEvent(session, event) { const sessionId = session?.id || session?.header?.id if (!sessionId) return const canvasId = canvasBySession.get(sessionId) if (!canvasId) return const type = event?.type if (type === 'assistant/chunk') { const chunk = event.data?.chunk if (chunk?.type === 'text-delta' && typeof chunk.text === 'string') { void pushMirror(canvasId, { mirror: { kind: 'text-delta', text: chunk.text, sessionId }, }) } return } if (type === 'user/message') { const message = event.data?.message || event.data if (!isMirrorableChatMessage(message)) return const text = textOf(message) if (!text || isRuntimeContextText(text)) return // Don't mirror our own plugin-sourced prompt twice (UI already shows optimistic bubble). if (message?.source?.kind === 'plugin' && message?.source?.plugin === PLUGIN) return void pushMirror(canvasId, { mirror: { kind: 'user', text, sessionId }, }) return } if (type === 'assistant/message') { const message = event.data?.message || event.data if (!isMirrorableChatMessage(message)) return const text = textOf(message) if (!text || isRuntimeContextText(text)) return void pushMirror(canvasId, { mirror: { kind: 'assistant', text, sessionId }, }) return } if (type === 'tool/call') { void pushMirror(canvasId, { mirror: { kind: 'tool', name: event.data?.name || 'tool', callId: event.data?.callId, sessionId, }, }) return } if (type === 'turn/end') { void pushMirror(canvasId, { mirror: { kind: 'turn-end', reason: event.data?.reason, sessionId, }, }) } } function dispose() { for (const binding of byCanvas.values()) { try { binding.handle?.dispose?.() } catch { /* ignore */ } } byCanvas.clear() canvasBySession.clear() activeBySession.clear() uiOpenBySession.clear() } return { ensureBinding, prompt, history, status, onSessionEvent, dispose, byCanvas, canvasBySession, activeBySession, setActiveCanvas, getActiveCanvas, clearActiveCanvas, clearActiveForCanvas, queueUiOpen, consumeUiOpen, } }