mirror of
https://github.com/hansjone/mind-map.git
synced 2026-10-11 02:53:22 +08:00
Initial commit: DSH mind-map canvas monorepo
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
commit
2ecd300ada
92 changed files with 20486 additions and 0 deletions
15
packages/dsh-plugin/INSTALL.md
Normal file
15
packages/dsh-plugin/INSTALL.md
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
# dsh-mind-map 本地安装到 DSH web profile(开发用)
|
||||
|
||||
```powershell
|
||||
$plugin = "d:\project\chatgpt\mind-map\packages\dsh-plugin"
|
||||
# 用 dsh plugin add 链到本地包,或在 profile 里 link
|
||||
dsh plugin --profile web add -w $plugin
|
||||
```
|
||||
|
||||
装好后:
|
||||
|
||||
1. 重启 `dsh web`,浏览器硬刷新(Ctrl+Shift+R)
|
||||
2. 右侧栏「+」选 **思维导图**,或 设置 → Mind Map → **打开思维导图 Tab**
|
||||
3. 拓扑占满右侧内容区;对话继续用中间 DSH 输入框
|
||||
|
||||
依赖:已安装 `dsh-better-sidebar`(提供 `ctx.betterSidebar.registerTab`)。
|
||||
11
packages/dsh-plugin/README.md
Normal file
11
packages/dsh-plugin/README.md
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
# dsh-mind-map
|
||||
|
||||
DSH 插件:自建画布 Web;**对话 = DSH 会话镜像**(输入转发,历史由 DSH 管)。
|
||||
|
||||
## 架构
|
||||
|
||||
- Host:注册 `mindmap_*`、启停本机 Web、会话绑定
|
||||
- Client:设置卡(端口 / 打开画布 / 绑定)
|
||||
- Web 右栏:零持久化镜像,不做独立 chat
|
||||
|
||||
详见仓库 `docs/dsh-bridge.md` 与设计稿 v2.9。
|
||||
4
packages/dsh-plugin/cordis.patch.yml
Normal file
4
packages/dsh-plugin/cordis.patch.yml
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
# Mind Map — DSH profile patch
|
||||
- insert:
|
||||
- id: dsh-mind-map
|
||||
name: dsh-mind-map
|
||||
1223
packages/dsh-plugin/lib/client.js
Normal file
1223
packages/dsh-plugin/lib/client.js
Normal file
File diff suppressed because it is too large
Load diff
380
packages/dsh-plugin/lib/index.js
Normal file
380
packages/dsh-plugin/lib/index.js
Normal file
|
|
@ -0,0 +1,380 @@
|
|||
/**
|
||||
* dsh-mind-map — Host half.
|
||||
* Canvas Web is local; chat IS a DSH Session (mirror only on the Web).
|
||||
*/
|
||||
|
||||
import { createRequire } from 'node:module'
|
||||
import { randomBytes } from 'node:crypto'
|
||||
import { homedir } from 'node:os'
|
||||
import { join, dirname } from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import { createRuntime } from './runtime.js'
|
||||
import { createSessionBridge } from './session-bridge.js'
|
||||
import { makeMindmapSkill, registerMindmapTools } from './tools.js'
|
||||
|
||||
export const name = 'dsh-mind-map'
|
||||
export const NS = 'dsh-mind-map'
|
||||
export const inject = []
|
||||
|
||||
const API_PREFIX = '/dsh-mind-map'
|
||||
const DEFAULTS = {
|
||||
enabled: true,
|
||||
port: 17890,
|
||||
autoStart: true,
|
||||
}
|
||||
|
||||
function loadPkg(id) {
|
||||
const homes = [
|
||||
fileURLToPath(import.meta.url),
|
||||
join(process.cwd(), 'package.json'),
|
||||
join(process.env.DSH_HOME || join(homedir(), '.dsh'), 'profiles', 'web', 'package.json'),
|
||||
]
|
||||
const errors = []
|
||||
for (const from of homes) {
|
||||
try {
|
||||
const require = createRequire(from)
|
||||
return require(id)
|
||||
} catch (e) {
|
||||
errors.push(e)
|
||||
}
|
||||
}
|
||||
const err = new Error(`cannot resolve ${id}`)
|
||||
err.cause = errors[0]
|
||||
throw err
|
||||
}
|
||||
|
||||
const SchemaMod = loadPkg('@deepseek-ai/schemastery')
|
||||
const Schema = SchemaMod.default || SchemaMod
|
||||
|
||||
export const Config = Schema.object({
|
||||
enabled: Schema.boolean().default(DEFAULTS.enabled),
|
||||
port: Schema.number().min(1024).max(65535).step(1).default(DEFAULTS.port),
|
||||
autoStart: Schema.boolean().default(DEFAULTS.autoStart),
|
||||
})
|
||||
|
||||
function resolveConfig(config = {}) {
|
||||
return {
|
||||
enabled: config.enabled !== false,
|
||||
port: Number(config.port) > 0 ? Number(config.port) : DEFAULTS.port,
|
||||
autoStart: config.autoStart !== false,
|
||||
}
|
||||
}
|
||||
|
||||
function tryGet(ctx, key) {
|
||||
try {
|
||||
return ctx.get?.(key) ?? ctx[key]
|
||||
} catch {
|
||||
return undefined
|
||||
}
|
||||
}
|
||||
|
||||
function writeJson(res, status, body) {
|
||||
if (res.headersSent || res.writableEnded) return
|
||||
res.writeHead(status, {
|
||||
'content-type': 'application/json; charset=utf-8',
|
||||
'cache-control': 'no-store',
|
||||
})
|
||||
res.end(JSON.stringify(body))
|
||||
}
|
||||
|
||||
async function readJson(req) {
|
||||
const chunks = []
|
||||
for await (const c of req) chunks.push(c)
|
||||
if (!chunks.length) return {}
|
||||
try {
|
||||
return JSON.parse(Buffer.concat(chunks).toString('utf8'))
|
||||
} catch {
|
||||
return {}
|
||||
}
|
||||
}
|
||||
|
||||
function registerWebRoute(ctx, handler) {
|
||||
const register = (webServer) => {
|
||||
if (!webServer || typeof webServer.register !== 'function') return () => {}
|
||||
return webServer.register({
|
||||
kind: 'prefix',
|
||||
path: API_PREFIX,
|
||||
handler,
|
||||
})
|
||||
}
|
||||
const present = tryGet(ctx, 'webServer')
|
||||
if (present !== undefined) return register(present)
|
||||
const disposers = []
|
||||
let registered = false
|
||||
const off = ctx.on('internal/service', (serviceName, value) => {
|
||||
if (serviceName !== 'webServer' || registered) return
|
||||
registered = true
|
||||
disposers.push(register(value))
|
||||
})
|
||||
return () => {
|
||||
off()
|
||||
for (const d of disposers) {
|
||||
if (typeof d === 'function') d()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function apply(ctx, config = {}) {
|
||||
const entry = resolveConfig(config)
|
||||
const runtime = createRuntime()
|
||||
const internalToken = randomBytes(16).toString('hex')
|
||||
let mindMapBase = `http://127.0.0.1:${entry.port}`
|
||||
let bridge = null
|
||||
|
||||
const getBase = () => mindMapBase
|
||||
const getToken = () => internalToken
|
||||
|
||||
bridge = createSessionBridge({
|
||||
ctx,
|
||||
getMindMapBase: getBase,
|
||||
getInternalToken: getToken,
|
||||
logger: ctx.logger,
|
||||
})
|
||||
|
||||
async function ensureServer(cfg = entry) {
|
||||
if (!cfg.enabled) {
|
||||
await runtime.stop()
|
||||
return runtime.status()
|
||||
}
|
||||
let dshPort = tryGet(ctx, 'webServer')?.port
|
||||
for (let i = 0; !dshPort && i < 40; i++) {
|
||||
await new Promise((r) => setTimeout(r, 100))
|
||||
dshPort = tryGet(ctx, 'webServer')?.port
|
||||
}
|
||||
if (!dshPort) {
|
||||
throw new Error('DSH webServer.port unavailable; cannot wire session bridge')
|
||||
}
|
||||
const bridgeUrl = `http://127.0.0.1:${dshPort}${API_PREFIX}`
|
||||
const st = await runtime.start({
|
||||
port: cfg.port,
|
||||
bridgeUrl,
|
||||
internalToken,
|
||||
})
|
||||
mindMapBase = st.url
|
||||
return st
|
||||
}
|
||||
|
||||
ctx.inject(['settings'], (sctx) => {
|
||||
sctx.settings.register(NS, Config, { base: entry, applies: 'live' })
|
||||
})
|
||||
|
||||
ctx.inject(['tools'], (tctx) => {
|
||||
registerMindmapTools(
|
||||
tctx,
|
||||
getBase,
|
||||
(sessionId) => bridge.getActiveCanvas(sessionId),
|
||||
(sessionId, canvasId) => bridge.setActiveCanvas(sessionId, canvasId),
|
||||
(sessionId, info) => bridge.queueUiOpen(sessionId, info),
|
||||
)
|
||||
tctx.logger?.info?.('[dsh-mind-map] mindmap_* tools registered')
|
||||
})
|
||||
|
||||
ctx.inject(['skills'], (sctx) => {
|
||||
if (typeof sctx.skills?.register === 'function') {
|
||||
sctx.skills.register(makeMindmapSkill())
|
||||
}
|
||||
})
|
||||
|
||||
ctx.effect(() => {
|
||||
const off = ctx.on('session/event', (session, event) => {
|
||||
bridge.onSessionEvent(session, event)
|
||||
})
|
||||
return () => {
|
||||
if (typeof off === 'function') off()
|
||||
}
|
||||
}, 'dsh-mind-map: session mirror')
|
||||
|
||||
ctx.effect(() => {
|
||||
return registerWebRoute(ctx, async (req, res) => {
|
||||
try {
|
||||
const url = new URL(req.url || '/', 'http://dsh.local')
|
||||
const path = url.pathname.replace(API_PREFIX, '') || '/'
|
||||
const method = (req.method || 'GET').toUpperCase()
|
||||
|
||||
if (path === '/health' && method === 'GET') {
|
||||
writeJson(res, 200, {
|
||||
ok: true,
|
||||
plugin: name,
|
||||
server: runtime.status(),
|
||||
canvasUrl: mindMapBase,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
if (path === '/status' && method === 'GET') {
|
||||
const canvasId = url.searchParams.get('canvasId') || ''
|
||||
writeJson(res, 200, {
|
||||
...bridge.status(canvasId),
|
||||
server: runtime.status(),
|
||||
canvasUrl: mindMapBase,
|
||||
config: entry,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
if (path === '/prompt' && method === 'POST') {
|
||||
const body = await readJson(req)
|
||||
const result = await bridge.prompt(body.canvasId, body.text)
|
||||
writeJson(res, 200, result)
|
||||
return
|
||||
}
|
||||
|
||||
if (path === '/history' && method === 'GET') {
|
||||
const canvasId = url.searchParams.get('canvasId') || ''
|
||||
const hist = await bridge.history(canvasId)
|
||||
writeJson(res, 200, { ok: true, ...hist })
|
||||
return
|
||||
}
|
||||
|
||||
if (path === '/ensure' && method === 'POST') {
|
||||
const body = await readJson(req)
|
||||
const binding = await bridge.ensureBinding(body.canvasId)
|
||||
writeJson(res, 200, { ok: true, sessionId: binding.sessionId })
|
||||
return
|
||||
}
|
||||
|
||||
if (path === '/open' && method === 'POST') {
|
||||
await ensureServer(entry)
|
||||
writeJson(res, 200, { ok: true, url: mindMapBase })
|
||||
return
|
||||
}
|
||||
|
||||
if (path === '/canvases' && method === 'GET') {
|
||||
await ensureServer(entry)
|
||||
const r = await fetch(`${mindMapBase}/api/canvases`)
|
||||
const body = await r.json().catch(() => ({}))
|
||||
writeJson(res, r.status, body)
|
||||
return
|
||||
}
|
||||
|
||||
if (path === '/canvases' && method === 'POST') {
|
||||
await ensureServer(entry)
|
||||
const body = await readJson(req)
|
||||
const r = await fetch(`${mindMapBase}/api/canvases`, {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
title: body.title || '未命名画布',
|
||||
rootText: body.rootText,
|
||||
}),
|
||||
})
|
||||
const out = await r.json().catch(() => ({}))
|
||||
const sessionId = String(body.sessionId || '').trim()
|
||||
const canvasId = out?.canvas?.id
|
||||
if (r.ok && sessionId && canvasId) {
|
||||
bridge.setActiveCanvas(sessionId, canvasId)
|
||||
bridge.queueUiOpen(sessionId, {
|
||||
canvasId,
|
||||
title: out.canvas?.title || body.title || 'Mind Map',
|
||||
})
|
||||
}
|
||||
writeJson(res, r.status, { ...out, activeCanvasId: canvasId || null })
|
||||
return
|
||||
}
|
||||
|
||||
if (path.startsWith('/canvases/') && method === 'PATCH') {
|
||||
await ensureServer(entry)
|
||||
const id = path.slice('/canvases/'.length).split('/')[0]
|
||||
const body = await readJson(req)
|
||||
const r = await fetch(`${mindMapBase}/api/canvases/${encodeURIComponent(id)}`, {
|
||||
method: 'PATCH',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: JSON.stringify({ title: body.title }),
|
||||
})
|
||||
const out = await r.json().catch(() => ({}))
|
||||
writeJson(res, r.status, out)
|
||||
return
|
||||
}
|
||||
|
||||
if (path.startsWith('/canvases/') && method === 'DELETE') {
|
||||
await ensureServer(entry)
|
||||
const id = path.slice('/canvases/'.length).split('/')[0]
|
||||
const r = await fetch(`${mindMapBase}/api/canvases/${encodeURIComponent(id)}`, {
|
||||
method: 'DELETE',
|
||||
})
|
||||
const out = await r.json().catch(() => ({}))
|
||||
if (r.ok) bridge.clearActiveForCanvas(id)
|
||||
writeJson(res, r.status, out)
|
||||
return
|
||||
}
|
||||
|
||||
if (path === '/active' && method === 'GET') {
|
||||
const sessionId = url.searchParams.get('sessionId') || ''
|
||||
writeJson(res, 200, {
|
||||
ok: true,
|
||||
sessionId,
|
||||
canvasId: bridge.getActiveCanvas(sessionId),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
if (path === '/active' && method === 'POST') {
|
||||
const body = await readJson(req)
|
||||
const sessionId = String(body.sessionId || '').trim()
|
||||
const canvasId = body.canvasId == null ? '' : String(body.canvasId).trim()
|
||||
if (!sessionId) {
|
||||
writeJson(res, 400, { ok: false, error: 'sessionId_required' })
|
||||
return
|
||||
}
|
||||
const bound = bridge.setActiveCanvas(sessionId, canvasId)
|
||||
writeJson(res, 200, { ok: true, sessionId, canvasId: bound })
|
||||
return
|
||||
}
|
||||
|
||||
if (path === '/ui-open' && method === 'GET') {
|
||||
const sessionId = url.searchParams.get('sessionId') || ''
|
||||
const cmd = bridge.consumeUiOpen(sessionId)
|
||||
writeJson(res, 200, { ok: true, ...(cmd || {}) })
|
||||
return
|
||||
}
|
||||
|
||||
writeJson(res, 404, { ok: false, error: 'not_found' })
|
||||
} catch (e) {
|
||||
ctx.logger?.error?.('[dsh-mind-map] route error %s', e)
|
||||
writeJson(res, 500, { ok: false, message: String(e?.message || e) })
|
||||
}
|
||||
})
|
||||
}, 'dsh-mind-map: web routes')
|
||||
|
||||
ctx.effect(() => {
|
||||
let stopped = false
|
||||
const boot = async () => {
|
||||
if (stopped) return
|
||||
if (entry.autoStart && entry.enabled) {
|
||||
try {
|
||||
await ensureServer(entry)
|
||||
ctx.logger?.info?.('[dsh-mind-map] canvas at %s', mindMapBase)
|
||||
} catch (e) {
|
||||
ctx.logger?.error?.('[dsh-mind-map] failed to start server: %s', e?.message || e)
|
||||
}
|
||||
}
|
||||
}
|
||||
void boot()
|
||||
|
||||
const settings = tryGet(ctx, 'settings')
|
||||
let unwatch = null
|
||||
if (settings?.watch) {
|
||||
try {
|
||||
unwatch = settings.watch(NS, (next) => {
|
||||
Object.assign(entry, resolveConfig(next))
|
||||
if (entry.autoStart && entry.enabled) void ensureServer(entry)
|
||||
else if (!entry.enabled) void runtime.stop()
|
||||
})
|
||||
} catch {
|
||||
/* optional */
|
||||
}
|
||||
}
|
||||
|
||||
return () => {
|
||||
stopped = true
|
||||
if (typeof unwatch === 'function') unwatch()
|
||||
bridge.dispose()
|
||||
// Sync kill so DSH process exit does not leave canvas server orphaned.
|
||||
try {
|
||||
runtime.stop()
|
||||
} catch (e) {
|
||||
ctx.logger?.warn?.('[dsh-mind-map] stop failed: %s', e?.message || e)
|
||||
}
|
||||
}
|
||||
}, 'dsh-mind-map: lifecycle')
|
||||
}
|
||||
284
packages/dsh-plugin/lib/runtime.js
Normal file
284
packages/dsh-plugin/lib/runtime.js
Normal file
|
|
@ -0,0 +1,284 @@
|
|||
/**
|
||||
* Start / stop the mind-map Hono server as a child process.
|
||||
* Lifetime is tied to the DSH host: when the plugin disposes / process exits,
|
||||
* the canvas server is killed (including process tree on Windows).
|
||||
*/
|
||||
|
||||
import { spawn, execSync } from 'node:child_process'
|
||||
import { existsSync, statSync } from 'node:fs'
|
||||
import { join, dirname, relative } from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import { createRequire } from 'node:module'
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url))
|
||||
export const mindMapRoot = join(__dirname, '../../..')
|
||||
|
||||
function resolveTsxCliRel() {
|
||||
const candidates = [
|
||||
join(mindMapRoot, 'node_modules', 'tsx', 'dist', 'cli.mjs'),
|
||||
join(mindMapRoot, 'apps', 'server', 'node_modules', 'tsx', 'dist', 'cli.mjs'),
|
||||
]
|
||||
for (const abs of candidates) {
|
||||
if (existsSync(abs)) {
|
||||
return relative(mindMapRoot, abs).split('\\').join('/')
|
||||
}
|
||||
}
|
||||
try {
|
||||
const requireFromRoot = createRequire(join(mindMapRoot, 'package.json'))
|
||||
const abs = requireFromRoot.resolve('tsx/dist/cli.mjs')
|
||||
return relative(mindMapRoot, abs).split('\\').join('/')
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
function ensureWebDist() {
|
||||
const indexHtml = join(mindMapRoot, 'apps', 'web', 'dist', 'index.html')
|
||||
const watchSrc = [
|
||||
join(mindMapRoot, 'apps', 'web', 'src', 'TopologyCanvas.tsx'),
|
||||
join(mindMapRoot, 'apps', 'web', 'src', 'store.ts'),
|
||||
join(mindMapRoot, 'apps', 'web', 'src', 'App.tsx'),
|
||||
join(mindMapRoot, 'apps', 'web', 'src', 'drag-subtree.ts'),
|
||||
]
|
||||
let needsBuild = !existsSync(indexHtml)
|
||||
if (!needsBuild && existsSync(indexHtml)) {
|
||||
try {
|
||||
const distM = statSync(indexHtml).mtimeMs
|
||||
for (const src of watchSrc) {
|
||||
if (existsSync(src) && statSync(src).mtimeMs > distM) {
|
||||
needsBuild = true
|
||||
break
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
if (!needsBuild) return
|
||||
console.log('[dsh-mind-map] building web UI (apps/web/dist missing or stale)…')
|
||||
try {
|
||||
execSync('pnpm --filter @mind-map/web build', {
|
||||
cwd: mindMapRoot,
|
||||
stdio: 'inherit',
|
||||
env: process.env,
|
||||
windowsHide: true,
|
||||
})
|
||||
} catch (e) {
|
||||
throw new Error(
|
||||
`failed to build mind-map web UI: ${e?.message || e}. Run manually: pnpm --filter @mind-map/web build`,
|
||||
)
|
||||
}
|
||||
if (!existsSync(indexHtml)) {
|
||||
throw new Error('web build finished but apps/web/dist/index.html is still missing')
|
||||
}
|
||||
}
|
||||
|
||||
async function probeMindMap(url) {
|
||||
try {
|
||||
const res = await fetch(`${url}/health`, { signal: AbortSignal.timeout(1500) })
|
||||
if (!res.ok) return null
|
||||
const body = await res.json().catch(() => null)
|
||||
if (body && body.ok === true) return body
|
||||
} catch {
|
||||
/* not up */
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
/** Kill a PID and its children (Windows needs /T). */
|
||||
function killPidTree(pid) {
|
||||
if (!pid || !Number.isFinite(pid)) return
|
||||
try {
|
||||
if (process.platform === 'win32') {
|
||||
execSync(`taskkill /PID ${pid} /T /F`, { stdio: 'ignore' })
|
||||
} else {
|
||||
try {
|
||||
process.kill(pid, 'SIGTERM')
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
try {
|
||||
process.kill(pid, 'SIGKILL')
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
/* already gone */
|
||||
}
|
||||
}
|
||||
|
||||
/** Free listeners on bind:port so DSH can own a fresh child. */
|
||||
function killListenersOnPort(port) {
|
||||
try {
|
||||
if (process.platform === 'win32') {
|
||||
const out = execSync('netstat -ano', { encoding: 'utf8' })
|
||||
const pids = new Set()
|
||||
for (const line of out.split(/\r?\n/)) {
|
||||
if (!line.includes(`:${port}`) || !/LISTENING/i.test(line)) continue
|
||||
const parts = line.trim().split(/\s+/)
|
||||
const pid = Number(parts[parts.length - 1])
|
||||
if (pid > 0) pids.add(pid)
|
||||
}
|
||||
for (const pid of pids) {
|
||||
// Never kill ourselves
|
||||
if (pid === process.pid) continue
|
||||
console.log(`[dsh-mind-map] freeing port ${port} (pid ${pid})`)
|
||||
killPidTree(pid)
|
||||
}
|
||||
} else {
|
||||
try {
|
||||
execSync(`fuser -k ${port}/tcp`, { stdio: 'ignore' })
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
|
||||
export function createRuntime() {
|
||||
let child = null
|
||||
let last = { port: 17890, url: 'http://127.0.0.1:17890', running: false }
|
||||
let exitHooksInstalled = false
|
||||
|
||||
function killOwnedChildSync() {
|
||||
const c = child
|
||||
child = null
|
||||
if (!c) return
|
||||
killPidTree(c.pid)
|
||||
}
|
||||
|
||||
function installExitHooks() {
|
||||
if (exitHooksInstalled) return
|
||||
exitHooksInstalled = true
|
||||
const onExit = () => {
|
||||
killOwnedChildSync()
|
||||
}
|
||||
process.on('exit', onExit)
|
||||
for (const sig of ['SIGINT', 'SIGTERM', 'SIGHUP', 'SIGBREAK']) {
|
||||
try {
|
||||
process.on(sig, () => {
|
||||
killOwnedChildSync()
|
||||
})
|
||||
} catch {
|
||||
/* unsupported signal on this platform */
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function start({ port = 17890, bridgeUrl, internalToken, dataDir } = {}) {
|
||||
const url = `http://127.0.0.1:${port}`
|
||||
installExitHooks()
|
||||
|
||||
if (child && !child.killed) {
|
||||
last = { ...last, port, url, running: true }
|
||||
return last
|
||||
}
|
||||
|
||||
ensureWebDist()
|
||||
|
||||
// Stale orphan from a previous DSH run: kill it so we own the next child
|
||||
// and can tear it down when DSH exits.
|
||||
if (await probeMindMap(url)) {
|
||||
console.log(`[dsh-mind-map] stopping leftover server on ${url}`)
|
||||
killListenersOnPort(port)
|
||||
await new Promise((r) => setTimeout(r, 400))
|
||||
}
|
||||
|
||||
const entryAbs = join(mindMapRoot, 'apps/server/src/index.ts')
|
||||
const distAbs = join(mindMapRoot, 'apps/server/dist/index.js')
|
||||
const useTs = existsSync(entryAbs)
|
||||
const scriptAbs = useTs ? entryAbs : distAbs
|
||||
if (!existsSync(scriptAbs)) {
|
||||
throw new Error(`mind-map server entry not found under ${mindMapRoot}`)
|
||||
}
|
||||
|
||||
const env = {
|
||||
...process.env,
|
||||
PORT: String(port),
|
||||
BIND: '127.0.0.1',
|
||||
MINDMAP_DSH_BRIDGE: bridgeUrl || '',
|
||||
MINDMAP_INTERNAL_TOKEN: internalToken || '',
|
||||
MINDMAP_LLM_SOURCE: 'dsh',
|
||||
}
|
||||
if (dataDir) env.DATA_DIR = dataDir
|
||||
|
||||
const scriptRel = useTs ? 'apps/server/src/index.ts' : 'apps/server/dist/index.js'
|
||||
let args
|
||||
if (useTs) {
|
||||
const tsxCli = resolveTsxCliRel()
|
||||
if (!tsxCli) throw new Error('tsx not found; run pnpm install in mind-map')
|
||||
args = [tsxCli, scriptRel]
|
||||
} else {
|
||||
args = [scriptRel]
|
||||
}
|
||||
|
||||
child = spawn(process.execPath, args, {
|
||||
cwd: mindMapRoot,
|
||||
env,
|
||||
stdio: ['ignore', 'pipe', 'pipe'],
|
||||
windowsHide: true,
|
||||
// Keep attached to parent so closing DSH can reap it; do NOT detach.
|
||||
detached: false,
|
||||
})
|
||||
|
||||
let spawnError = null
|
||||
child.stdout?.on('data', (buf) => {
|
||||
const line = String(buf).trim()
|
||||
if (line) console.log(`[dsh-mind-map:server] ${line}`)
|
||||
})
|
||||
child.stderr?.on('data', (buf) => {
|
||||
const line = String(buf).trim()
|
||||
if (line) {
|
||||
console.error(`[dsh-mind-map:server] ${line}`)
|
||||
if (line.includes('EADDRINUSE')) spawnError = new Error(`port ${port} already in use`)
|
||||
}
|
||||
})
|
||||
child.on('exit', (code) => {
|
||||
console.warn(`[dsh-mind-map] server exited code=${code}`)
|
||||
if (child) child = null
|
||||
last = { ...last, running: false }
|
||||
})
|
||||
|
||||
const deadline = Date.now() + 20000
|
||||
let ok = false
|
||||
while (Date.now() < deadline) {
|
||||
if (!child) break
|
||||
if (await probeMindMap(url)) {
|
||||
ok = true
|
||||
break
|
||||
}
|
||||
await new Promise((r) => setTimeout(r, 200))
|
||||
}
|
||||
|
||||
if (!ok) {
|
||||
killOwnedChildSync()
|
||||
killListenersOnPort(port)
|
||||
throw spawnError || new Error(`mind-map server failed to become healthy on ${url}`)
|
||||
}
|
||||
|
||||
last = { port, url, running: true }
|
||||
return last
|
||||
}
|
||||
|
||||
async function stop() {
|
||||
const port = last.port
|
||||
killOwnedChildSync()
|
||||
// Belt-and-suspenders: anything still listening on our canvas port goes down with DSH.
|
||||
killListenersOnPort(port)
|
||||
await new Promise((r) => setTimeout(r, 200))
|
||||
last = { ...last, running: false }
|
||||
}
|
||||
|
||||
function status() {
|
||||
return {
|
||||
...last,
|
||||
running: Boolean(child && !child.killed),
|
||||
pid: child?.pid || null,
|
||||
}
|
||||
}
|
||||
|
||||
return { start, stop, status, mindMapRoot }
|
||||
}
|
||||
444
packages/dsh-plugin/lib/session-bridge.js
Normal file
444
packages/dsh-plugin/lib/session-bridge.js
Normal file
|
|
@ -0,0 +1,444 @@
|
|||
/**
|
||||
* 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<string, { sessionId: string, handle: any, agent: any }>} */
|
||||
const byCanvas = new Map()
|
||||
/** @type {Map<string, string>} mirror agent session → canvas */
|
||||
const canvasBySession = new Map()
|
||||
/** @type {Map<string, string>} DSH chat session → currently displayed canvas */
|
||||
const activeBySession = new Map()
|
||||
/** @type {Map<string, { canvasId: string, title: string, at: number }>} */
|
||||
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,
|
||||
}
|
||||
}
|
||||
717
packages/dsh-plugin/lib/tools.js
Normal file
717
packages/dsh-plugin/lib/tools.js
Normal file
|
|
@ -0,0 +1,717 @@
|
|||
/**
|
||||
* Register mindmap_* tools that call the local mind-map HTTP API.
|
||||
* Plain ToolDefinition objects (same style as dsh-ops-cron).
|
||||
*/
|
||||
|
||||
function text(value) {
|
||||
return [{ type: 'text', text: String(value ?? '') }]
|
||||
}
|
||||
|
||||
function jsonText(value) {
|
||||
return text(typeof value === 'string' ? value : JSON.stringify(value, null, 0))
|
||||
}
|
||||
|
||||
async function callTool(getBase, name, args) {
|
||||
const base = getBase?.()
|
||||
if (!base) throw new Error('mind-map server is not running')
|
||||
const res = await fetch(`${base}/api/tools/${name}`, {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: JSON.stringify(args || {}),
|
||||
})
|
||||
const body = await res.json().catch(() => ({}))
|
||||
if (!res.ok) {
|
||||
const err = new Error(body.message || body.error || `HTTP ${res.status}`)
|
||||
err.code = body.error || 'TOOL_HTTP'
|
||||
throw err
|
||||
}
|
||||
return body
|
||||
}
|
||||
|
||||
const OBJECT = {
|
||||
type: 'object',
|
||||
additionalProperties: true,
|
||||
}
|
||||
|
||||
const OP_ITEM = {
|
||||
type: 'object',
|
||||
additionalProperties: true,
|
||||
description:
|
||||
'Graph op. REQUIRED discriminator field is `type` (NOT `op`). Examples: {"type":"create_node","text":"贾宝玉","parentId":"..."}, {"type":"link","from":"...","to":"...","kind":"relation","label":"木石前盟"}, {"type":"set_pinned","nodeId":"...","pinned":true,"pos":{"x":120,"y":-80}}',
|
||||
properties: {
|
||||
type: {
|
||||
type: 'string',
|
||||
description:
|
||||
'Op kind: create_node | update_text | link | unlink | set_pinned | set_focus | set_collapsed | set_style_preset | set_edge_label | set_prefs | move_node | delete_node | …',
|
||||
},
|
||||
},
|
||||
required: ['type'],
|
||||
}
|
||||
|
||||
export function mindmapToolDefinitions(getBase, getCanvasIdForSession, setActiveCanvas, queueUiOpen) {
|
||||
/** Tools that must open/switch the right-panel canvas. */
|
||||
const HARD_OPEN_UI = new Set(['mindmap_create_canvas', 'mindmap_open_canvas'])
|
||||
/** Soft reveal: bind session + open tab only if needed (client dedupes). */
|
||||
const SOFT_REVEAL_UI = new Set([
|
||||
'mindmap_query',
|
||||
'mindmap_find',
|
||||
'mindmap_find_and_focus',
|
||||
'mindmap_get_node',
|
||||
'mindmap_get_edge',
|
||||
'mindmap_highlight',
|
||||
'mindmap_get_spatial_context',
|
||||
'mindmap_get_subgraph',
|
||||
'mindmap_focus',
|
||||
'mindmap_apply_direct',
|
||||
'mindmap_propose',
|
||||
'mindmap_accept_proposal',
|
||||
'mindmap_export',
|
||||
])
|
||||
|
||||
const run = (name) => async (args, exec) => {
|
||||
const sessionId = String(
|
||||
exec?.agent?.session?.id ||
|
||||
exec?.agent?.session?.header?.id ||
|
||||
exec?.sessionId ||
|
||||
'',
|
||||
).trim()
|
||||
const bound =
|
||||
typeof getCanvasIdForSession === 'function' && sessionId
|
||||
? getCanvasIdForSession(sessionId)
|
||||
: null
|
||||
const payload = {
|
||||
...(args || {}),
|
||||
canvasId: args?.canvasId || bound || undefined,
|
||||
sessionId: sessionId || undefined,
|
||||
}
|
||||
const body = await callTool(getBase, name, payload)
|
||||
|
||||
if (name === 'mindmap_delete_canvas' && sessionId && typeof setActiveCanvas === 'function') {
|
||||
const deleted = args?.canvasId || bound
|
||||
const still = getCanvasIdForSession?.(sessionId)
|
||||
if (deleted && still === deleted) setActiveCanvas(sessionId, null)
|
||||
}
|
||||
|
||||
const cid =
|
||||
body?.canvas?.id ||
|
||||
payload.canvasId ||
|
||||
(typeof getCanvasIdForSession === 'function' && sessionId
|
||||
? getCanvasIdForSession(sessionId)
|
||||
: null)
|
||||
|
||||
if (body && body.ok !== false && sessionId && cid) {
|
||||
if (HARD_OPEN_UI.has(name) && typeof queueUiOpen === 'function') {
|
||||
queueUiOpen(sessionId, {
|
||||
canvasId: cid,
|
||||
title: body?.canvas?.title || body?.title || args?.title || 'Mind Map',
|
||||
})
|
||||
} else if (SOFT_REVEAL_UI.has(name)) {
|
||||
// Bind session; open UI at most via queue (client skips if already live).
|
||||
if (typeof setActiveCanvas === 'function') setActiveCanvas(sessionId, cid)
|
||||
if (typeof queueUiOpen === 'function') {
|
||||
queueUiOpen(sessionId, {
|
||||
canvasId: cid,
|
||||
title: body?.canvas?.title || body?.title || args?.title || 'Mind Map',
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return body
|
||||
}
|
||||
|
||||
return [
|
||||
{
|
||||
name: 'mindmap_get_schema',
|
||||
description:
|
||||
'Return mind-map op schema: valid `type` values, examples (create_node / link / set_pinned), and tool list. Call when unsure about op shape.',
|
||||
parameters: { type: 'object', additionalProperties: false, properties: {} },
|
||||
output: { schema: OBJECT, render: (_a, v) => jsonText(v) },
|
||||
execute: run('mindmap_get_schema'),
|
||||
},
|
||||
{
|
||||
name: 'mindmap_list_canvases',
|
||||
description: 'List mind-map canvases (id + title).',
|
||||
parameters: { type: 'object', additionalProperties: false, properties: {} },
|
||||
output: { schema: OBJECT, render: (_a, v) => jsonText(v) },
|
||||
execute: run('mindmap_list_canvases'),
|
||||
},
|
||||
{
|
||||
name: 'mindmap_create_canvas',
|
||||
description:
|
||||
'Create a brand-new mind-map canvas (new id; does NOT overwrite existing canvases), bind it to this chat session, and open it in the 思维导图 tab. Canvas starts visually empty. Top-level nodes: omit parentId.',
|
||||
parameters: {
|
||||
type: 'object',
|
||||
additionalProperties: false,
|
||||
properties: {
|
||||
title: { type: 'string', description: 'Canvas title, e.g. 红楼梦人物关系' },
|
||||
rootText: {
|
||||
type: 'string',
|
||||
description:
|
||||
'Optional internal label / title sync only — there is no visible root node. Prefer title alone.',
|
||||
},
|
||||
},
|
||||
},
|
||||
output: { schema: OBJECT, render: (_a, v) => jsonText(v) },
|
||||
execute: run('mindmap_create_canvas'),
|
||||
},
|
||||
{
|
||||
name: 'mindmap_open_canvas',
|
||||
description:
|
||||
'Open an existing canvas in the 思维导图 tab and bind it as the edit target for this session.',
|
||||
parameters: {
|
||||
type: 'object',
|
||||
additionalProperties: false,
|
||||
properties: {
|
||||
canvasId: { type: 'string', description: 'Canvas id from mindmap_list_canvases' },
|
||||
},
|
||||
required: ['canvasId'],
|
||||
},
|
||||
output: { schema: OBJECT, render: (_a, v) => jsonText(v) },
|
||||
execute: run('mindmap_open_canvas'),
|
||||
},
|
||||
{
|
||||
name: 'mindmap_delete_canvas',
|
||||
description:
|
||||
'Permanently delete a canvas (hard delete). Omit canvasId to delete the currently open canvas.',
|
||||
parameters: {
|
||||
type: 'object',
|
||||
additionalProperties: false,
|
||||
properties: {
|
||||
canvasId: { type: 'string' },
|
||||
},
|
||||
},
|
||||
output: { schema: OBJECT, render: (_a, v) => jsonText(v) },
|
||||
execute: run('mindmap_delete_canvas'),
|
||||
},
|
||||
{
|
||||
name: 'mindmap_get_spatial_context',
|
||||
description:
|
||||
'Get spatial harness context for the active mind-map canvas. Always call before inventing node ids.',
|
||||
parameters: {
|
||||
type: 'object',
|
||||
additionalProperties: false,
|
||||
properties: {
|
||||
canvasId: { type: 'string', description: 'Omit to use the open/bound canvas.' },
|
||||
},
|
||||
},
|
||||
output: { schema: OBJECT, render: (_a, v) => jsonText(v) },
|
||||
execute: run('mindmap_get_spatial_context'),
|
||||
},
|
||||
{
|
||||
name: 'mindmap_find',
|
||||
description:
|
||||
'Find nodes or edges. searchScope: text|note|tags|description|edge_label|edge_note|all (default text). Edge scopes return {kind:"edge", edgeId, fromText, toText, label}. If ambiguous, ask the user — do not pick silently.',
|
||||
parameters: {
|
||||
type: 'object',
|
||||
additionalProperties: false,
|
||||
properties: {
|
||||
query: { type: 'string' },
|
||||
searchScope: {
|
||||
type: 'string',
|
||||
enum: [
|
||||
'text',
|
||||
'note',
|
||||
'tags',
|
||||
'description',
|
||||
'edge_label',
|
||||
'edge_note',
|
||||
'all',
|
||||
],
|
||||
description:
|
||||
'Default text. Use edge_label/edge_note for edges; all includes nodes + edges.',
|
||||
},
|
||||
canvasId: { type: 'string' },
|
||||
},
|
||||
required: ['query'],
|
||||
},
|
||||
output: { schema: OBJECT, render: (_a, v) => jsonText(v) },
|
||||
execute: run('mindmap_find'),
|
||||
},
|
||||
{
|
||||
name: 'mindmap_find_and_focus',
|
||||
description:
|
||||
'Find then focus+highlight the best unique match. Edge hits highlight endpoints + edge. If ambiguous, returns candidates without focusing.',
|
||||
parameters: {
|
||||
type: 'object',
|
||||
additionalProperties: false,
|
||||
properties: {
|
||||
query: { type: 'string' },
|
||||
searchScope: {
|
||||
type: 'string',
|
||||
enum: [
|
||||
'text',
|
||||
'note',
|
||||
'tags',
|
||||
'description',
|
||||
'edge_label',
|
||||
'edge_note',
|
||||
'all',
|
||||
],
|
||||
},
|
||||
canvasId: { type: 'string' },
|
||||
},
|
||||
required: ['query'],
|
||||
},
|
||||
output: { schema: OBJECT, render: (_a, v) => jsonText(v) },
|
||||
execute: run('mindmap_find_and_focus'),
|
||||
},
|
||||
{
|
||||
name: 'mindmap_export',
|
||||
description:
|
||||
'Export canvas as json | md | svg. format=png also returns SVG (vector) for agents; use web UI for raster PNG.',
|
||||
parameters: {
|
||||
type: 'object',
|
||||
additionalProperties: false,
|
||||
properties: {
|
||||
format: { type: 'string', enum: ['json', 'md', 'svg', 'png'] },
|
||||
canvasId: { type: 'string' },
|
||||
},
|
||||
},
|
||||
output: { schema: OBJECT, render: (_a, v) => jsonText(v) },
|
||||
execute: run('mindmap_export'),
|
||||
},
|
||||
{
|
||||
name: 'mindmap_get_subgraph',
|
||||
description:
|
||||
'Read a subgraph around a node (depth <= 3). rootId: id|@alias|title. edgeKinds: hierarchy (default, tree children) | relation | all — same as mindmap_query. Use relation/all for relationship networks.',
|
||||
parameters: {
|
||||
type: 'object',
|
||||
additionalProperties: false,
|
||||
properties: {
|
||||
rootId: {
|
||||
type: 'string',
|
||||
description: 'Node id, @alias, or unique title',
|
||||
},
|
||||
depth: { type: 'number' },
|
||||
edgeKinds: {
|
||||
type: 'string',
|
||||
enum: ['hierarchy', 'relation', 'all'],
|
||||
description:
|
||||
'hierarchy = primary-parent children (default); relation|all = undirected neighborhood',
|
||||
},
|
||||
canvasId: { type: 'string' },
|
||||
},
|
||||
},
|
||||
output: { schema: OBJECT, render: (_a, v) => jsonText(v) },
|
||||
execute: run('mindmap_get_subgraph'),
|
||||
},
|
||||
{
|
||||
name: 'mindmap_query',
|
||||
description:
|
||||
'Deterministic graph Q&A primitives (no LLM inventing). op: neighbors|path|stats|orphans|missing_meta. Node refs: id | @alias | unique title. path returns paths[].edges with meta; stats includes longestPath + mostConnected. If empty, say the graph has no such info.',
|
||||
parameters: {
|
||||
type: 'object',
|
||||
additionalProperties: false,
|
||||
properties: {
|
||||
op: {
|
||||
type: 'string',
|
||||
enum: ['neighbors', 'path', 'stats', 'orphans', 'missing_meta'],
|
||||
},
|
||||
node: { type: 'string', description: 'For neighbors: id|@alias|title' },
|
||||
nodeId: { type: 'string' },
|
||||
from: { type: 'string', description: 'For path: start node' },
|
||||
to: { type: 'string', description: 'For path: end node' },
|
||||
depth: { type: 'number' },
|
||||
maxDepth: { type: 'number' },
|
||||
maxHops: {
|
||||
type: 'number',
|
||||
description:
|
||||
'For path: max edges in a path (default 8). Do NOT pass depth:1 — that was neighbors-only.',
|
||||
},
|
||||
throughRelationNodes: {
|
||||
type: 'boolean',
|
||||
description:
|
||||
'For path: allow multi-hop via intermediate nodes (default true). false = direct edge only.',
|
||||
},
|
||||
maxPaths: { type: 'number' },
|
||||
direction: {
|
||||
type: 'string',
|
||||
enum: ['out', 'in', 'both'],
|
||||
description:
|
||||
'neighbors default both; path default both when throughRelationNodes, else out',
|
||||
},
|
||||
edgeKinds: {
|
||||
type: 'string',
|
||||
enum: ['all', 'hierarchy', 'relation'],
|
||||
},
|
||||
canvasId: { type: 'string' },
|
||||
},
|
||||
required: ['op'],
|
||||
},
|
||||
output: { schema: OBJECT, render: (_a, v) => jsonText(v) },
|
||||
execute: run('mindmap_query'),
|
||||
},
|
||||
{
|
||||
name: 'mindmap_get_node',
|
||||
description:
|
||||
'Read one node. Default returns id/text/alias/function + full description. Use after Q&A when user asks for details on a cited node.',
|
||||
parameters: {
|
||||
type: 'object',
|
||||
additionalProperties: false,
|
||||
properties: {
|
||||
nodeId: { type: 'string', description: 'id | @alias | unique title' },
|
||||
node: { type: 'string' },
|
||||
fields: {
|
||||
type: 'array',
|
||||
items: {
|
||||
type: 'string',
|
||||
enum: ['all', 'description', 'note', 'tags', 'links'],
|
||||
},
|
||||
},
|
||||
canvasId: { type: 'string' },
|
||||
},
|
||||
required: ['nodeId'],
|
||||
},
|
||||
output: { schema: OBJECT, render: (_a, v) => jsonText(v) },
|
||||
execute: run('mindmap_get_node'),
|
||||
},
|
||||
{
|
||||
name: 'mindmap_get_edge',
|
||||
description:
|
||||
'Read one edge by id. Returns from/to texts + label/direction/weight/lineStyle/note. fields: all|label|meta.',
|
||||
parameters: {
|
||||
type: 'object',
|
||||
additionalProperties: false,
|
||||
properties: {
|
||||
edgeId: { type: 'string' },
|
||||
fields: {
|
||||
type: 'array',
|
||||
items: { type: 'string', enum: ['all', 'label', 'meta'] },
|
||||
},
|
||||
canvasId: { type: 'string' },
|
||||
},
|
||||
required: ['edgeId'],
|
||||
},
|
||||
output: { schema: OBJECT, render: (_a, v) => jsonText(v) },
|
||||
execute: run('mindmap_get_edge'),
|
||||
},
|
||||
{
|
||||
name: 'mindmap_highlight',
|
||||
description:
|
||||
'Flash nodes and/or edges on the open canvas (no graph mutation). Use after path/neighbors Q&A. Optional style + ttlMs (default 5000).',
|
||||
parameters: {
|
||||
type: 'object',
|
||||
additionalProperties: false,
|
||||
properties: {
|
||||
nodeIds: {
|
||||
type: 'array',
|
||||
items: { type: 'string' },
|
||||
description: 'id | @alias | title',
|
||||
},
|
||||
edgeIds: { type: 'array', items: { type: 'string' } },
|
||||
style: {
|
||||
type: 'object',
|
||||
additionalProperties: false,
|
||||
properties: {
|
||||
nodeColor: { type: 'string' },
|
||||
nodeGlow: { type: 'boolean' },
|
||||
edgeWidth: { type: 'number' },
|
||||
edgeColor: { type: 'string' },
|
||||
},
|
||||
},
|
||||
ttlMs: { type: 'number', description: 'Auto-clear ms (500–60000)' },
|
||||
canvasId: { type: 'string' },
|
||||
},
|
||||
},
|
||||
output: { schema: OBJECT, render: (_a, v) => jsonText(v) },
|
||||
execute: run('mindmap_highlight'),
|
||||
},
|
||||
{
|
||||
name: 'mindmap_apply_direct',
|
||||
description:
|
||||
'Apply low-risk graph ops immediately. Prefer batch_create for trees; relations[] for inline edges; auto_fit after large builds. EACH op MUST have field `type`. Response includes nodeIdMap. Set preview:true for dry-run. High-risk delete_node/move_node need mindmap_propose.',
|
||||
parameters: {
|
||||
type: 'object',
|
||||
additionalProperties: false,
|
||||
properties: {
|
||||
ops: { type: 'array', items: OP_ITEM },
|
||||
summary: { type: 'string' },
|
||||
canvasId: { type: 'string' },
|
||||
preview: {
|
||||
type: 'boolean',
|
||||
description: 'If true, simulate apply + layout without persisting',
|
||||
},
|
||||
},
|
||||
required: ['ops'],
|
||||
},
|
||||
output: { schema: OBJECT, render: (_a, v) => jsonText(v) },
|
||||
execute: run('mindmap_apply_direct'),
|
||||
},
|
||||
{
|
||||
name: 'mindmap_propose',
|
||||
description:
|
||||
'Submit a validated proposal (ops must use `type`). Then mindmap_accept_proposal({proposalId}) to apply. Failures return invalid_op with details — not a silent stale.',
|
||||
parameters: {
|
||||
type: 'object',
|
||||
additionalProperties: false,
|
||||
properties: {
|
||||
ops: { type: 'array', items: OP_ITEM },
|
||||
rationale: { type: 'string' },
|
||||
canvasId: { type: 'string' },
|
||||
},
|
||||
required: ['ops', 'rationale'],
|
||||
},
|
||||
output: { schema: OBJECT, render: (_a, v) => jsonText(v) },
|
||||
execute: run('mindmap_propose'),
|
||||
},
|
||||
{
|
||||
name: 'mindmap_accept_proposal',
|
||||
description: 'Accept a pending proposal by id returned from mindmap_propose.',
|
||||
parameters: {
|
||||
type: 'object',
|
||||
additionalProperties: false,
|
||||
properties: {
|
||||
proposalId: { type: 'string' },
|
||||
canvasId: { type: 'string' },
|
||||
},
|
||||
required: ['proposalId'],
|
||||
},
|
||||
output: { schema: OBJECT, render: (_a, v) => jsonText(v) },
|
||||
execute: run('mindmap_accept_proposal'),
|
||||
},
|
||||
{
|
||||
name: 'mindmap_reject_proposal',
|
||||
description: 'Reject a pending proposal by id.',
|
||||
parameters: {
|
||||
type: 'object',
|
||||
additionalProperties: false,
|
||||
properties: {
|
||||
proposalId: { type: 'string' },
|
||||
},
|
||||
required: ['proposalId'],
|
||||
},
|
||||
output: { schema: OBJECT, render: (_a, v) => jsonText(v) },
|
||||
execute: run('mindmap_reject_proposal'),
|
||||
},
|
||||
{
|
||||
name: 'mindmap_focus',
|
||||
description: 'Move visual focus to a node.',
|
||||
parameters: {
|
||||
type: 'object',
|
||||
additionalProperties: false,
|
||||
properties: {
|
||||
nodeId: { type: 'string' },
|
||||
canvasId: { type: 'string' },
|
||||
},
|
||||
required: ['nodeId'],
|
||||
},
|
||||
output: { schema: OBJECT, render: (_a, v) => jsonText(v) },
|
||||
execute: run('mindmap_focus'),
|
||||
},
|
||||
{
|
||||
name: 'mindmap_undo',
|
||||
description:
|
||||
'Undo latest change set (or specific changeSetId). Creates an undo companion that mindmap_redo can reverse.',
|
||||
parameters: {
|
||||
type: 'object',
|
||||
additionalProperties: false,
|
||||
properties: {
|
||||
changeSetId: { type: 'string' },
|
||||
canvasId: { type: 'string' },
|
||||
},
|
||||
},
|
||||
output: { schema: OBJECT, render: (_a, v) => jsonText(v) },
|
||||
execute: run('mindmap_undo'),
|
||||
},
|
||||
{
|
||||
name: 'mindmap_redo',
|
||||
description:
|
||||
'Redo after undo: undoes the latest undo-companion change set on the canvas.',
|
||||
parameters: {
|
||||
type: 'object',
|
||||
additionalProperties: false,
|
||||
properties: {
|
||||
canvasId: { type: 'string' },
|
||||
},
|
||||
},
|
||||
output: { schema: OBJECT, render: (_a, v) => jsonText(v) },
|
||||
execute: run('mindmap_redo'),
|
||||
},
|
||||
{
|
||||
name: 'mindmap_history',
|
||||
description:
|
||||
'List recent change sets + named snapshots; shows latest undoable/redoable ids.',
|
||||
parameters: {
|
||||
type: 'object',
|
||||
additionalProperties: false,
|
||||
properties: {
|
||||
canvasId: { type: 'string' },
|
||||
limit: { type: 'number' },
|
||||
},
|
||||
},
|
||||
output: { schema: OBJECT, render: (_a, v) => jsonText(v) },
|
||||
execute: run('mindmap_history'),
|
||||
},
|
||||
{
|
||||
name: 'mindmap_snapshot',
|
||||
description:
|
||||
'Create a named canvas snapshot, or action:"list" to list snapshots.',
|
||||
parameters: {
|
||||
type: 'object',
|
||||
additionalProperties: false,
|
||||
properties: {
|
||||
canvasId: { type: 'string' },
|
||||
name: { type: 'string' },
|
||||
action: { type: 'string', enum: ['create', 'list'] },
|
||||
limit: { type: 'number' },
|
||||
},
|
||||
},
|
||||
output: { schema: OBJECT, render: (_a, v) => jsonText(v) },
|
||||
execute: run('mindmap_snapshot'),
|
||||
},
|
||||
{
|
||||
name: 'mindmap_restore_snapshot',
|
||||
description: 'Restore canvas to a prior snapshot id from mindmap_snapshot / mindmap_history.',
|
||||
parameters: {
|
||||
type: 'object',
|
||||
additionalProperties: false,
|
||||
properties: {
|
||||
snapshotId: { type: 'string' },
|
||||
canvasId: { type: 'string' },
|
||||
},
|
||||
required: ['snapshotId'],
|
||||
},
|
||||
output: { schema: OBJECT, render: (_a, v) => jsonText(v) },
|
||||
execute: run('mindmap_restore_snapshot'),
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
export function registerMindmapTools(
|
||||
ctx,
|
||||
getBase,
|
||||
getCanvasIdForSession,
|
||||
setActiveCanvas,
|
||||
queueUiOpen,
|
||||
) {
|
||||
const tools = ctx?.tools
|
||||
if (!tools || typeof tools.register !== 'function') return () => {}
|
||||
const offs = mindmapToolDefinitions(
|
||||
getBase,
|
||||
getCanvasIdForSession,
|
||||
setActiveCanvas,
|
||||
queueUiOpen,
|
||||
).map((def) => tools.register(def))
|
||||
return () => {
|
||||
for (const off of offs) {
|
||||
if (typeof off === 'function') off()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function makeMindmapSkill() {
|
||||
return {
|
||||
name: 'mind-map',
|
||||
description:
|
||||
'AI 无限画布思维导图:右侧「思维导图」Tab;用 mindmap_* 建图/改图。Ops 必须用 type 字段。',
|
||||
whenToUse:
|
||||
'User asks for 思维导图 / 关系图 / 梳理人物或家族关系 / Mind Map, OR asks questions about an existing map (路径/依赖/邻居/统计/缺描述).',
|
||||
source: 'runtime',
|
||||
provider: 'runtime',
|
||||
content: `Mind Map workflow (dsh-mind-map):
|
||||
|
||||
CRITICAL — op shape:
|
||||
- Every graph mutation item MUST use discriminator field **type** (NOT op / operation).
|
||||
- If unsure, call mindmap_get_schema first.
|
||||
- Prefer **batch_create** for trees (up to 200 nodes) instead of many create_node rounds.
|
||||
- Valid types include: batch_create, batch_link, create_node, update_text, link, unlink, set_pinned, set_focus, set_collapsed, auto_fit, set_style_preset, set_edge_label, delete_node, move_node, …
|
||||
|
||||
Examples:
|
||||
{"type":"batch_create","nodes":[{"text":"数通","alias":"datacom"},{"text":"交换机","alias":"sw","parentId":"@datacom"},{"text":"AIOps","alias":"aiops","parentId":"@datacom","relations":[{"to":"@restart","label":"触发","kind":"relation"}],"description":{"function":"根因诊断"}},{"text":"自动重启","alias":"restart","parentId":"@datacom"}]}
|
||||
{"type":"batch_link","edges":[{"from":"@daiyu","to":"@baoyu","kind":"relation","label":"知己"},{"from":"@baoyu","to":"@baochai","kind":"relation","label":"金玉良缘"}]}
|
||||
{"type":"auto_fit","padding":50}
|
||||
{"type":"create_node","text":"贾宝玉"} ← top-level (omit parentId); appears as independent node
|
||||
{"type":"create_node","text":"贾政","parentId":"<贾宝玉的可见父节点或主题id>"}
|
||||
{"type":"create_node","text":"AIOps","alias":"aiops","relations":[{"to":"@restart","kind":"relation","label":"触发"}]}
|
||||
{"type":"link","from":"<黛玉Id>","to":"<宝玉Id>","kind":"relation","label":"木石前盟"}
|
||||
{"type":"update_text","nodeId":"<id>","text":"新文案"}
|
||||
{"type":"set_pinned","nodeId":"<id>","pinned":true,"pos":{"x":120,"y":-80}}
|
||||
{"type":"update_node_meta","nodeId":"@aiops","patch":{"description":{"owner":"SRE","sla":"30s"},"note":"自由文本"}}
|
||||
{"type":"set_style_preset","nodeId":"@baoyu","stylePreset":"decision"} ← field is stylePreset (not preset)
|
||||
{"type":"unlink","from":"@daiyu","to":"@baoyu","kind":"relation"} ← or edgeId
|
||||
{"type":"restore_node","nodeId":"<softDeletedId>"} ← same id after delete_node; also @alias/title of deleted nodes
|
||||
|
||||
There is NO visible root node. Canvas title is metadata only. Internally top-level nodes hang under a hidden anchor — agents must never mention or target a root id.
|
||||
|
||||
When the user asks to build a map (e.g. 「输出红楼梦人物关系图」):
|
||||
1. mindmap_create_canvas({ title: "红楼梦人物关系" }) — ALWAYS creates a NEW canvas id (never overwrites an old one). Opens the right tab on that new canvas.
|
||||
2. mindmap_get_spatial_context — orient on the NEW empty canvas
|
||||
3. mindmap_apply_direct({ ops: [ {type:"batch_create", nodes:[...]}, {type:"auto_fit"} ] })
|
||||
4. Keep adding in small batches; never dump a full tree JSON overwrite
|
||||
|
||||
If the user asks for another topic later: call mindmap_create_canvas again with the new title. Do NOT reuse the previous canvas unless they explicitly say to continue the same map.
|
||||
mindmap_list_canvases shows all canvases; mindmap_open_canvas switches the edit target.
|
||||
|
||||
Hierarchy vs relation:
|
||||
- omit parentId = top-level theme/person (siblings look unrelated, which is intended)
|
||||
- parentId = nest under a visible node
|
||||
- link kind:"relation" = cross links (人物关系、姻亲等)
|
||||
- create_node.relations / batch_create[].relations = outgoing graph edges in the same op (not URL bookmarks; those are links[])
|
||||
|
||||
Layout:
|
||||
- Default positions come from auto layout.
|
||||
- After large batch_create, include {type:"auto_fit"} so the camera frames the map.
|
||||
- To place manually / avoid overwrite: set_pinned + pos, or create_node with pos (auto-pins).
|
||||
|
||||
Propose/accept:
|
||||
- mindmap_propose validates ops first; then mindmap_accept_proposal({proposalId}).
|
||||
- Apply failures return error invalid_op (proposal stays pending) — not a fake proposal_stale.
|
||||
- proposal_stale only means rev drifted > 50 or proposal already finished / missing after restart.
|
||||
|
||||
Other tools:
|
||||
- mindmap_list_canvases / mindmap_open_canvas / mindmap_delete_canvas
|
||||
- Query/find/highlight also auto-open the 思维导图 tab on the bound canvas (same as create/open)
|
||||
- mindmap_find — searchScope text|note|tags|description|edge_label|edge_note|all; if ambiguous, ask then mindmap_focus / mindmap_highlight with quickActions[i].args
|
||||
- mindmap_find_and_focus — unique hit → focus + highlight flash (edges → highlight endpoints)
|
||||
- mindmap_query — graph Q&A: neighbors|path|stats|orphans|missing_meta (deterministic; cite returned nodes)
|
||||
- mindmap_get_node / mindmap_get_edge — details for follow-up
|
||||
- mindmap_highlight — flash path (nodeIds + edgeIds + optional ttlMs/style)
|
||||
- mindmap_export — format json|md|svg (png→svg for agents; UI has raster PNG)
|
||||
- mindmap_focus, mindmap_undo, mindmap_redo, mindmap_history
|
||||
- mindmap_snapshot / mindmap_restore_snapshot — named checkpoints
|
||||
- mindmap_apply_direct({ preview:true }) — dry-run layout + overlaps, no persist
|
||||
- mindmap_get_subgraph, mindmap_get_schema
|
||||
- get_subgraph: edgeKinds hierarchy (default tree) | relation | all — use relation/all for 关系网络 around a leaf
|
||||
|
||||
Graph Q&A workflow:
|
||||
1. Resolve entities with mindmap_find or @alias (edge labels: searchScope edge_label)
|
||||
2. Call mindmap_query (never guess paths/relations from memory)
|
||||
3. If empty/message says no match → answer "图中没有相关信息" — do NOT invent
|
||||
4. Answer with node names + paths[].edges (label/weight/note); mention citedNodeIds
|
||||
5. Follow-up details → mindmap_get_node / mindmap_get_edge
|
||||
6. Visualize → mindmap_highlight({ nodeIds, edgeIds }) after path/neighbors
|
||||
|
||||
Examples:
|
||||
mindmap_find({ query:"高置信", searchScope:"edge_label" })
|
||||
mindmap_query({ op:"neighbors", node:"@aiops", direction:"out", depth:1 })
|
||||
mindmap_query({ op:"path", from:"@detect", to:"@knowledge_write", throughRelationNodes:true, maxHops:5, edgeKinds:"relation" })
|
||||
mindmap_query({ op:"path", from:"@detect", to:"@aiops", throughRelationNodes:false }) // direct edge only
|
||||
mindmap_query({ op:"stats" }) // connectedComponents + longestPath/mostConnected
|
||||
mindmap_query({ op:"stats", edgeKinds:"relation" }) // diameter on relation edges only
|
||||
mindmap_query({ op:"missing_meta" })
|
||||
mindmap_query({ op:"orphans" })
|
||||
mindmap_highlight({ nodeIds:["@detect","@aiops"], edgeIds:["e_…"], ttlMs:5000 })
|
||||
|
||||
Node refs (P0 — prefer these over raw ids):
|
||||
{"type":"create_node","text":"AIOps","alias":"aiops"}
|
||||
{"type":"create_node","text":"重启","parentId":"@aiops"}
|
||||
{"type":"link","from":"@aiops","to":"重启","kind":"relation","label":"诊断→重启"}
|
||||
{"type":"link","fromText":"AIOps","toText":"重启","kind":"relation"}
|
||||
{"type":"batch_link","edges":[{"from":"@daiyu","to":"@baoyu","kind":"relation","label":"知己"},{"from":"@baoyu","to":"@baochai","kind":"relation","label":"金玉良缘"}]}
|
||||
apply_direct returns nodeIdMap: { "AIOps":"n_…", "@aiops":"n_…" }
|
||||
|
||||
Node/edge meta extras:
|
||||
- update_node_meta patch: description (JSON object; put a short "function" field for Q&A), accentColor, icon, alias, note
|
||||
- update_edge_meta patch: weight 1-5, lineStyle solid|dashed|dotted, direction forward|both|none
|
||||
- Prefer batch_create for trees; relations[] for inline edges; batch_link for bulk edges later; auto_fit after big builds
|
||||
|
||||
Rules:
|
||||
1. Always create or open a canvas BEFORE apply/propose.
|
||||
2. Never invent node ids; use find or create_node results. Never ask for / use a root node id — there is no visible root.
|
||||
3. Prefer batch_create (or many small ops) over one giant rewrite of unrelated topics.
|
||||
4. Always use type, never op.
|
||||
5. Top-level siblings: omit parentId on each create_node.
|
||||
6. For questions about the map, use mindmap_query first; never fabricate graph facts.`,
|
||||
}
|
||||
}
|
||||
43
packages/dsh-plugin/package.json
Normal file
43
packages/dsh-plugin/package.json
Normal file
|
|
@ -0,0 +1,43 @@
|
|||
{
|
||||
"name": "dsh-mind-map",
|
||||
"version": "0.1.0",
|
||||
"description": "AI 双向无限画布:DSH 右侧思维导图 Tab;对话用 DSH。",
|
||||
"type": "module",
|
||||
"main": "lib/index.js",
|
||||
"exports": {
|
||||
".": "./lib/index.js",
|
||||
"./client": "./lib/client.js",
|
||||
"./package.json": "./package.json"
|
||||
},
|
||||
"files": [
|
||||
"lib",
|
||||
"cordis.patch.yml",
|
||||
"README.md"
|
||||
],
|
||||
"scripts": {
|
||||
"test": "node --test test/*.test.js"
|
||||
},
|
||||
"dsh": {
|
||||
"bundle": {
|
||||
"patch": "./cordis.patch.yml"
|
||||
},
|
||||
"client": {
|
||||
"platform": "web",
|
||||
"immediately": true,
|
||||
"inject": [
|
||||
"@deepseek-ai/dsh-client-ui-settings-plugins",
|
||||
"@deepseek-ai/dsh-client-locale"
|
||||
]
|
||||
}
|
||||
},
|
||||
"keywords": [
|
||||
"dsh-plugin",
|
||||
"dsh",
|
||||
"mind-map",
|
||||
"canvas"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=22"
|
||||
},
|
||||
"license": "MIT"
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue