@zmainer/dsh-wx-bridge 1.0.8
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +201 -0
- package/cordis.patch.yml +3 -0
- package/lib/client.js +440 -0
- package/lib/index.js +650 -0
- package/lib/kernel/acp-overlay-chat.yml +26 -0
- package/lib/kernel/acp-overlay.yml +22 -0
- package/lib/kernel/acp-preset-shim.mjs +162 -0
- package/lib/kernel/acp.mjs +232 -0
- package/lib/kernel/bridge.mjs +1341 -0
- package/lib/kernel/keeper.mjs +302 -0
- package/lib/kernel/second-brain-contract.txt +7 -0
- package/lib/pairing.js +225 -0
- package/lib/vendor/qr-svg.cjs +58 -0
- package/lib/vendor/qrcode-core/LICENSE +10 -0
- package/lib/vendor/qrcode-core/NOTICE.md +21 -0
- package/lib/vendor/qrcode-core/alignment-pattern.js +83 -0
- package/lib/vendor/qrcode-core/alphanumeric-data.js +59 -0
- package/lib/vendor/qrcode-core/bit-buffer.js +37 -0
- package/lib/vendor/qrcode-core/bit-matrix.js +65 -0
- package/lib/vendor/qrcode-core/byte-data.js +30 -0
- package/lib/vendor/qrcode-core/dijkstrajs.LICENSE +19 -0
- package/lib/vendor/qrcode-core/dijkstrajs.js +165 -0
- package/lib/vendor/qrcode-core/error-correction-code.js +135 -0
- package/lib/vendor/qrcode-core/error-correction-level.js +50 -0
- package/lib/vendor/qrcode-core/finder-pattern.js +22 -0
- package/lib/vendor/qrcode-core/format-info.js +29 -0
- package/lib/vendor/qrcode-core/galois-field.js +69 -0
- package/lib/vendor/qrcode-core/kanji-data.js +54 -0
- package/lib/vendor/qrcode-core/mask-pattern.js +234 -0
- package/lib/vendor/qrcode-core/mode.js +167 -0
- package/lib/vendor/qrcode-core/numeric-data.js +43 -0
- package/lib/vendor/qrcode-core/package.json +8 -0
- package/lib/vendor/qrcode-core/polynomial.js +62 -0
- package/lib/vendor/qrcode-core/qrcode.js +495 -0
- package/lib/vendor/qrcode-core/reed-solomon-encoder.js +56 -0
- package/lib/vendor/qrcode-core/regex.js +31 -0
- package/lib/vendor/qrcode-core/segments.js +330 -0
- package/lib/vendor/qrcode-core/utils.js +63 -0
- package/lib/vendor/qrcode-core/version-check.js +9 -0
- package/lib/vendor/qrcode-core/version.js +163 -0
- package/package.json +40 -0
- package/scripts/build-client.mjs +35 -0
- package/src/client.js +427 -0
package/lib/index.js
ADDED
|
@@ -0,0 +1,650 @@
|
|
|
1
|
+
import { existsSync, readFileSync, writeFileSync, readdirSync, mkdirSync, appendFileSync } from 'node:fs'
|
|
2
|
+
import { join, dirname } from 'node:path'
|
|
3
|
+
import { fileURLToPath } from 'node:url'
|
|
4
|
+
import { createRequire as makeRequire } from 'node:module'
|
|
5
|
+
import { randomUUID } from 'node:crypto'
|
|
6
|
+
|
|
7
|
+
export const name = 'wxbridge'
|
|
8
|
+
|
|
9
|
+
const KERNEL = join(dirname(fileURLToPath(import.meta.url)), 'kernel')
|
|
10
|
+
/** 插件版本:从本包 package.json 读,避免在代码里写死(面板 /status 会显示它)。 */
|
|
11
|
+
const PLUGIN_VERSION = (() => {
|
|
12
|
+
try { return JSON.parse(readFileSync(join(dirname(fileURLToPath(import.meta.url)), '..', 'package.json'), 'utf8')).version || '0.0.0' } catch { return '0.0.0' }
|
|
13
|
+
})()
|
|
14
|
+
const NL = String.fromCharCode(10)
|
|
15
|
+
const TASK_TIMEOUT_MS = Number(process.env.WXBRIDGE_TASK_TIMEOUT_MS || 180000)
|
|
16
|
+
const require_ = makeRequire(import.meta.url)
|
|
17
|
+
|
|
18
|
+
/** 解析 qrcode 渲染器:插件不一定自带该依赖,按 profile 层级逐个探,都找不到就降级为原始文字链接。 */
|
|
19
|
+
const loadQrRenderer = () => {
|
|
20
|
+
// ① 自带渲染器优先:lib/vendor/ 里 vendored 了 qrcode 的编码核心 + 自己的 SVG 渲染,
|
|
21
|
+
// 零外部依赖 —— 干净安装的机器也能出二维码(此前靠解析 profile 里的 qrcode 包,
|
|
22
|
+
// 插件 dependencies 为空 → 别人装完只能看到"备用链接",2026-09-21 实测反馈)。
|
|
23
|
+
try {
|
|
24
|
+
const vendored = require_(join(import.meta.dirname, 'vendor', 'qr-svg.cjs'))
|
|
25
|
+
if (vendored && typeof vendored.toSvgDataUrl === 'function') {
|
|
26
|
+
return (text) => vendored.toSvgDataUrl(text, { size: 320, margin: 2, ecLevel: 'M' })
|
|
27
|
+
}
|
|
28
|
+
} catch {}
|
|
29
|
+
// ② 兜底:profile 里恰好装了 qrcode 包(旧行为,保持兼容)
|
|
30
|
+
const home = process.env.DSH_HOME || join(process.env.USERPROFILE || process.env.HOME || '', '.dsh')
|
|
31
|
+
const bases = [import.meta.dirname, join(home, 'profiles', 'web'), join(home, 'profiles', 'desktop'), join(home, 'profiles')]
|
|
32
|
+
for (const b of bases) {
|
|
33
|
+
try {
|
|
34
|
+
const f = require_.resolve('qrcode', { paths: [b] })
|
|
35
|
+
const qr = require_(f)
|
|
36
|
+
if (qr && typeof qr.toDataURL === 'function') {
|
|
37
|
+
return (text) => qr.toDataURL(text, { width: 320, margin: 1, errorCorrectionLevel: 'M' })
|
|
38
|
+
}
|
|
39
|
+
} catch {}
|
|
40
|
+
}
|
|
41
|
+
return null
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const DEFAULTS = {
|
|
45
|
+
dataDir: '',
|
|
46
|
+
cwd: '',
|
|
47
|
+
vault: '',
|
|
48
|
+
nodePath: '',
|
|
49
|
+
intervalMs: 0,
|
|
50
|
+
staleMs: 0,
|
|
51
|
+
// autoStart 默认关:拉起桥的职责归独立 keeper(HKCU Run / start-keeper.vbs)。
|
|
52
|
+
// 宿主自己 spawn 的桥是宿主进程的后代,宿主被整树终止时会连带杀掉(E-2026-09-21-06)。
|
|
53
|
+
autoStart: false,
|
|
54
|
+
autoSupervise: true,
|
|
55
|
+
/**
|
|
56
|
+
* host = 桥由宿主托管:启动即拉起、宿主退出即结束(你的要求:应用关了就不能远程驱动);
|
|
57
|
+
* standalone = 由独立 keeper 守护(应用关不关都能用)。
|
|
58
|
+
*/
|
|
59
|
+
lifecycle: 'standalone',
|
|
60
|
+
hostHome: '',
|
|
61
|
+
/** 与应用同版本的 DSH 运行时入口(留空则自动探测宿主运行时) */
|
|
62
|
+
dshBin: '',
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
const ACTION_LOG_MAX = 60
|
|
66
|
+
|
|
67
|
+
let createUserMessage = null
|
|
68
|
+
let installModelSelection = null
|
|
69
|
+
const loadSessionHelpers = async () => {
|
|
70
|
+
if (createUserMessage && installModelSelection) return { createUserMessage, installModelSelection }
|
|
71
|
+
const load = async (specs, key) => {
|
|
72
|
+
for (const spec of specs) {
|
|
73
|
+
try { const m = await import(spec); if (typeof m[key] === 'function') return m[key] } catch {}
|
|
74
|
+
}
|
|
75
|
+
return null
|
|
76
|
+
}
|
|
77
|
+
if (!createUserMessage) createUserMessage = await load(['@deepseek-ai/dsh-llm', 'dsh-llm'], 'createUserMessage')
|
|
78
|
+
if (!installModelSelection) installModelSelection = await load(['@deepseek-ai/dsh-agent', 'dsh-agent'], 'installModelSelection')
|
|
79
|
+
return (createUserMessage && installModelSelection) ? { createUserMessage, installModelSelection } : null
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
export function apply(ctx, config) {
|
|
83
|
+
const cfg = { ...DEFAULTS, ...(config || {}) }
|
|
84
|
+
let keeper = null
|
|
85
|
+
let timer = null
|
|
86
|
+
let pairing = null
|
|
87
|
+
|
|
88
|
+
/** 按钮操作日志(环形缓冲):面板要能看到"我点了什么、结果如何"。 */
|
|
89
|
+
const actions = []
|
|
90
|
+
const pushAction = (entry) => {
|
|
91
|
+
actions.unshift({ at: new Date().toISOString(), ...entry })
|
|
92
|
+
if (actions.length > ACTION_LOG_MAX) actions.length = ACTION_LOG_MAX
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
const readConfigFile = () => {
|
|
96
|
+
const f = process.env.WXBRIDGE_CONFIG || join(process.env.DSH_HOME || '', 'wxbridge', 'config.json')
|
|
97
|
+
try { return JSON.parse(readFileSync(f, 'utf8')) || {} } catch { return {} }
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
const boot = async () => {
|
|
101
|
+
const file = readConfigFile()
|
|
102
|
+
const pick = (k) => cfg[k] || file[k] || undefined
|
|
103
|
+
const dataDir = pick('dataDir')
|
|
104
|
+
const vault = pick('vault')
|
|
105
|
+
const cwd = pick('cwd')
|
|
106
|
+
const hostHome = pick('hostHome') || process.env.DSH_HOME || ''
|
|
107
|
+
// 自动探测宿主运行时:会话 schema 必须与应用一致,否则外部写的会话在应用里打不开
|
|
108
|
+
const dshBin = pick('dshBin') || process.env.DSH_RUNTIME_BIN || ''
|
|
109
|
+
const ownership = cfg.lifecycle === 'host' || file.lifecycle === 'host' ? 'host' : 'standalone'
|
|
110
|
+
const { createKeeper } = await import(new URL('./kernel/keeper.mjs', import.meta.url).href)
|
|
111
|
+
keeper = createKeeper({
|
|
112
|
+
hostHome,
|
|
113
|
+
dshBin,
|
|
114
|
+
dataDir,
|
|
115
|
+
cwd,
|
|
116
|
+
node: cfg.nodePath || undefined,
|
|
117
|
+
intervalMs: cfg.intervalMs || file.intervalMs || undefined,
|
|
118
|
+
staleMs: cfg.staleMs || file.staleMs || undefined,
|
|
119
|
+
bridgePath: join(KERNEL, 'bridge.mjs'),
|
|
120
|
+
// 宿主半只做监测与报告,生命周期归独立 keeper(E-2026-09-21-06)。
|
|
121
|
+
superviseMode: ownership === 'host' ? 'heal' : 'monitor',
|
|
122
|
+
// --host-home 由内核(keeper.mjs)统一追加,这里只补宿主特有的参数,避免重复
|
|
123
|
+
bridgeArgs: [...(vault ? ['--vault', vault] : []), ...(ownership === 'host' ? ['--owner-pid', String(process.pid)] : [])],
|
|
124
|
+
detached: ownership === 'host' ? false : true,
|
|
125
|
+
})
|
|
126
|
+
keeper.lifecycle = ownership
|
|
127
|
+
const st = keeper.status()
|
|
128
|
+
ctx.logger?.info?.('wxbridge: data=' + st.dataDir + ' cwd=' + st.cwd + ' pid=' + st.pid + ' pidOk=' + st.pidOk)
|
|
129
|
+
const standaloneKeeper = (() => { try { const j = JSON.parse(readFileSync(join(st.dataDir, 'keeper.lock'), 'utf8')); return j && j.pid ? Number(j.pid) : 0 } catch { return 0 } })()
|
|
130
|
+
ctx.logger?.info?.('wxbridge: 独立 keeper pid=' + (standaloneKeeper || '无') + '(负责拉起/自愈;宿主半只做看门狗,不抢拉起)')
|
|
131
|
+
if (ownership === 'host') {
|
|
132
|
+
if (!st.pidOk) {
|
|
133
|
+
try { ctx.logger?.info?.('wxbridge: host-owned 模式,拉起桥 pid=' + keeper.startBridge('host-boot', true)) } catch (e) { ctx.logger?.warn?.('wxbridge: 拉起失败 ' + String(e?.message ?? e)) }
|
|
134
|
+
}
|
|
135
|
+
ctx.logger?.info?.('wxbridge: lifecycle=host(宿主退出时结束桥);hostHome=' + hostHome)
|
|
136
|
+
}
|
|
137
|
+
if (ownership !== 'host' && cfg.autoStart && process.env.WXBRIDGE_NO_AUTOSTART !== '1' && !st.pidOk) {
|
|
138
|
+
try { ctx.logger?.info?.('wxbridge: spawned bridge pid=' + keeper.startBridge()) } catch (e) { ctx.logger?.warn?.('wxbridge: spawn failed ' + String(e?.message ?? e)) }
|
|
139
|
+
}
|
|
140
|
+
// 开机自动归组:把外部(桥/headless)写出的、尚未登记到工作区的会话补登记
|
|
141
|
+
setTimeout(async () => {
|
|
142
|
+
try {
|
|
143
|
+
const fsMod = await import('node:fs')
|
|
144
|
+
const hostHome = process.env.DSH_HOME || ''
|
|
145
|
+
if (!hostHome) return
|
|
146
|
+
// 复用路由里的 attachOne/scanAndAttach 需要 webCtx;这里用等价的独立实现
|
|
147
|
+
const reg = ctx.workspaceRegistry
|
|
148
|
+
if (!reg) { ctx.logger?.info?.('wxbridge: 跳过自动归组(workspaceRegistry 未注入)'); return }
|
|
149
|
+
const workspaces = await reg.list()
|
|
150
|
+
const norm = (v) => String(v || '').replace(/[\/]+$/, '').toLowerCase()
|
|
151
|
+
const known = new Set()
|
|
152
|
+
for (const w of workspaces) for (const id of (w.sessionIds || [])) known.add(id)
|
|
153
|
+
const persistence = ctx.sessionPersistence
|
|
154
|
+
if (!persistence || typeof persistence.list !== 'function') return
|
|
155
|
+
const records = await persistence.list()
|
|
156
|
+
let n = 0
|
|
157
|
+
for (const rec of (records || [])) {
|
|
158
|
+
const header = (rec && (rec.header || rec)) || {}
|
|
159
|
+
const id = String(header.id || '')
|
|
160
|
+
if (!id.startsWith('session-') || known.has(id)) continue
|
|
161
|
+
const ws = workspaces.find((w) => norm(w.path) === norm(header.cwd))
|
|
162
|
+
if (!ws) continue
|
|
163
|
+
try { await ws.attachSession(id); known.add(id); n++ } catch {}
|
|
164
|
+
}
|
|
165
|
+
if (n) ctx.logger?.info?.('wxbridge: 自动归组 ' + n + ' 条会话到对应工作区')
|
|
166
|
+
} catch (e) { ctx.logger?.warn?.('wxbridge: 自动归组失败 ' + String(e?.message ?? e)) }
|
|
167
|
+
}, 15000)
|
|
168
|
+
if (cfg.autoSupervise && process.env.WXBRIDGE_NO_SUPERVISE !== '1') {
|
|
169
|
+
timer = setInterval(() => { try { keeper.tick() } catch (e) { ctx.logger?.warn?.('wxbridge tick: ' + String(e?.message ?? e)) } }, cfg.intervalMs || file.intervalMs || 300000)
|
|
170
|
+
if (timer.unref) timer.unref()
|
|
171
|
+
}
|
|
172
|
+
const { createPairing } = await import(new URL('./pairing.js', import.meta.url).href)
|
|
173
|
+
pairing = createPairing({
|
|
174
|
+
dataDir: st.dataDir,
|
|
175
|
+
dshHome: process.env.DSH_HOME,
|
|
176
|
+
toDataUrl: loadQrRenderer(),
|
|
177
|
+
log: (m) => ctx.logger?.info?.('wxbridge: ' + m),
|
|
178
|
+
})
|
|
179
|
+
pushAction({ action: 'host-boot', ok: true, detail: 'dataDir=' + st.dataDir + ' pidOk=' + st.pidOk + ' qr=' + (loadQrRenderer() ? 'qrcode' : 'text-only') })
|
|
180
|
+
// 把宿主自身的 HTTP 地址写给桥:桥据此把新生成的会话上报回来登记进工作区
|
|
181
|
+
try {
|
|
182
|
+
let port = 0
|
|
183
|
+
try {
|
|
184
|
+
// 依次尝试:webServer 的底层 server → 常见宿主属性 → 配置/环境变量
|
|
185
|
+
const ws = webCtx.webServer || {}
|
|
186
|
+
const cands = [ws.server, ws.httpServer, ws.instance, ws.listener, ws.app]
|
|
187
|
+
for (const srv of cands) {
|
|
188
|
+
if (srv && typeof srv.address === 'function') {
|
|
189
|
+
const addr = srv.address()
|
|
190
|
+
if (addr && typeof addr === 'object' && addr.port) { port = addr.port; break }
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
if (!port) {
|
|
194
|
+
for (const k of ['port', 'portNumber', 'listenPort']) if (ws[k]) { port = Number(ws[k]); break }
|
|
195
|
+
}
|
|
196
|
+
if (!port) port = Number(process.env.WXBRIDGE_HOST_PORT || 0)
|
|
197
|
+
} catch {}
|
|
198
|
+
const addrFile = join(st.dataDir, 'host-address.json')
|
|
199
|
+
writeFileSync(addrFile, JSON.stringify({ pid: process.pid, port, at: new Date().toISOString() }, null, 2))
|
|
200
|
+
} catch {}
|
|
201
|
+
ctx.logger?.info?.('wxbridge: pairing ready (qr renderer=' + (loadQrRenderer() ? 'qrcode' : 'text-only') + ')')
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
const booted = boot().catch((e) => { ctx.logger?.error?.('wxbridge boot failed: ' + String(e?.message ?? e)) })
|
|
205
|
+
|
|
206
|
+
ctx.on?.('dispose', () => {
|
|
207
|
+
if (timer) clearInterval(timer)
|
|
208
|
+
if (keeper && keeper.lifecycle === 'host') {
|
|
209
|
+
try { keeper.stopBridge('host-exit'); ctx.logger?.info?.('wxbridge: 宿主退出,已结束桥') } catch {}
|
|
210
|
+
}
|
|
211
|
+
})
|
|
212
|
+
|
|
213
|
+
ctx.inject?.(['webServer', 'workspaceRegistry', 'sessions', 'agents', 'sessionPersistence', 'agentDefaultModel', 'sessionController'], (webCtx) => {
|
|
214
|
+
const send = (res, o, code = 200) => { res.writeHead(code, { 'content-type': 'application/json', 'cache-control': 'no-store' }); res.end(JSON.stringify(o)) }
|
|
215
|
+
let lastError = null
|
|
216
|
+
|
|
217
|
+
const bridgeView = () => {
|
|
218
|
+
if (!keeper) return null
|
|
219
|
+
const st = keeper.status()
|
|
220
|
+
const hb = st.heartbeat || {}
|
|
221
|
+
let peers = 0, tasks = 0, allowed = 0, stateOk = false, manualStop = null
|
|
222
|
+
try {
|
|
223
|
+
const s = JSON.parse(readFileSync(join(st.dataDir, 'state.json'), 'utf8'))
|
|
224
|
+
peers = Object.keys(s.peers || {}).length
|
|
225
|
+
tasks = Object.values(s.tasks || {}).filter((t) => t.status === 'running').length
|
|
226
|
+
allowed = (s.allowedUsers || []).length
|
|
227
|
+
stateOk = !!s.checksum
|
|
228
|
+
manualStop = (s.manualStop && s.manualStop.at) ? s.manualStop : null
|
|
229
|
+
} catch {}
|
|
230
|
+
return {
|
|
231
|
+
phase: hb.phase || 'stopped',
|
|
232
|
+
pid: st.pid, pidOk: st.pidOk, fresh: st.fresh,
|
|
233
|
+
ageSec: st.heartbeat ? Math.round(st.heartbeat.age / 1000) : null,
|
|
234
|
+
polls: hb.polls ?? null, boot: hb.boot ?? null, running: hb.tasks ?? 0,
|
|
235
|
+
peers, tasks, allowedUsers: allowed, stateOk, manualStop,
|
|
236
|
+
dataDir: st.dataDir, cwd: st.cwd, intervalMs: st.intervalMs, staleMs: st.staleMs,
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
const infoView = () => {
|
|
241
|
+
const dir = keeper ? keeper.dataDir : ''
|
|
242
|
+
const out = { dataDir: dir, tokenPath: dir ? join(dir, 'auth-token.txt') : null, token: null, vault: cfg.vault || null, contract: null, credentialFile: pairing ? pairing.credentialFile : null, profiles: [] }
|
|
243
|
+
try { out.token = readFileSync(out.tokenPath, 'utf8').trim() } catch {}
|
|
244
|
+
const cp = dir ? join(dir, 'second-brain-contract.txt') : ''
|
|
245
|
+
out.contract = cp && existsSync(cp) ? cp : join(KERNEL, 'second-brain-contract.txt')
|
|
246
|
+
const pdir = join(process.env.DSH_HOME || '', 'profiles')
|
|
247
|
+
try { for (const d of readdirSync(pdir)) { const pj = join(pdir, d, 'package.json'); let nm = d; try { nm = JSON.parse(readFileSync(pj, 'utf8')).name || d } catch {} out.profiles.push(nm) } } catch {}
|
|
248
|
+
return out
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
const configPath = () => process.env.WXBRIDGE_CONFIG
|
|
252
|
+
|| join(process.env.DSH_HOME || (keeper && keeper.dataDir) || '', 'wxbridge', 'config.json')
|
|
253
|
+
const readJsonFile = (f) => { try { return JSON.parse(readFileSync(f, 'utf8')) || {} } catch { return {} } }
|
|
254
|
+
|
|
255
|
+
/**
|
|
256
|
+
* 预设名册:用户预设($DSH_HOME/.agent-presets)优先,其次是运行时的出厂预设树与包内预设。
|
|
257
|
+
* 行 = 一个目录(含 preset.yml + agent.cordis.yml);无 preset.yml 或缺 composition 的目录视为损坏行,跳过。
|
|
258
|
+
*/
|
|
259
|
+
const presetRoots = () => {
|
|
260
|
+
const roots = []
|
|
261
|
+
const home = process.env.DSH_HOME || ''
|
|
262
|
+
if (home) roots.push(join(home, '.agent-presets'))
|
|
263
|
+
for (const bin of [keeper && keeper.dshBin, process.env.DSH_BIN].filter(Boolean)) {
|
|
264
|
+
const rt = dirname(dirname(bin))
|
|
265
|
+
roots.push(join(rt, 'config', 'agent-presets'))
|
|
266
|
+
roots.push(join(rt, 'node_modules', '@deepseek-ai', 'dsh-agent-presets', 'presets'))
|
|
267
|
+
}
|
|
268
|
+
return roots
|
|
269
|
+
}
|
|
270
|
+
const presetMeta = (dir) => {
|
|
271
|
+
let meta = ''
|
|
272
|
+
try { meta = readFileSync(join(dir, 'preset.yml'), 'utf8') } catch { return null }
|
|
273
|
+
if (!existsSync(join(dir, 'agent.cordis.yml'))) return null
|
|
274
|
+
const pick = (k) => { const m = meta.match(new RegExp('^' + k + ':\\s*(.+)$', 'm')); return m ? m[1].trim().replace(/^["']|["']$/g, '') : '' }
|
|
275
|
+
return { name: pick('name'), description: pick('description'), order: Number(pick('order') || 999) }
|
|
276
|
+
}
|
|
277
|
+
const listPresets = () => {
|
|
278
|
+
const seen = new Set(); const out = []
|
|
279
|
+
for (const root of presetRoots()) {
|
|
280
|
+
let ids = []
|
|
281
|
+
try { ids = readdirSync(root) } catch { continue }
|
|
282
|
+
for (const id of ids) {
|
|
283
|
+
if (String(id).startsWith('.') || /\.bak/.test(id) || seen.has(id)) continue
|
|
284
|
+
const meta = presetMeta(join(root, id))
|
|
285
|
+
if (!meta) continue
|
|
286
|
+
seen.add(id); out.push({ id, name: meta.name || id, description: meta.description, order: meta.order })
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
return out.sort((a, b) => (a.order - b.order) || String(a.id).localeCompare(String(b.id)))
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
const readBody = (req) => new Promise((resolve) => {
|
|
293
|
+
let b = ''
|
|
294
|
+
req.on('data', (c) => { b += c; if (b.length > 65536) req.destroy() })
|
|
295
|
+
req.on('end', () => { try { resolve(b ? JSON.parse(b) : {}) } catch { resolve({}) } })
|
|
296
|
+
req.on('error', () => resolve({}))
|
|
297
|
+
})
|
|
298
|
+
|
|
299
|
+
const disposer = webCtx.webServer.register({
|
|
300
|
+
kind: 'prefix',
|
|
301
|
+
path: '/wxbridge',
|
|
302
|
+
handler: async (req, res) => {
|
|
303
|
+
const p = new URL(req.url ?? '/', 'http://x').pathname.replace(/^\/wxbridge/, '') || '/'
|
|
304
|
+
try {
|
|
305
|
+
await booted
|
|
306
|
+
if (p === '/status') { const b = bridgeView(); return send(res, { ok: true, plugin: 'wxbridge', version: PLUGIN_VERSION, hostPid: process.pid, bridge: b, peers: b ? b.peers : 0 }) }
|
|
307
|
+
if (p === '/log') return send(res, { ok: true, actions, error: lastError })
|
|
308
|
+
// 桥自己的日志(含"为什么起不来"):bridge.log(桥写入)+ bridge-standalone.log(spawn 的 stdout/stderr)
|
|
309
|
+
if (p === '/bridge-log') {
|
|
310
|
+
const m = String(req.url || '').match(/[?&]lines=(\d+)/)
|
|
311
|
+
const want = Math.max(10, Math.min(400, Number((m && m[1]) || 80)))
|
|
312
|
+
const dir = keeper ? keeper.dataDir : ''
|
|
313
|
+
const parts = []
|
|
314
|
+
for (const f of ['bridge.log', 'bridge-standalone.log']) {
|
|
315
|
+
const full = join(dir, f)
|
|
316
|
+
try {
|
|
317
|
+
const all = readFileSync(full, 'utf8').split('\n')
|
|
318
|
+
// 只看"最后一次启动"之后的段落:追加式日志里混着历史尝试,直接 tail 会让人把旧崩溃当成新问题
|
|
319
|
+
const marks = []
|
|
320
|
+
all.forEach((l, i) => { if (l.indexOf('===== bridge start') === 0) marks.push(i) })
|
|
321
|
+
const last = marks.length ? marks[marks.length - 1] : 0
|
|
322
|
+
const skipped = marks.length ? last : 0
|
|
323
|
+
const seg = all.slice(last).filter(Boolean)
|
|
324
|
+
const tailSeg = seg.slice(-want)
|
|
325
|
+
parts.push('=== ' + f + '(本次启动' + (skipped ? ';已省略前 ' + skipped + ' 行历史尝试' : '') + ',tail ' + want + ')===' + NL + tailSeg.join(NL))
|
|
326
|
+
} catch { parts.push('=== ' + f + ' === (无)') }
|
|
327
|
+
}
|
|
328
|
+
return send(res, { ok: true, dataDir: dir, log: parts.join(NL + NL) })
|
|
329
|
+
}
|
|
330
|
+
if (p === '/info') return send(res, { ok: true, ...infoView() })
|
|
331
|
+
if (p === '/probe') return send(res, { ok: true, path: p, note: 'non-/api prefix served by plugin' })
|
|
332
|
+
// ===== 会话归组:把外部(桥/headless)写出的会话登记进工作区 =====
|
|
333
|
+
const attachOne = async (sessionId, wantWs, cwdHint, title) => {
|
|
334
|
+
const reg = webCtx.workspaceRegistry
|
|
335
|
+
if (!reg) return { ok: false, error: 'workspaceRegistry 不可用' }
|
|
336
|
+
let list = []
|
|
337
|
+
try { list = await reg.list() } catch {}
|
|
338
|
+
const items = Array.isArray(list) ? list : (list && list.items) || []
|
|
339
|
+
const norm = (v) => String(v || '').replace(/[\/]+$/, '').toLowerCase()
|
|
340
|
+
const target = (wantWs ? items.find((w) => String(w.id ?? w.workspaceId) === wantWs) : null)
|
|
341
|
+
|| (cwdHint ? items.find((w) => norm(w.path) === norm(cwdHint)) : null)
|
|
342
|
+
|| items[0]
|
|
343
|
+
if (!target) return { ok: false, error: '未找到目标工作区' }
|
|
344
|
+
try { await target.attachSession(sessionId) } catch (e) {
|
|
345
|
+
return { ok: false, error: 'attachSession 失败: ' + String(e?.message ?? e) }
|
|
346
|
+
}
|
|
347
|
+
let renamed = false
|
|
348
|
+
try {
|
|
349
|
+
if (title && webCtx.sessions && typeof webCtx.sessions.rename === 'function') { await webCtx.sessions.rename(sessionId, title); renamed = true }
|
|
350
|
+
} catch {}
|
|
351
|
+
pushAction({ action: 'attach', ok: true, detail: sessionId + ' → ' + String(target.path) + (renamed ? '(改名)' : '') })
|
|
352
|
+
return { ok: true, sessionId, workspaceId: target.id ?? target.workspaceId, workspacePath: target.path, renamed }
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
// 扫描:把尚未登记进工作区的会话补登记(桥写的会话天然落在工作区路径下,按 cwd 精确匹配)
|
|
356
|
+
const scanAndAttach = async (limit) => {
|
|
357
|
+
const reg = webCtx.workspaceRegistry
|
|
358
|
+
if (!reg) return { ok: false, error: 'workspaceRegistry 不可用' }
|
|
359
|
+
let workspaces = []
|
|
360
|
+
try { workspaces = await reg.list() } catch {}
|
|
361
|
+
workspaces = Array.isArray(workspaces) ? workspaces : []
|
|
362
|
+
const norm = (v) => String(v || '').replace(/[\/]+$/, '').toLowerCase()
|
|
363
|
+
|
|
364
|
+
// 已登记集合:直接从各工作区记录里取,天然幂等
|
|
365
|
+
const known = new Set()
|
|
366
|
+
for (const w of workspaces) for (const id of (w.sessionIds || [])) known.add(id)
|
|
367
|
+
|
|
368
|
+
// 列出所有持久化会话(宿主内服务,含 header.cwd)
|
|
369
|
+
let records = []
|
|
370
|
+
try {
|
|
371
|
+
const persistence = webCtx.sessionPersistence
|
|
372
|
+
if (persistence && typeof persistence.list === 'function') records = await persistence.list()
|
|
373
|
+
else if (webCtx.sessions && typeof webCtx.sessions.list === 'function') records = webCtx.sessions.list()
|
|
374
|
+
} catch (e) { return { ok: false, error: '无法列出会话: ' + String(e?.message ?? e) } }
|
|
375
|
+
records = Array.isArray(records) ? records : []
|
|
376
|
+
|
|
377
|
+
const added = [], failed = []
|
|
378
|
+
let matched = 0
|
|
379
|
+
for (const rec of records) {
|
|
380
|
+
const header = (rec && (rec.header || rec)) || {}
|
|
381
|
+
const id = String(header.id || '')
|
|
382
|
+
if (!id.startsWith('session-') || known.has(id)) continue
|
|
383
|
+
const cwd = norm(header.cwd)
|
|
384
|
+
if (!cwd) continue
|
|
385
|
+
const ws = workspaces.find((w) => norm(w.path) === cwd)
|
|
386
|
+
if (!ws) continue
|
|
387
|
+
matched++
|
|
388
|
+
if (limit && added.length >= limit) continue
|
|
389
|
+
const r = await attachOne(id, ws.id ?? ws.workspaceId, ws.path, '')
|
|
390
|
+
if (r.ok) { added.push(id); known.add(id) } else failed.push({ id, error: r.error })
|
|
391
|
+
}
|
|
392
|
+
return { scanned: records.length, matchedCwd: matched, attached: added.length, added: added.slice(0, 20), failed: failed.slice(0, 5) }
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
// ===== 宿主内驱动一轮对话(原生会话 + 原生上下文,替代桥 spawn headless)=====
|
|
396
|
+
const runTask = async (text, opts = {}) => {
|
|
397
|
+
const sc = webCtx.sessionController
|
|
398
|
+
if (!sc) return { ok: false, error: 'sessionController 服务不可用(需宿主注入)' }
|
|
399
|
+
|
|
400
|
+
const norm = (v) => String(v || '').replace(/[\/]+$/, '').toLowerCase()
|
|
401
|
+
const workspaces = await (async () => { try { return (await webCtx.workspaceRegistry.list()) || [] } catch { return [] } })()
|
|
402
|
+
const ws = (opts.workspaceId ? workspaces.find((w) => String(w.id ?? w.workspaceId) === String(opts.workspaceId)) : null)
|
|
403
|
+
|| (opts.cwd ? workspaces.find((w) => norm(w.path) === norm(opts.cwd)) : null)
|
|
404
|
+
|| workspaces[0]
|
|
405
|
+
if (!ws) return { ok: false, error: '未找到工作区' }
|
|
406
|
+
|
|
407
|
+
const wsId = ws.id ?? ws.workspaceId
|
|
408
|
+
let sessionId = String(opts.sessionId || '')
|
|
409
|
+
let created = false
|
|
410
|
+
|
|
411
|
+
if (!sessionId) {
|
|
412
|
+
// 走宿主自己的 create:它内部就是 workspaceRegistry.get → agents.ensureSession → attachSession
|
|
413
|
+
try {
|
|
414
|
+
const r = await sc.create({ workspaceId: wsId, ...(opts.agentPreset ? { agentPreset: opts.agentPreset } : {}) })
|
|
415
|
+
sessionId = String((r && (r.sessionId || (r.value && r.value.sessionId))) || '')
|
|
416
|
+
created = true
|
|
417
|
+
} catch (e) {
|
|
418
|
+
const msg = String(e?.message ?? e)
|
|
419
|
+
const stack = String(e?.stack || '').split(NL).slice(0, 6).join(' | ')
|
|
420
|
+
return { ok: false, error: 'sessionController.create 失败: ' + msg.slice(0, 600), stack: stack.slice(0, 800), workspaceId: wsId, preset: opts.agentPreset || null }
|
|
421
|
+
}
|
|
422
|
+
if (!sessionId) return { ok: false, error: 'create 未返回 sessionId', workspaceId: wsId }
|
|
423
|
+
pushAction({ action: 'task-create', ok: true, detail: 'session=' + sessionId + ' ws=' + wsId })
|
|
424
|
+
} else {
|
|
425
|
+
try { await ws.attachSession(sessionId) } catch {}
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
// 可选:切模型
|
|
429
|
+
if (opts.model && opts.provider) {
|
|
430
|
+
try { await sc.selectModel({ sessionId, selection: { provider: opts.provider, model: opts.model } }) } catch (e) { /* 忽略 */ }
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
const before = await (async () => { try { const pg = await sc.page({ sessionId }, AbortSignal.timeout(8000)); return Number(pg && (pg.lastSeq ?? pg.asOfSeq)) || 0 } catch { return 0 } })()
|
|
434
|
+
|
|
435
|
+
// 先显式拿 agent(resolveAgent 是 controller 自己的公开方法),把它挂到 sessions 上再 prompt
|
|
436
|
+
let resolved = null
|
|
437
|
+
try { resolved = await sc.resolveAgent(sessionId) } catch (e) { pushAction({ action: 'task-resolve', ok: false, detail: String(e?.message ?? e).slice(0, 160) }) }
|
|
438
|
+
if (resolved && resolved.error) return { ok: false, error: 'resolveAgent: ' + JSON.stringify(resolved.error).slice(0, 300), sessionId, created }
|
|
439
|
+
try {
|
|
440
|
+
await sc.prompt({ sessionId, requestId: randomUUID(), mode: 'queue', content: [{ type: 'text', text: String(text) }] }, AbortSignal.timeout(Math.min(TASK_TIMEOUT_MS, 180000)))
|
|
441
|
+
} catch (e) {
|
|
442
|
+
return { ok: false, error: 'sessionController.prompt 失败: ' + String(e?.message ?? e), sessionId, created, resolved: !!resolved }
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
// 等这一轮结束并取回助手文本:轮询 page(宿主权威的会话视图)
|
|
446
|
+
let out = '', reason = null, detail = null
|
|
447
|
+
const deadline = Date.now() + Math.min(TASK_TIMEOUT_MS, 180000)
|
|
448
|
+
let lastSeq = before
|
|
449
|
+
while (Date.now() < deadline) {
|
|
450
|
+
await new Promise((r) => setTimeout(r, 1200))
|
|
451
|
+
let pg = null
|
|
452
|
+
try { pg = await sc.page({ sessionId }, AbortSignal.timeout(8000)) } catch {}
|
|
453
|
+
const seqNow = Number((pg && (pg.lastSeq ?? pg.asOfSeq)) || 0)
|
|
454
|
+
if (seqNow > lastSeq) lastSeq = seqNow
|
|
455
|
+
const events = (pg && pg.events) || []
|
|
456
|
+
for (const e of events) {
|
|
457
|
+
if (e.type === 'assistant/message') {
|
|
458
|
+
const b = (e.data && e.data.message && e.data.message.content) || []
|
|
459
|
+
const joined = b.filter((x) => x.type === 'text').map((x) => x.text).join('')
|
|
460
|
+
if (joined) out = joined
|
|
461
|
+
}
|
|
462
|
+
if (e.type === 'turn/end') {
|
|
463
|
+
reason = e.data && e.data.reason
|
|
464
|
+
if (reason && reason.kind === 'error' && reason.error) detail = { code: reason.error.code, message: String(reason.error.message || '').slice(0, 300) }
|
|
465
|
+
}
|
|
466
|
+
}
|
|
467
|
+
if (out || detail) break
|
|
468
|
+
if (pg && pg.idle === true) break
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
try {
|
|
472
|
+
const dbg = join(keeper ? keeper.dataDir : KERNEL, 'task-debug.jsonl')
|
|
473
|
+
appendFileSync(dbg, JSON.stringify({ at: new Date().toISOString(), via: 'sessionController', sessionId, created, lastSeq, text: out.slice(0, 200), reason, detail }) + NL)
|
|
474
|
+
} catch {}
|
|
475
|
+
|
|
476
|
+
pushAction({ action: 'task', ok: !!out, detail: 'session=' + sessionId + ' created=' + created + ' len=' + out.length })
|
|
477
|
+
return { ok: true, sessionId, created, text: out, reason: reason ? String(reason.kind || reason) : null, detail }
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
if (p === '/task' && req.method === 'POST') {
|
|
481
|
+
const body = await readBody(req)
|
|
482
|
+
const r = await runTask(String((body && body.text) || ''), body || {})
|
|
483
|
+
return send(res, r, r.ok ? 200 : 500)
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
if (p === '/scan' && req.method === 'POST') {
|
|
487
|
+
const body = await readBody(req)
|
|
488
|
+
const r = await scanAndAttach(Number(body && body.limit) || 80)
|
|
489
|
+
pushAction({ action: 'scan', ok: true, detail: '扫描 ' + r.scanned + ',登记 ' + r.attached })
|
|
490
|
+
return send(res, { ok: true, ...r })
|
|
491
|
+
}
|
|
492
|
+
if (p === '/attach' && req.method === 'POST') {
|
|
493
|
+
// 把「外部(桥/headless)写出来的会话」登记进宿主的工作区:
|
|
494
|
+
// 外部进程调不到宿主的 RPC(配对 fence),但宿主内插件可以直接用 workspaceRegistry。
|
|
495
|
+
const body = await readBody(req)
|
|
496
|
+
const sessionId = String((body && body.sessionId) || '').trim()
|
|
497
|
+
const title = (body && body.title) || ''
|
|
498
|
+
const want = (body && body.workspaceId) || ''
|
|
499
|
+
if (!sessionId) return send(res, { ok: false, error: 'sessionId required' }, 400)
|
|
500
|
+
const reg = webCtx.workspaceRegistry
|
|
501
|
+
if (!reg) return send(res, { ok: false, error: 'workspaceRegistry 服务不可用' }, 503)
|
|
502
|
+
let list = []
|
|
503
|
+
try { list = await reg.list() } catch (e) { try { list = reg.listSync?.() || [] } catch {} }
|
|
504
|
+
const items = Array.isArray(list) ? list : (list && list.items) || []
|
|
505
|
+
const target = want
|
|
506
|
+
? items.find((w) => String(w.id ?? w.workspaceId) === want)
|
|
507
|
+
: items.find((w) => String(w.path || '').toLowerCase() === String((body && body.cwd) || '').toLowerCase())
|
|
508
|
+
|| items[0]
|
|
509
|
+
if (!target) return send(res, { ok: false, error: '未找到目标工作区', workspaces: items.map((w) => ({ id: w.id, path: w.path })) }, 404)
|
|
510
|
+
const wsId = target.id ?? target.workspaceId
|
|
511
|
+
try {
|
|
512
|
+
await target.attachSession(sessionId)
|
|
513
|
+
} catch (e) {
|
|
514
|
+
return send(res, { ok: false, error: 'attachSession 失败: ' + String(e?.message ?? e), workspaceId: wsId, path: target.path }, 500)
|
|
515
|
+
}
|
|
516
|
+
let renamed = false
|
|
517
|
+
try {
|
|
518
|
+
if (title && webCtx.sessions && typeof webCtx.sessions.rename === 'function') { await webCtx.sessions.rename(sessionId, title); renamed = true }
|
|
519
|
+
} catch {}
|
|
520
|
+
pushAction({ action: 'attach', ok: true, detail: sessionId + ' → 工作区 ' + target.path + (renamed ? '(已改名)' : '') })
|
|
521
|
+
return send(res, { ok: true, sessionId, workspaceId: wsId, workspacePath: target.path, renamed })
|
|
522
|
+
}
|
|
523
|
+
if (p === '/svc') {
|
|
524
|
+
// 探针:宿主进程内我们能拿到哪些服务(用于判断能否在宿主里创建会话)
|
|
525
|
+
const probe = (fn) => { try { return fn() } catch (e) { return 'ERR:' + String(e?.message ?? e).slice(0, 60) } }
|
|
526
|
+
const seen = {
|
|
527
|
+
workspaceRegistry: probe(() => !!webCtx.workspaceRegistry),
|
|
528
|
+
sessions: probe(() => !!webCtx.sessions),
|
|
529
|
+
agents: probe(() => !!webCtx.agents),
|
|
530
|
+
sessionPersistence: probe(() => !!webCtx.sessionPersistence),
|
|
531
|
+
sessionQuery: probe(() => !!webCtx.sessionQuery),
|
|
532
|
+
llm: probe(() => !!webCtx.llm),
|
|
533
|
+
typert: probe(() => !!webCtx.typert),
|
|
534
|
+
storage: probe(() => !!webCtx.storage),
|
|
535
|
+
}
|
|
536
|
+
const ctxSeen = { defaultCwd: probe(() => !!webCtx.defaultCwd), sessionTitle: probe(() => typeof webCtx.get === 'function' ? !!webCtx.get('sessionTitle') : 'no ctx.get') }
|
|
537
|
+
const reg = probe(() => (webCtx.workspaceRegistry && typeof webCtx.workspaceRegistry.list === 'function') ? 'list() 可用' : '无 list()')
|
|
538
|
+
const ses = probe(() => (webCtx.sessions && typeof webCtx.sessions.create === 'function') ? 'create() 可用' : '无 create()')
|
|
539
|
+
const ag = probe(() => (webCtx.agents && typeof webCtx.agents.createAgent === 'function') ? 'createAgent() 可用' : (webCtx.agents ? Object.getOwnPropertyNames(Object.getPrototypeOf(webCtx.agents)).slice(0, 12).join(',') : '无'))
|
|
540
|
+
const sc = probe(() => {
|
|
541
|
+
const c = webCtx.sessionController
|
|
542
|
+
if (!c) return '无'
|
|
543
|
+
return '方法: ' + Object.getOwnPropertyNames(Object.getPrototypeOf(c)).filter((k) => k !== 'constructor').slice(0, 22).join(',')
|
|
544
|
+
})
|
|
545
|
+
return send(res, { ok: true, services: seen, context: ctxSeen, workspaceRegistryFace: reg, sessionsFace: ses, agentsFace: ag, sessionControllerFace: sc })
|
|
546
|
+
}
|
|
547
|
+
if (p === '/qr/start' && req.method === 'POST') {
|
|
548
|
+
if (!pairing) return send(res, { ok: false, error: 'pairing not ready' }, 503)
|
|
549
|
+
const s = await pairing.start()
|
|
550
|
+
pushAction({ action: 'qr/start', ok: !s.error, detail: 'qrcode=' + s.qrcode + ' status=' + s.status })
|
|
551
|
+
return send(res, { ok: !s.error, pairing: s, error: s.error || undefined })
|
|
552
|
+
}
|
|
553
|
+
if (p === '/qr/status') {
|
|
554
|
+
if (!pairing) return send(res, { ok: false, error: 'pairing not ready' }, 503)
|
|
555
|
+
return send(res, { ok: true, pairing: await pairing.poll() })
|
|
556
|
+
}
|
|
557
|
+
if (p === '/qr/verify' && req.method === 'POST') {
|
|
558
|
+
if (!pairing) return send(res, { ok: false, error: 'pairing not ready' }, 503)
|
|
559
|
+
const body = await readBody(req)
|
|
560
|
+
pairing.setVerifyCode(body && body.code)
|
|
561
|
+
return send(res, { ok: true, pairing: await pairing.poll() })
|
|
562
|
+
}
|
|
563
|
+
if (p === '/qr/stop' && req.method === 'POST') {
|
|
564
|
+
if (!pairing) return send(res, { ok: false, error: 'pairing not ready' }, 503)
|
|
565
|
+
return send(res, { ok: true, pairing: pairing.stop() })
|
|
566
|
+
}
|
|
567
|
+
if (!keeper) return send(res, { ok: false, error: 'not booted' }, 503)
|
|
568
|
+
if (p === '/start') {
|
|
569
|
+
const st = keeper.status()
|
|
570
|
+
if (st.pidOk) { pushAction({ action: 'start', ok: true, detail: 'already running pid=' + st.pid }); return send(res, { ok: true, already: true, pid: st.pid }) }
|
|
571
|
+
const pid = keeper.startBridge('panel', true)
|
|
572
|
+
pushAction({ action: 'start', ok: true, detail: 'pid=' + pid })
|
|
573
|
+
return send(res, { ok: true, pid })
|
|
574
|
+
}
|
|
575
|
+
if (p === '/stop') {
|
|
576
|
+
const stopped = keeper.stopBridge('panel')
|
|
577
|
+
pushAction({ action: 'stop', ok: true, detail: 'stopped=' + stopped + '(人工停止粘性:自检不再自动拉起,直到点「启动」)' })
|
|
578
|
+
return send(res, { ok: true, stopped })
|
|
579
|
+
}
|
|
580
|
+
if (p === '/restart') {
|
|
581
|
+
keeper.stopBridge('panel-restart')
|
|
582
|
+
await new Promise((r) => setTimeout(r, 1500))
|
|
583
|
+
const pid = keeper.startBridge('panel-restart')
|
|
584
|
+
pushAction({ action: 'restart', ok: true, detail: 'new pid=' + pid })
|
|
585
|
+
return send(res, { ok: true, pid })
|
|
586
|
+
}
|
|
587
|
+
if (p === '/tick') {
|
|
588
|
+
const r = keeper.tick()
|
|
589
|
+
pushAction({ action: 'tick', ok: true, detail: JSON.stringify(r) })
|
|
590
|
+
return send(res, { ok: true, result: r })
|
|
591
|
+
}
|
|
592
|
+
// ===== 手机通道预设:读名册 / 写选定值到配置文件(桥每条任务重读配置,改完即生效)=====
|
|
593
|
+
if (p === '/presets') {
|
|
594
|
+
const cfgNow = readJsonFile(configPath())
|
|
595
|
+
const sel = (cfgNow.acp && typeof cfgNow.acp === 'object' && typeof cfgNow.acp.preset === 'string') ? cfgNow.acp.preset : ''
|
|
596
|
+
let out = []
|
|
597
|
+
try { out = listPresets() } catch (e) { lastError = String(e?.message ?? e) }
|
|
598
|
+
return send(res, { ok: true, presets: out, selected: sel, configFile: configPath(),
|
|
599
|
+
note: '预设来自 DSH 名册(用户 .agent-presets + 运行时出厂树);留空=跟随默认预设' })
|
|
600
|
+
}
|
|
601
|
+
if (p === '/preset' && req.method === 'POST') {
|
|
602
|
+
const body = await readBody(req)
|
|
603
|
+
const f = configPath()
|
|
604
|
+
const keep = readJsonFile(f)
|
|
605
|
+
const want = body && typeof body.preset === 'string' ? body.preset.trim() : ''
|
|
606
|
+
keep.acp = Object.assign({}, (keep.acp && typeof keep.acp === 'object') ? keep.acp : {}, { preset: want })
|
|
607
|
+
mkdirSync(dirname(f), { recursive: true })
|
|
608
|
+
writeFileSync(f, JSON.stringify(keep, null, 2))
|
|
609
|
+
pushAction({ action: 'preset-save', ok: true, detail: want || '(空=跟随 DSH 默认预设)' })
|
|
610
|
+
return send(res, { ok: true, preset: want, configFile: f, note: '下一条微信任务即生效(桥每条任务都重读配置)' })
|
|
611
|
+
}
|
|
612
|
+
if (p === '/config' && req.method === 'POST') {
|
|
613
|
+
const body = await readBody(req)
|
|
614
|
+
const f = configPath()
|
|
615
|
+
const keep = readJsonFile(f)
|
|
616
|
+
for (const k of ['dataDir', 'cwd', 'vault', 'dshBin', 'intervalMs', 'staleMs']) if (body && body[k]) keep[k] = body[k]
|
|
617
|
+
if (body && typeof body.prompt === 'string') keep.prompt = body.prompt
|
|
618
|
+
mkdirSync(dirname(f), { recursive: true })
|
|
619
|
+
writeFileSync(f, JSON.stringify(keep, null, 2))
|
|
620
|
+
return send(res, { ok: true, written: f, applied: keep, note: '提示词下次任务即生效;其余项重启宿主后生效' })
|
|
621
|
+
}
|
|
622
|
+
if (p === '/prompt') {
|
|
623
|
+
if (req.method === 'POST') {
|
|
624
|
+
const body = await readBody(req)
|
|
625
|
+
const f = configPath()
|
|
626
|
+
const keep = readJsonFile(f)
|
|
627
|
+
keep.prompt = typeof body.prompt === 'string' ? body.prompt : ''
|
|
628
|
+
mkdirSync(dirname(f), { recursive: true })
|
|
629
|
+
writeFileSync(f, JSON.stringify(keep, null, 2))
|
|
630
|
+
pushAction({ action: 'prompt-save', ok: true, detail: '长度 ' + keep.prompt.length + '(空=不注入任何提示词)' })
|
|
631
|
+
return send(res, { ok: true, prompt: keep.prompt, note: '下一条微信任务即生效(桥每条任务都重读配置)' })
|
|
632
|
+
}
|
|
633
|
+
const f = configPath()
|
|
634
|
+
const keep = readJsonFile(f)
|
|
635
|
+
let contractTemplate = ''
|
|
636
|
+
try { contractTemplate = readFileSync(join(KERNEL, 'second-brain-contract.txt'), 'utf8') } catch {}
|
|
637
|
+
return send(res, { ok: true, prompt: typeof keep.prompt === 'string' ? keep.prompt : null,
|
|
638
|
+
usingDefault: typeof keep.prompt !== 'string', contractTemplate, configFile: f })
|
|
639
|
+
}
|
|
640
|
+
return send(res, { ok: false, path: p }, 404)
|
|
641
|
+
} catch (e) {
|
|
642
|
+
lastError = String(e?.message ?? e)
|
|
643
|
+
pushAction({ action: p, ok: false, detail: 'error: ' + lastError })
|
|
644
|
+
return send(res, { ok: false, error: lastError }, 500)
|
|
645
|
+
}
|
|
646
|
+
},
|
|
647
|
+
})
|
|
648
|
+
return disposer
|
|
649
|
+
})
|
|
650
|
+
}
|