dsh-multi-session 0.0.0-stage → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +85 -0
- package/LICENSE +21 -0
- package/README.md +158 -2
- package/client.js +1972 -0
- package/cordis.patch.yml +3 -0
- package/icon.png +0 -0
- package/icon.svg +6 -0
- package/index.js +767 -0
- package/package.json +68 -4
package/index.js
ADDED
|
@@ -0,0 +1,767 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* dsh-multi-session — Host 半边。
|
|
3
|
+
*
|
|
4
|
+
* 主要职责:
|
|
5
|
+
* - `/digest`:读某个会话的事件日志、折叠成"我 ↔ AI"的对话条目,供并排列渲染;
|
|
6
|
+
* - `/prompt`:从面板里给别的会话发消息 / 停止运行;
|
|
7
|
+
* - `/report`:客户端异常落盘,开发期排查用;
|
|
8
|
+
* - dev 路由(`/probe` `/stamp` `/reload`)仅在 `dev: true` 时注册。
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { mkdir, readFile, writeFile } from 'node:fs/promises'
|
|
12
|
+
import { dirname, join } from 'node:path'
|
|
13
|
+
import { tmpdir } from 'node:os'
|
|
14
|
+
|
|
15
|
+
export const name = 'dsh-multi-session'
|
|
16
|
+
|
|
17
|
+
/** webServer 用来挂路由;connection 用来做同源/鉴权闸门(裸路由不继承 RPC 鉴权)。 */
|
|
18
|
+
export const inject = ['webServer', 'connection']
|
|
19
|
+
|
|
20
|
+
const ROUTE = '/plugins/dsh-multi-session/probe'
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* 开发期自助重载:`clientModules.rebuilt(id)` 是构建变化进入 Web 图表的唯一入口,
|
|
24
|
+
* 图表 rev 一变,已打开的页面就会拿到新 bundle。改完 client.js 敲一下这条路由,
|
|
25
|
+
* 不用每次 remove_bundle + install_bundle。
|
|
26
|
+
*/
|
|
27
|
+
const RELOAD_ROUTE = '/plugins/dsh-multi-session/reload'
|
|
28
|
+
|
|
29
|
+
const PACKAGE_ID = 'dsh-multi-session'
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* 开发期自动重载:注入到 index.html 的轮询脚本。
|
|
33
|
+
* 页面每 2 秒问一次「现在服务端发布的 bundle rev 是多少」,变了就 location.reload()。
|
|
34
|
+
* 配合 STAMP_ROUTE(内部调用 clientModules.rebuilt 发布新字节),改完 client.js
|
|
35
|
+
* 页面会自己刷新,不用手动 F5。由插件配置 `dev: true` 控制开关。
|
|
36
|
+
*/
|
|
37
|
+
|
|
38
|
+
const STAMP_ROUTE = '/plugins/dsh-multi-session/stamp'
|
|
39
|
+
|
|
40
|
+
/** 客户端异常落盘:页面一加载就装全局 error / unhandledrejection 钩子。 */
|
|
41
|
+
const ERROR_ROUTE = '/plugins/dsh-multi-session/report'
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* 客户端异常落盘目录。
|
|
45
|
+
* 用系统临时目录 + 插件名子目录,卸载即无残留。
|
|
46
|
+
*/
|
|
47
|
+
const DIAG_DIR = join(tmpdir(), 'dsh-multi-session')
|
|
48
|
+
|
|
49
|
+
const ERROR_FILE = join(DIAG_DIR, 'client-errors.json')
|
|
50
|
+
|
|
51
|
+
/** 探针落盘位置(仅 dev 时写入)。 */
|
|
52
|
+
const PROBE_FILE = join(DIAG_DIR, 'probe.json')
|
|
53
|
+
|
|
54
|
+
/** 最多保留多少次探针结果,避免反复刷新页面把文件撑大。 */
|
|
55
|
+
const MAX_PROBES = 20
|
|
56
|
+
|
|
57
|
+
/** 会话对话摘要:面板里点开某个会话时由 Host 侧读真实事件日志再折叠。 */
|
|
58
|
+
const DIGEST_ROUTE = '/plugins/dsh-multi-session/digest'
|
|
59
|
+
const PROMPT_ROUTE = '/plugins/dsh-multi-session/prompt'
|
|
60
|
+
const PROMPT_TIMEOUT_MS = 15000
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* 每个会话的摘要缓存。readSurface 一次要 2.5 秒(整段日志重放),
|
|
64
|
+
* 而面板是多列同时刷新,必须共享同一次结果,否则 Host 侧会被拖死。
|
|
65
|
+
* 键是 `${mode}:${sessionId}:${limit}`;live 会话短 TTL,空闲会话长 TTL。
|
|
66
|
+
*/
|
|
67
|
+
const digestCache = new Map()
|
|
68
|
+
/**
|
|
69
|
+
* live 会话缓存只留 800ms:客户端每 1.5 秒轮一次,如果 TTL 比轮询间隔还长,
|
|
70
|
+
* 每次都会命中旧 payload,看起来就是"不实时"。留一点余量是为了让同一次
|
|
71
|
+
* 刷新里的多个请求(多个并排列、诊断)共享一次重放,又不至于挡住新内容。
|
|
72
|
+
*/
|
|
73
|
+
const DIGEST_TTL_LIVE = 800
|
|
74
|
+
const DIGEST_TTL_IDLE = 60000
|
|
75
|
+
const DIGEST_CACHE_MAX = 200
|
|
76
|
+
|
|
77
|
+
/** 从消息体里抽纯文本:content 是 block 数组(type:'text'),同时兼容字符串形态。 */
|
|
78
|
+
function textOfMessage(message) {
|
|
79
|
+
if (message === null || typeof message !== 'object') return ''
|
|
80
|
+
const content = message.content
|
|
81
|
+
if (typeof content === 'string') return content
|
|
82
|
+
if (!Array.isArray(content)) return ''
|
|
83
|
+
const parts = []
|
|
84
|
+
for (const block of content) {
|
|
85
|
+
if (block === null || typeof block !== 'object') continue
|
|
86
|
+
if (block.type === 'text' && typeof block.text === 'string' && block.text !== '') parts.push(block.text)
|
|
87
|
+
}
|
|
88
|
+
return parts.join('\n')
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* 宿主往 user/message 里塞的"假用户消息",第一行特征。
|
|
93
|
+
* 这些都不是人打的字:运行时快照、时间戳、技能目录变更、压缩摘要。
|
|
94
|
+
* 不过滤掉的话,并排视图里会混进几千字的系统文本,把真正的对话顶走。
|
|
95
|
+
*/
|
|
96
|
+
const INJECTED_HEADS = [
|
|
97
|
+
'Current runtime context',
|
|
98
|
+
'Time sampled while',
|
|
99
|
+
'The available skill catalog changed',
|
|
100
|
+
'[Compressed conversation section]',
|
|
101
|
+
'Browser time zone for this request',
|
|
102
|
+
'Your task is to',
|
|
103
|
+
]
|
|
104
|
+
|
|
105
|
+
/** 去掉 <system-reminder>…</system-reminder> 整段(技能目录、审批策略变更等都在里面)。 */
|
|
106
|
+
function dropSystemReminders(text) {
|
|
107
|
+
return text.replace(/<system-reminder>[\s\S]*?<\/system-reminder>/gi, '').trim()
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* 把一条 user/message 洗成"人真正打的那句话"。
|
|
112
|
+
* 返回空串表示这条整条都是注入,不该显示。
|
|
113
|
+
*/
|
|
114
|
+
function cleanUserText(raw) {
|
|
115
|
+
const text = dropSystemReminders(String(raw ?? ''))
|
|
116
|
+
if (text === '') return ''
|
|
117
|
+
const head = text.slice(0, 120)
|
|
118
|
+
for (const marker of INJECTED_HEADS) {
|
|
119
|
+
if (head.startsWith(marker)) return ''
|
|
120
|
+
}
|
|
121
|
+
return text
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* 把原始事件日志折叠成面板能直接渲染的对话条目。
|
|
126
|
+
* 同时兼容两种来源:
|
|
127
|
+
* - readSession / readEvent 的完整事件(有 turn/start、turn/end)
|
|
128
|
+
* - readSurface 的模型面事件(没有 turn/start)
|
|
129
|
+
* 因此轮数在拿不到 turn/start 时退化为"用户消息条数"。
|
|
130
|
+
*
|
|
131
|
+
* 只保留 user/message 与 assistant/message 两种正文:并排视图的用途是
|
|
132
|
+
* "一眼看清这几个会话各自聊到哪了",工具调用、工具结果、思考过程都属于噪音,
|
|
133
|
+
* 一律不搬进 items(Host 侧折叠阶段就丢掉,客户端只做兜底)。
|
|
134
|
+
*/
|
|
135
|
+
function foldDigest(events, limit) {
|
|
136
|
+
const items = []
|
|
137
|
+
let turns = 0
|
|
138
|
+
let userTurns = 0
|
|
139
|
+
let endReason = null
|
|
140
|
+
for (const event of Array.isArray(events) ? events : []) {
|
|
141
|
+
if (event === null || typeof event !== 'object') continue
|
|
142
|
+
const data = event.data !== null && typeof event.data === 'object' ? event.data : {}
|
|
143
|
+
const at = typeof event.time === 'number' ? event.time : 0
|
|
144
|
+
if (event.type === 'turn/start') {
|
|
145
|
+
turns = Math.max(turns, Number(data.turn) || 0)
|
|
146
|
+
} else if (event.type === 'turn/end') {
|
|
147
|
+
endReason = data.reason !== null && typeof data.reason === 'object' ? String(data.reason.kind ?? '') : null
|
|
148
|
+
} else if (event.type === 'user/message') {
|
|
149
|
+
userTurns += 1
|
|
150
|
+
const text = cleanUserText(textOfMessage(data)).trim()
|
|
151
|
+
if (text !== '') items.push({ kind: 'user', text: text.length > 1500 ? `${text.slice(0, 1500)}…` : text, at })
|
|
152
|
+
} else if (event.type === 'assistant/message') {
|
|
153
|
+
// 助手消息也可能带 system-reminder(极少见),一起洗掉。
|
|
154
|
+
const text = dropSystemReminders(textOfMessage(data.message)).trim()
|
|
155
|
+
if (text !== '') {
|
|
156
|
+
items.push({ kind: 'assistant', text: text.length > 3000 ? `${text.slice(0, 3000)}…` : text, at })
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
// tool/call、tool/result 一律不搬:并排视图只保留"我 ↔ AI 的对话正文",
|
|
160
|
+
// 工具调用和它们的输出对"看清这段聊了什么"是纯噪音。
|
|
161
|
+
}
|
|
162
|
+
return { items: items.slice(-limit), turns: turns > 0 ? turns : userTurns, endReason }
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
function sendJson(res, status, value, options = {}) {
|
|
166
|
+
const body = JSON.stringify(value ?? null)
|
|
167
|
+
res.statusCode = status
|
|
168
|
+
res.setHeader('content-type', 'application/json; charset=utf-8')
|
|
169
|
+
res.setHeader('cache-control', 'no-store')
|
|
170
|
+
for (const [key, headerValue] of Object.entries(options.headers ?? {})) {
|
|
171
|
+
res.setHeader(key, headerValue)
|
|
172
|
+
}
|
|
173
|
+
res.end(body)
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
function sendText(res, status, text) {
|
|
177
|
+
res.statusCode = status
|
|
178
|
+
res.setHeader('content-type', 'text/plain; charset=utf-8')
|
|
179
|
+
res.setHeader('cache-control', 'no-store')
|
|
180
|
+
res.end(text)
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/** 读取请求体(有上限,防止探针把内存吃满)。 */
|
|
184
|
+
async function readBody(req, limit = 4 * 1024 * 1024) {
|
|
185
|
+
const chunks = []
|
|
186
|
+
let size = 0
|
|
187
|
+
for await (const chunk of req) {
|
|
188
|
+
size += chunk.length
|
|
189
|
+
if (size > limit) throw new Error('request body too large')
|
|
190
|
+
chunks.push(chunk)
|
|
191
|
+
}
|
|
192
|
+
return Buffer.concat(chunks).toString('utf8')
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
async function appendError(payload) {
|
|
196
|
+
let history = []
|
|
197
|
+
try {
|
|
198
|
+
const raw = await readFile(ERROR_FILE, 'utf8')
|
|
199
|
+
const parsed = JSON.parse(raw)
|
|
200
|
+
if (Array.isArray(parsed)) history = parsed
|
|
201
|
+
} catch {
|
|
202
|
+
/* 文件不存在:从空历史开始 */
|
|
203
|
+
}
|
|
204
|
+
history.push({ at: new Date().toISOString(), payload })
|
|
205
|
+
if (history.length > 40) history = history.slice(-40)
|
|
206
|
+
await mkdir(dirname(ERROR_FILE), { recursive: true })
|
|
207
|
+
await writeFile(ERROR_FILE, JSON.stringify(history, null, 2), 'utf8')
|
|
208
|
+
return history.length
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
/** 注入到 index.html 的报错钩子:只报告,不阻断任何东西。 */
|
|
212
|
+
const buildAutoreloadTap = (initialRev) => `<script>
|
|
213
|
+
(function () {
|
|
214
|
+
var INITIAL = ${JSON.stringify(initialRev || '')}
|
|
215
|
+
var ROUTE = '${STAMP_ROUTE}'
|
|
216
|
+
var reloads = 0
|
|
217
|
+
var pending = null
|
|
218
|
+
var pendingCount = 0
|
|
219
|
+
var RELOAD_CAP = 30
|
|
220
|
+
function check() {
|
|
221
|
+
if (reloads > RELOAD_CAP) return
|
|
222
|
+
fetch(ROUTE, { cache: 'no-store' })
|
|
223
|
+
.then(function (response) { return response.json() })
|
|
224
|
+
.then(function (body) {
|
|
225
|
+
var rev = body && body.rev
|
|
226
|
+
if (!rev || !INITIAL) { INITIAL = rev || INITIAL; return }
|
|
227
|
+
if (rev === INITIAL) { pending = null; pendingCount = 0; return }
|
|
228
|
+
// 防抖:连续两次看到同一个新 rev 才刷新,避免写入过程中的中间态反复刷新。
|
|
229
|
+
if (pending === rev) {
|
|
230
|
+
pendingCount++
|
|
231
|
+
if (pendingCount >= 1) { reloads++; location.reload() }
|
|
232
|
+
} else {
|
|
233
|
+
pending = rev
|
|
234
|
+
pendingCount = 0
|
|
235
|
+
}
|
|
236
|
+
})
|
|
237
|
+
.catch(function () {})
|
|
238
|
+
}
|
|
239
|
+
setInterval(check, 2000)
|
|
240
|
+
})()
|
|
241
|
+
</script>`
|
|
242
|
+
|
|
243
|
+
/** 注入到 index.html 的报错钩子:只报告,不阻断任何东西。 */
|
|
244
|
+
const ERROR_TAP = `<script>
|
|
245
|
+
(function () {
|
|
246
|
+
var ROUTE = '${ERROR_ROUTE}'
|
|
247
|
+
var seen = 0
|
|
248
|
+
function send(kind, message, extra) {
|
|
249
|
+
if (seen++ > 20) return
|
|
250
|
+
try {
|
|
251
|
+
fetch(ROUTE, {
|
|
252
|
+
method: 'POST',
|
|
253
|
+
headers: { 'content-type': 'application/json' },
|
|
254
|
+
body: JSON.stringify({
|
|
255
|
+
kind: kind,
|
|
256
|
+
message: String(message || ''),
|
|
257
|
+
url: location.href,
|
|
258
|
+
extra: extra || null,
|
|
259
|
+
at: new Date().toISOString()
|
|
260
|
+
})
|
|
261
|
+
}).catch(function () {})
|
|
262
|
+
} catch (cause) {}
|
|
263
|
+
}
|
|
264
|
+
window.addEventListener('error', function (event) {
|
|
265
|
+
send('error', event.message, {
|
|
266
|
+
source: event.filename,
|
|
267
|
+
line: event.lineno,
|
|
268
|
+
column: event.colno,
|
|
269
|
+
stack: event.error && event.error.stack ? String(event.error.stack).slice(0, 2000) : null
|
|
270
|
+
})
|
|
271
|
+
})
|
|
272
|
+
window.addEventListener('unhandledrejection', function (event) {
|
|
273
|
+
var reason = event.reason
|
|
274
|
+
send('rejection', reason && reason.message ? reason.message : reason, {
|
|
275
|
+
stack: reason && reason.stack ? String(reason.stack).slice(0, 2000) : null
|
|
276
|
+
})
|
|
277
|
+
})
|
|
278
|
+
send('boot', 'index loaded')
|
|
279
|
+
})()
|
|
280
|
+
</script>`
|
|
281
|
+
|
|
282
|
+
/**
|
|
283
|
+
* 探针写入必须串行:客户端可能同时 POST 多条(诊断 + 一次性探针),
|
|
284
|
+
* 并发 read-modify-write 会把 JSON 写坏(实测撞过一次)。
|
|
285
|
+
*/
|
|
286
|
+
let probeChain = Promise.resolve()
|
|
287
|
+
|
|
288
|
+
function appendProbe(payload) {
|
|
289
|
+
probeChain = probeChain.then(async () => {
|
|
290
|
+
let history = []
|
|
291
|
+
try {
|
|
292
|
+
const raw = await readFile(PROBE_FILE, 'utf8')
|
|
293
|
+
const parsed = JSON.parse(raw)
|
|
294
|
+
if (Array.isArray(parsed)) history = parsed
|
|
295
|
+
} catch {
|
|
296
|
+
/* 文件不存在或已损坏:从空历史开始,不打断这次探针 */
|
|
297
|
+
}
|
|
298
|
+
history.push({ at: new Date().toISOString(), payload })
|
|
299
|
+
if (history.length > MAX_PROBES) history = history.slice(-MAX_PROBES)
|
|
300
|
+
await mkdir(dirname(PROBE_FILE), { recursive: true })
|
|
301
|
+
await writeFile(PROBE_FILE, JSON.stringify(history, null, 2), 'utf8')
|
|
302
|
+
return history.length
|
|
303
|
+
})
|
|
304
|
+
return probeChain
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
export function apply(ctx, config) {
|
|
308
|
+
const get = (key) => (typeof ctx?.get === 'function' ? ctx.get(key) : undefined) ?? ctx?.[key]
|
|
309
|
+
const webServer = get('webServer')
|
|
310
|
+
// 开发期设施(探针上报、手动/自动重载)默认关闭,正式安装只留业务路由。
|
|
311
|
+
// 需要在 profile 的 cordis.patch.yml 里给本插件配 `dev: true` 才打开。
|
|
312
|
+
const DEV = config?.dev === true || process.env.DSH_MULTI_SESSION_DEV === '1'
|
|
313
|
+
|
|
314
|
+
if (!webServer || typeof webServer.register !== 'function') {
|
|
315
|
+
console.warn(
|
|
316
|
+
`[dsh-multi-session] webServer service has no register(); the probe route ${ROUTE} is NOT registered.`,
|
|
317
|
+
)
|
|
318
|
+
return
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
const connectionOf = () => get('connection')
|
|
322
|
+
|
|
323
|
+
// 页面一加载就装上全局报错钩子:客户端崩溃也能留下证据。
|
|
324
|
+
// 同时把 DEV 标志交给客户端,诊断层据此决定要不要往 /probe 上报。
|
|
325
|
+
// dev 打开时再叠加一个 rev 轮询,代码一变页面自己刷新。
|
|
326
|
+
if (typeof webServer.tapIndex === 'function') {
|
|
327
|
+
ctx.effect(() =>
|
|
328
|
+
webServer.tapIndex((html) => {
|
|
329
|
+
if (typeof html !== 'string' || !html.includes('</head>')) return html
|
|
330
|
+
const flagTap = `<script>window.__DSH_MULTI_SESSION_DEV__=${DEV ? 'true' : 'false'}</script>`
|
|
331
|
+
let injected = `${flagTap}${ERROR_TAP}`
|
|
332
|
+
if (DEV) {
|
|
333
|
+
const clientModules = get('clientModules')
|
|
334
|
+
let rev = ''
|
|
335
|
+
try {
|
|
336
|
+
const graph = clientModules && typeof clientModules.graph === 'function' ? clientModules.graph() : null
|
|
337
|
+
const entry = graph && Array.isArray(graph.entries)
|
|
338
|
+
? graph.entries.find((item) => item && item.id === PACKAGE_ID)
|
|
339
|
+
: null
|
|
340
|
+
rev = entry && entry.rev ? String(entry.rev) : ''
|
|
341
|
+
} catch (error) {
|
|
342
|
+
rev = ''
|
|
343
|
+
}
|
|
344
|
+
injected = `${flagTap}${buildAutoreloadTap(rev)}${ERROR_TAP}`
|
|
345
|
+
}
|
|
346
|
+
return html.replace('</head>', `${injected}</head>`)
|
|
347
|
+
}),
|
|
348
|
+
)
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
ctx.effect(() =>
|
|
352
|
+
webServer.register({
|
|
353
|
+
kind: 'exact',
|
|
354
|
+
path: ERROR_ROUTE,
|
|
355
|
+
async handler(req, res) {
|
|
356
|
+
const method = String(req.method ?? 'GET').toUpperCase()
|
|
357
|
+
if (method === 'GET' || method === 'HEAD') {
|
|
358
|
+
sendJson(res, 200, { ok: true, route: ERROR_ROUTE, errorFile: ERROR_FILE })
|
|
359
|
+
return
|
|
360
|
+
}
|
|
361
|
+
if (method !== 'POST') {
|
|
362
|
+
sendJson(res, 405, { error: 'method not allowed' })
|
|
363
|
+
return
|
|
364
|
+
}
|
|
365
|
+
try {
|
|
366
|
+
const raw = await readBody(req, 256 * 1024)
|
|
367
|
+
const payload = JSON.parse(raw)
|
|
368
|
+
const kept = await appendError(payload)
|
|
369
|
+
sendJson(res, 200, { ok: true, kept })
|
|
370
|
+
} catch (error) {
|
|
371
|
+
sendJson(res, 400, { error: String(error?.message ?? error) })
|
|
372
|
+
}
|
|
373
|
+
},
|
|
374
|
+
}),
|
|
375
|
+
)
|
|
376
|
+
|
|
377
|
+
// 开发期路由统一收口:DEV 关闭时它们根本不存在,正式安装不会多出三个入口。
|
|
378
|
+
const registerDev = (path, handler) => {
|
|
379
|
+
if (!DEV) return
|
|
380
|
+
ctx.effect(() => webServer.register({ kind: 'exact', path, handler }))
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
registerDev(STAMP_ROUTE, async (req, res) => {
|
|
384
|
+
const clientModules = get('clientModules')
|
|
385
|
+
if (!clientModules || typeof clientModules.rebuilt !== 'function') {
|
|
386
|
+
sendJson(res, 200, { rev: null })
|
|
387
|
+
return
|
|
388
|
+
}
|
|
389
|
+
try {
|
|
390
|
+
// rebuilt 会把磁盘上的新字节发布成新一代 bundle(元数据没变则原样返回旧图)。
|
|
391
|
+
const rev = clientModules.rebuilt(PACKAGE_ID)
|
|
392
|
+
sendJson(res, 200, { rev: rev ?? null })
|
|
393
|
+
} catch (error) {
|
|
394
|
+
sendJson(res, 200, { rev: null, error: String(error?.message ?? error) })
|
|
395
|
+
}
|
|
396
|
+
})
|
|
397
|
+
|
|
398
|
+
registerDev(RELOAD_ROUTE, async (req, res) => {
|
|
399
|
+
const gate = connectionOf()
|
|
400
|
+
const rejection = gate === undefined ? 503 : gate.requestRejection(req)
|
|
401
|
+
// 本机开发自助重载:只放行 loopback,其他来源照旧过鉴权闸门。
|
|
402
|
+
const address = String(req?.socket?.remoteAddress ?? '')
|
|
403
|
+
const loopback =
|
|
404
|
+
address === '127.0.0.1' || address === '::1' || address === '::ffff:127.0.0.1'
|
|
405
|
+
if (rejection !== undefined && !loopback) {
|
|
406
|
+
sendJson(res, rejection, { error: 'not authorized' })
|
|
407
|
+
return
|
|
408
|
+
}
|
|
409
|
+
const clientModules = get('clientModules')
|
|
410
|
+
if (!clientModules || typeof clientModules.rebuilt !== 'function') {
|
|
411
|
+
sendJson(res, 503, { error: 'clientModules.rebuilt unavailable' })
|
|
412
|
+
return
|
|
413
|
+
}
|
|
414
|
+
try {
|
|
415
|
+
const rev = clientModules.rebuilt(PACKAGE_ID)
|
|
416
|
+
sendJson(res, 200, { ok: true, rev: rev ?? null })
|
|
417
|
+
} catch (error) {
|
|
418
|
+
sendJson(res, 500, { error: String(error?.message ?? error) })
|
|
419
|
+
}
|
|
420
|
+
})
|
|
421
|
+
|
|
422
|
+
registerDev(ROUTE, async (req, res) => {
|
|
423
|
+
const method = String(req.method ?? 'GET').toUpperCase()
|
|
424
|
+
|
|
425
|
+
// GET 用来确认路由活着(排查"插件装了但没生效")。
|
|
426
|
+
if (method === 'GET' || method === 'HEAD') {
|
|
427
|
+
sendJson(res, 200, { ok: true, route: ROUTE, probeFile: PROBE_FILE })
|
|
428
|
+
return
|
|
429
|
+
}
|
|
430
|
+
if (method !== 'POST') {
|
|
431
|
+
sendJson(res, 405, { error: 'method not allowed' }, { headers: { allow: 'GET, HEAD, POST' } })
|
|
432
|
+
return
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
// 裸 WebServer 路由不继承 Connection 的鉴权围栏,必须自己过一遍。
|
|
436
|
+
const gate = connectionOf()
|
|
437
|
+
const rejection = gate === undefined ? 503 : gate.requestRejection(req)
|
|
438
|
+
if (rejection !== undefined) {
|
|
439
|
+
sendJson(res, rejection, {
|
|
440
|
+
error:
|
|
441
|
+
rejection === 503
|
|
442
|
+
? 'authentication unavailable'
|
|
443
|
+
: rejection === 401
|
|
444
|
+
? 'unauthorized'
|
|
445
|
+
: 'forbidden',
|
|
446
|
+
})
|
|
447
|
+
return
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
try {
|
|
451
|
+
const raw = await readBody(req)
|
|
452
|
+
const payload = JSON.parse(raw)
|
|
453
|
+
const kept = await appendProbe(payload)
|
|
454
|
+
sendJson(res, 200, { ok: true, kept })
|
|
455
|
+
} catch (error) {
|
|
456
|
+
console.warn(`[dsh-multi-session] probe write failed: ${String(error?.message ?? error)}`)
|
|
457
|
+
sendJson(res, 400, { error: String(error?.message ?? error) })
|
|
458
|
+
}
|
|
459
|
+
})
|
|
460
|
+
|
|
461
|
+
// 会话对话摘要:面板点开某个会话时拉一次,Host 侧读真实事件日志(客户端事件窗拿不到)。
|
|
462
|
+
ctx.effect(() =>
|
|
463
|
+
webServer.register({
|
|
464
|
+
kind: 'exact',
|
|
465
|
+
path: DIGEST_ROUTE,
|
|
466
|
+
async handler(req, res) {
|
|
467
|
+
const method = String(req.method ?? 'GET').toUpperCase()
|
|
468
|
+
if (method !== 'GET' && method !== 'HEAD') {
|
|
469
|
+
sendJson(res, 405, { error: 'method not allowed' }, { headers: { allow: 'GET, HEAD' } })
|
|
470
|
+
return
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
const gate = connectionOf()
|
|
474
|
+
const rejection = gate === undefined ? 503 : gate.requestRejection(req)
|
|
475
|
+
if (rejection !== undefined) {
|
|
476
|
+
sendJson(res, rejection, {
|
|
477
|
+
error:
|
|
478
|
+
rejection === 503
|
|
479
|
+
? 'authentication unavailable'
|
|
480
|
+
: rejection === 401
|
|
481
|
+
? 'unauthorized'
|
|
482
|
+
: 'forbidden',
|
|
483
|
+
})
|
|
484
|
+
return
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
let params
|
|
488
|
+
try {
|
|
489
|
+
params = new URL(String(req.url ?? '/'), 'http://127.0.0.1').searchParams
|
|
490
|
+
} catch {
|
|
491
|
+
sendJson(res, 400, { error: 'bad url' })
|
|
492
|
+
return
|
|
493
|
+
}
|
|
494
|
+
|
|
495
|
+
const sessionId = params.get('sessionId')
|
|
496
|
+
if (sessionId === null || sessionId === '') {
|
|
497
|
+
sendJson(res, 400, { error: 'sessionId is required' })
|
|
498
|
+
return
|
|
499
|
+
}
|
|
500
|
+
const rawLimit = Number(params.get('limit'))
|
|
501
|
+
const limit = Number.isFinite(rawLimit) ? Math.min(Math.max(Math.trunc(rawLimit), 1), 600) : 30
|
|
502
|
+
|
|
503
|
+
const sessionQuery = get('sessionQuery')
|
|
504
|
+
if (sessionQuery === undefined || typeof sessionQuery.readSession !== 'function') {
|
|
505
|
+
sendJson(res, 503, { error: 'sessionQuery.readSession unavailable' })
|
|
506
|
+
return
|
|
507
|
+
}
|
|
508
|
+
|
|
509
|
+
try {
|
|
510
|
+
const mode = params.get('mode') || 'observe'
|
|
511
|
+
const startedAt = Date.now()
|
|
512
|
+
const live = params.get('live') === '1'
|
|
513
|
+
const noCache = params.get('fresh') === '1'
|
|
514
|
+
|
|
515
|
+
// 面板会同时刷新多列,同一次 readSurface 不该被重复执行。
|
|
516
|
+
// v3:折叠规则变了(只剩我↔AI 正文),旧缓存必须失效。
|
|
517
|
+
// live 必须进键:运行中的短 TTL 缓存和空闲的长 TTL 缓存不能互相污染,
|
|
518
|
+
// 否则会话刚跑完那一刻,空闲请求会命中"助手最后一句话还没写进去"的旧 payload。
|
|
519
|
+
const cacheKey = `v4:${mode}:${live ? 'live' : 'idle'}:${sessionId}:${limit}`
|
|
520
|
+
const ttl = live ? DIGEST_TTL_LIVE : DIGEST_TTL_IDLE
|
|
521
|
+
if (!noCache) {
|
|
522
|
+
const hit = digestCache.get(cacheKey)
|
|
523
|
+
if (hit !== undefined && startedAt - hit.at < ttl) {
|
|
524
|
+
sendJson(res, 200, { ...hit.payload, ms: Date.now() - startedAt, cached: true, age: startedAt - hit.at })
|
|
525
|
+
return
|
|
526
|
+
}
|
|
527
|
+
}
|
|
528
|
+
|
|
529
|
+
const remember = (payload) => {
|
|
530
|
+
digestCache.set(cacheKey, { at: Date.now(), payload })
|
|
531
|
+
if (digestCache.size > DIGEST_CACHE_MAX) {
|
|
532
|
+
// 简单淘汰:删掉最早插入的一条,够用了。
|
|
533
|
+
const oldest = digestCache.keys().next()
|
|
534
|
+
if (oldest.done !== true) digestCache.delete(oldest.value)
|
|
535
|
+
}
|
|
536
|
+
sendJson(res, 200, { ...payload, cached: false })
|
|
537
|
+
}
|
|
538
|
+
|
|
539
|
+
// 默认取数路径:observeSession 直接给内存/已备好的事件快照,不做 replay 校验,
|
|
540
|
+
// 实测实时会话 ~5ms、139 轮历史会话 ~84ms;readSurface 同条件下要 300ms/2.8s。
|
|
541
|
+
// 只有 observe 拿不到事件时才退回 readSurface。
|
|
542
|
+
if (mode === 'observe') {
|
|
543
|
+
let events = []
|
|
544
|
+
let source = null
|
|
545
|
+
let cursor = null
|
|
546
|
+
let usedObserve = false
|
|
547
|
+
try {
|
|
548
|
+
const lease = await sessionQuery.observeSession(sessionId, { projectionMode: 'none' })
|
|
549
|
+
if (lease && Array.isArray(lease.events) && lease.events.length > 0) {
|
|
550
|
+
events = lease.events
|
|
551
|
+
source = lease.source ?? null
|
|
552
|
+
cursor = lease.cursor ?? null
|
|
553
|
+
usedObserve = true
|
|
554
|
+
}
|
|
555
|
+
// 只读快照,不 retain(retain 会让会话被插件长期持有),用完即释放。
|
|
556
|
+
if (lease && typeof lease.dispose === 'function') {
|
|
557
|
+
try {
|
|
558
|
+
lease.dispose()
|
|
559
|
+
} catch {
|
|
560
|
+
// 忽略:释放失败不影响本次结果。
|
|
561
|
+
}
|
|
562
|
+
}
|
|
563
|
+
} catch {
|
|
564
|
+
// observe 不可用就走下面的兜底。
|
|
565
|
+
}
|
|
566
|
+
|
|
567
|
+
if (!usedObserve) {
|
|
568
|
+
const surface = await sessionQuery.readSurface(sessionId)
|
|
569
|
+
events = surface && Array.isArray(surface.events) ? surface.events : []
|
|
570
|
+
source = 'surface'
|
|
571
|
+
}
|
|
572
|
+
|
|
573
|
+
const folded = foldDigest(events, limit)
|
|
574
|
+
remember({
|
|
575
|
+
ok: true,
|
|
576
|
+
mode: 'observe',
|
|
577
|
+
sessionId,
|
|
578
|
+
ms: Date.now() - startedAt,
|
|
579
|
+
eventCount: events.length,
|
|
580
|
+
source,
|
|
581
|
+
cursor,
|
|
582
|
+
turns: folded.turns,
|
|
583
|
+
endReason: folded.endReason,
|
|
584
|
+
items: folded.items,
|
|
585
|
+
})
|
|
586
|
+
return
|
|
587
|
+
}
|
|
588
|
+
|
|
589
|
+
if (mode === 'list') {
|
|
590
|
+
const records = await sessionQuery.listEvents(sessionId)
|
|
591
|
+
const list = Array.isArray(records) ? records : []
|
|
592
|
+
sendJson(res, 200, {
|
|
593
|
+
ok: true,
|
|
594
|
+
mode,
|
|
595
|
+
sessionId,
|
|
596
|
+
ms: Date.now() - startedAt,
|
|
597
|
+
count: list.length,
|
|
598
|
+
tail: list.slice(-6).map((record) => ({ seq: record.seq, type: record.type })),
|
|
599
|
+
})
|
|
600
|
+
return
|
|
601
|
+
}
|
|
602
|
+
|
|
603
|
+
if (mode === 'surface') {
|
|
604
|
+
const surface = await sessionQuery.readSurface(sessionId)
|
|
605
|
+
const events = surface && Array.isArray(surface.events) ? surface.events : []
|
|
606
|
+
const folded = foldDigest(events, limit)
|
|
607
|
+
remember({
|
|
608
|
+
ok: true,
|
|
609
|
+
mode,
|
|
610
|
+
sessionId,
|
|
611
|
+
ms: Date.now() - startedAt,
|
|
612
|
+
eventCount: events.length,
|
|
613
|
+
turns: folded.turns,
|
|
614
|
+
endReason: folded.endReason,
|
|
615
|
+
items: folded.items,
|
|
616
|
+
})
|
|
617
|
+
return
|
|
618
|
+
}
|
|
619
|
+
|
|
620
|
+
if (mode === 'event') {
|
|
621
|
+
const listStart = Date.now()
|
|
622
|
+
const records = await sessionQuery.listEvents(sessionId)
|
|
623
|
+
const list = Array.isArray(records) ? records : []
|
|
624
|
+
const listMs = Date.now() - listStart
|
|
625
|
+
const wanted = new Set(['user/message', 'assistant/message', 'tool/call', 'turn/end'])
|
|
626
|
+
const interesting = list.filter((record) => wanted.has(record.type))
|
|
627
|
+
const tail = interesting.slice(-Math.min(limit, 4))
|
|
628
|
+
const readStart = Date.now()
|
|
629
|
+
const collected = []
|
|
630
|
+
const perCall = []
|
|
631
|
+
for (const record of tail) {
|
|
632
|
+
const oneStart = Date.now()
|
|
633
|
+
try {
|
|
634
|
+
const window = await sessionQuery.readEvent({ sessionId, seq: record.seq, before: 0, after: 0 })
|
|
635
|
+
if (window && window.target) collected.push(window.target)
|
|
636
|
+
} catch {
|
|
637
|
+
// 单条读失败就跳过,不让整次摘要崩掉。
|
|
638
|
+
}
|
|
639
|
+
perCall.push(Date.now() - oneStart)
|
|
640
|
+
}
|
|
641
|
+
const readMs = Date.now() - readStart
|
|
642
|
+
const folded = foldDigest(collected, limit)
|
|
643
|
+
remember({
|
|
644
|
+
ok: true,
|
|
645
|
+
mode,
|
|
646
|
+
sessionId,
|
|
647
|
+
ms: Date.now() - startedAt,
|
|
648
|
+
listMs,
|
|
649
|
+
readMs,
|
|
650
|
+
perCall,
|
|
651
|
+
eventCount: collected.length,
|
|
652
|
+
turns: folded.turns,
|
|
653
|
+
endReason: folded.endReason,
|
|
654
|
+
items: folded.items,
|
|
655
|
+
})
|
|
656
|
+
return
|
|
657
|
+
}
|
|
658
|
+
|
|
659
|
+
const snapshot = await sessionQuery.readSession(sessionId)
|
|
660
|
+
const events = snapshot && Array.isArray(snapshot.events) ? snapshot.events : []
|
|
661
|
+
const folded = foldDigest(events, limit)
|
|
662
|
+
remember({
|
|
663
|
+
ok: true,
|
|
664
|
+
mode: 'log',
|
|
665
|
+
sessionId,
|
|
666
|
+
header: snapshot && snapshot.session ? snapshot.session : null,
|
|
667
|
+
eventCount: events.length,
|
|
668
|
+
ms: Date.now() - startedAt,
|
|
669
|
+
turns: folded.turns,
|
|
670
|
+
endReason: folded.endReason,
|
|
671
|
+
items: folded.items,
|
|
672
|
+
})
|
|
673
|
+
} catch (error) {
|
|
674
|
+
sendJson(res, 404, { error: String(error?.message ?? error) })
|
|
675
|
+
}
|
|
676
|
+
},
|
|
677
|
+
}),
|
|
678
|
+
)
|
|
679
|
+
|
|
680
|
+
// 在面板里直接给别的会话发消息 / 打断它,不用先切过去。
|
|
681
|
+
// 走 sessionController(Host 侧唯一能 admission 一条 prompt 的入口)。
|
|
682
|
+
ctx.effect(() =>
|
|
683
|
+
webServer.register({
|
|
684
|
+
kind: 'exact',
|
|
685
|
+
path: PROMPT_ROUTE,
|
|
686
|
+
async handler(req, res) {
|
|
687
|
+
const method = String(req.method ?? 'GET').toUpperCase()
|
|
688
|
+
if (method !== 'POST') {
|
|
689
|
+
sendJson(res, 405, { error: 'method not allowed' }, { headers: { allow: 'POST' } })
|
|
690
|
+
return
|
|
691
|
+
}
|
|
692
|
+
|
|
693
|
+
const gate = connectionOf()
|
|
694
|
+
const rejection = gate === undefined ? 503 : gate.requestRejection(req)
|
|
695
|
+
if (rejection !== undefined) {
|
|
696
|
+
sendJson(res, rejection, { error: rejection === 503 ? 'authentication unavailable' : rejection === 401 ? 'unauthorized' : 'forbidden' })
|
|
697
|
+
return
|
|
698
|
+
}
|
|
699
|
+
|
|
700
|
+
let body
|
|
701
|
+
try {
|
|
702
|
+
body = JSON.parse(await readBody(req, 256 * 1024))
|
|
703
|
+
} catch {
|
|
704
|
+
sendJson(res, 400, { error: 'bad json body' })
|
|
705
|
+
return
|
|
706
|
+
}
|
|
707
|
+
|
|
708
|
+
const sessionId = typeof body?.sessionId === 'string' ? body.sessionId : ''
|
|
709
|
+
const text = typeof body?.text === 'string' ? body.text : ''
|
|
710
|
+
const action = body?.action === 'cancel' ? 'cancel' : 'prompt'
|
|
711
|
+
if (sessionId === '') {
|
|
712
|
+
sendJson(res, 400, { error: 'sessionId is required' })
|
|
713
|
+
return
|
|
714
|
+
}
|
|
715
|
+
|
|
716
|
+
const sessionController = get('sessionController')
|
|
717
|
+
if (sessionController === undefined) {
|
|
718
|
+
sendJson(res, 503, { error: 'sessionController unavailable' })
|
|
719
|
+
return
|
|
720
|
+
}
|
|
721
|
+
|
|
722
|
+
try {
|
|
723
|
+
if (action === 'cancel') {
|
|
724
|
+
if (typeof sessionController.cancel !== 'function') {
|
|
725
|
+
sendJson(res, 503, { error: 'sessionController.cancel unavailable' })
|
|
726
|
+
return
|
|
727
|
+
}
|
|
728
|
+
const value = sessionController.cancel({ sessionId })
|
|
729
|
+
sendJson(res, 200, { ok: true, action, sessionId, accepted: value?.accepted === true })
|
|
730
|
+
return
|
|
731
|
+
}
|
|
732
|
+
|
|
733
|
+
if (text.trim() === '') {
|
|
734
|
+
sendJson(res, 400, { error: 'text is required' })
|
|
735
|
+
return
|
|
736
|
+
}
|
|
737
|
+
if (typeof sessionController.prompt !== 'function') {
|
|
738
|
+
sendJson(res, 503, { error: 'sessionController.prompt unavailable' })
|
|
739
|
+
return
|
|
740
|
+
}
|
|
741
|
+
|
|
742
|
+
const controller = new AbortController()
|
|
743
|
+
const timer = setTimeout(() => controller.abort(), PROMPT_TIMEOUT_MS)
|
|
744
|
+
try {
|
|
745
|
+
const value = await sessionController.prompt(
|
|
746
|
+
{
|
|
747
|
+
// requestId 只用于去重/回执,插件自己发起的请求给个带前缀的唯一值即可。
|
|
748
|
+
requestId: `dsh-multi-session-${Date.now()}-${Math.random().toString(36).slice(2, 10)}`,
|
|
749
|
+
sessionId,
|
|
750
|
+
// queue:排进收件箱,不打断正在跑的回合(默认,最安全)。
|
|
751
|
+
// steer:立刻插话打断当前回合,由调用方显式要求。
|
|
752
|
+
mode: body?.mode === 'steer' ? 'steer' : 'queue',
|
|
753
|
+
content: [{ type: 'text', text }],
|
|
754
|
+
},
|
|
755
|
+
controller.signal,
|
|
756
|
+
)
|
|
757
|
+
sendJson(res, 200, { ok: true, action: 'prompt', sessionId, accepted: value?.accepted === true })
|
|
758
|
+
} finally {
|
|
759
|
+
clearTimeout(timer)
|
|
760
|
+
}
|
|
761
|
+
} catch (error) {
|
|
762
|
+
sendJson(res, 400, { error: String(error?.message ?? error) })
|
|
763
|
+
}
|
|
764
|
+
},
|
|
765
|
+
}),
|
|
766
|
+
)
|
|
767
|
+
}
|