dsh-remote-plugin 0.6.24 → 0.6.26
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/apk/dsh-remote.apk +0 -0
- package/gateway-stats.cjs +106 -84
- package/gateway.cjs +151 -44
- package/index.mjs +41 -40
- package/package.json +1 -1
- package/public/app.js +153 -244
- package/public/desktop/desktop.html +2 -0
- package/public/desktop/desktop.js +50 -2
- package/public/genui.css +8 -0
- package/public/genui.js +254 -0
- package/public/index.html +11 -26
- package/public/md.js +16 -3
- package/public/styles.css +4 -11
- package/public/update.json +12 -12
- package/public/version.json +1 -1
package/apk/dsh-remote.apk
CHANGED
|
Binary file
|
package/gateway-stats.cjs
CHANGED
|
@@ -22,6 +22,17 @@ const path = require('node:path')
|
|
|
22
22
|
const os = require('node:os')
|
|
23
23
|
const { spawn } = require('node:child_process')
|
|
24
24
|
const readline = require('node:readline')
|
|
25
|
+
const zlib = require('node:zlib')
|
|
26
|
+
|
|
27
|
+
function logGeneration(file) {
|
|
28
|
+
const match = /^session(?:\.v([1-9]\d*))?\.jsonl(?:\.zstd)?$/.exec(path.basename(file))
|
|
29
|
+
return match && Number.isSafeInteger(Number(match[1] || 0)) ? Number(match[1] || 0) : null
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
// V3 preserves message IDs, times and usage while renumbering durable seqs.
|
|
33
|
+
function usageIdentity(event) {
|
|
34
|
+
return JSON.stringify([event.data?.message?.id || '', event.time, normalizeUsage(event)])
|
|
35
|
+
}
|
|
25
36
|
|
|
26
37
|
// ---------- 固定价格表(v1 硬编码; v2 将改为配置文件/环境变量) ----------
|
|
27
38
|
// 时段判定: 工作日北京时间 9:00-12:00 与 14:00-18:00 为高峰(含起点不含终点),周末全天谷时
|
|
@@ -238,9 +249,9 @@ class StatsStore {
|
|
|
238
249
|
return null
|
|
239
250
|
}
|
|
240
251
|
|
|
241
|
-
_setCursor(sessionId, lastSeq) {
|
|
252
|
+
_setCursor(sessionId, lastSeq, generation) {
|
|
242
253
|
const c = this._loadCursors()
|
|
243
|
-
c[sessionId] = { lastSeq, updatedAt: Date.now() }
|
|
254
|
+
c[sessionId] = { ...c[sessionId], lastSeq, updatedAt: Date.now(), ...(generation == null ? {} : { generation }) }
|
|
244
255
|
this._saveCursors()
|
|
245
256
|
}
|
|
246
257
|
|
|
@@ -296,102 +307,111 @@ class StatsStore {
|
|
|
296
307
|
return this._enqueue(() => this._scanFile(file, onProgress))
|
|
297
308
|
}
|
|
298
309
|
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
dirtyDays.clear()
|
|
314
|
-
if (lastSeq >= 0) this._setCursor(sessionId, lastSeq)
|
|
315
|
-
}
|
|
316
|
-
|
|
317
|
-
const zstd = this.spawn('zstd', ['-dc', file], { windowsHide: true, stdio: ['ignore', 'pipe', 'ignore'] })
|
|
318
|
-
const rl = readline.createInterface({ input: zstd.stdout })
|
|
319
|
-
|
|
320
|
-
zstd.on('error', (err) => {
|
|
321
|
-
if (err.code === 'ENOENT') {
|
|
322
|
-
console.warn(`[stats] 未找到 zstd 命令, 跳过历史回填: ${file}`)
|
|
323
|
-
} else {
|
|
324
|
-
console.warn(`[stats] zstd 解压失败 ${file}: ${err.message}`)
|
|
325
|
-
}
|
|
326
|
-
scanError = err.code || err.message
|
|
327
|
-
rl.close()
|
|
310
|
+
async _readLog(file, visit) {
|
|
311
|
+
let input, source, child, childResult
|
|
312
|
+
if (!file.endsWith('.zstd')) input = fs.createReadStream(file)
|
|
313
|
+
else if (this.spawn === spawn && typeof zlib.createZstdDecompress === 'function') {
|
|
314
|
+
source = fs.createReadStream(file)
|
|
315
|
+
input = zlib.createZstdDecompress()
|
|
316
|
+
source.on('error', error => input.destroy(error))
|
|
317
|
+
source.pipe(input)
|
|
318
|
+
} else {
|
|
319
|
+
child = this.spawn('zstd', ['-dc', file], { windowsHide: true, stdio: ['ignore', 'pipe', 'ignore'] })
|
|
320
|
+
input = child.stdout
|
|
321
|
+
childResult = new Promise(resolve => {
|
|
322
|
+
child.once('close', code => resolve({ code }))
|
|
323
|
+
child.once('error', error => resolve({ error }))
|
|
328
324
|
})
|
|
325
|
+
child.on('error', error => input.destroy(error))
|
|
326
|
+
}
|
|
327
|
+
const rl = readline.createInterface({ input })
|
|
328
|
+
let lines = 0
|
|
329
|
+
try {
|
|
330
|
+
for await (const line of rl) {
|
|
331
|
+
if (!line.trim()) continue
|
|
332
|
+
const event = JSON.parse(line)
|
|
333
|
+
if (Number.isSafeInteger(event.seq)) visit(event)
|
|
334
|
+
if (++lines % 500 === 0) await new Promise(resolve => setImmediate(resolve))
|
|
335
|
+
}
|
|
336
|
+
if (childResult) {
|
|
337
|
+
const result = await childResult
|
|
338
|
+
if (result.error) throw result.error
|
|
339
|
+
if (result.code !== 0) throw new Error('zstd-decompression-failed')
|
|
340
|
+
}
|
|
341
|
+
} finally {
|
|
342
|
+
rl.close()
|
|
343
|
+
input.destroy()
|
|
344
|
+
source?.destroy()
|
|
345
|
+
child?.kill?.()
|
|
346
|
+
}
|
|
347
|
+
}
|
|
329
348
|
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
if (event.type === 'request/context' && event.data?.model) currentModel = event.data.model
|
|
352
|
-
} catch {}
|
|
353
|
-
if (event.type === 'assistant/message') {
|
|
354
|
-
const usage = normalizeUsage(event)
|
|
355
|
-
if (usage) {
|
|
356
|
-
const model = eventModel(event) || currentModel || 'unknown'
|
|
357
|
-
const { date, hour, period } = eventKey(event.time)
|
|
358
|
-
if (date >= PRICING_START_DATE) {
|
|
359
|
-
const day = dirtyDays.get(date) || this._loadDay(date)
|
|
360
|
-
dirtyDays.set(date, day)
|
|
361
|
-
const hourBucket = day.hours[hour] || (day.hours[hour] = {})
|
|
362
|
-
const modelBucket = hourBucket[model] || (hourBucket[model] = emptyBucket())
|
|
363
|
-
addUsage(modelBucket, model, period, usage)
|
|
364
|
-
processed++
|
|
365
|
-
}
|
|
349
|
+
async _scanFile(file, onProgress) {
|
|
350
|
+
const sessionId = path.basename(path.dirname(file))
|
|
351
|
+
const cur = this._cursor(sessionId)
|
|
352
|
+
const generation = logGeneration(file) ?? 0
|
|
353
|
+
const migrated = cur && generation !== (cur.generation ?? 0)
|
|
354
|
+
let lastSeq = migrated ? -1 : (cur?.lastSeq ?? -1)
|
|
355
|
+
let processed = 0
|
|
356
|
+
let currentModel = ''
|
|
357
|
+
const dirtyDays = new Map()
|
|
358
|
+
const counted = new Map()
|
|
359
|
+
try {
|
|
360
|
+
if (migrated) {
|
|
361
|
+
// Read the preserved predecessor before advancing to a renumbered log.
|
|
362
|
+
// Missing predecessor is an explicit error, never a blind double count.
|
|
363
|
+
const names = await fs.promises.readdir(path.dirname(file))
|
|
364
|
+
const previous = names.find(name => logGeneration(name) === (cur.generation ?? 0))
|
|
365
|
+
if (!previous) throw new Error('stats-migration-source-missing')
|
|
366
|
+
await this._readLog(path.join(path.dirname(file), previous), event => {
|
|
367
|
+
if (event.seq <= cur.lastSeq && event.type === 'assistant/message' && normalizeUsage(event)) {
|
|
368
|
+
const key = usageIdentity(event)
|
|
369
|
+
counted.set(key, (counted.get(key) || 0) + 1)
|
|
366
370
|
}
|
|
367
|
-
}
|
|
371
|
+
})
|
|
372
|
+
}
|
|
373
|
+
await this._readLog(file, event => {
|
|
374
|
+
if (event.type === 'request/header' && event.data?.config?.model) currentModel = event.data.config.model
|
|
375
|
+
if (event.type === 'request/context' && event.data?.model) currentModel = event.data.model
|
|
376
|
+
if (event.seq <= lastSeq) return
|
|
368
377
|
lastSeq = event.seq
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
378
|
+
if (event.type !== 'assistant/message') return
|
|
379
|
+
const usage = normalizeUsage(event)
|
|
380
|
+
if (!usage) return
|
|
381
|
+
const key = usageIdentity(event)
|
|
382
|
+
if (counted.get(key)) { counted.set(key, counted.get(key) - 1); return }
|
|
383
|
+
const model = eventModel(event) || currentModel || 'unknown'
|
|
384
|
+
const { date, hour, period } = eventKey(event.time)
|
|
385
|
+
if (date < PRICING_START_DATE) return
|
|
386
|
+
// Stage detached buckets: a failed read must not mutate cached totals.
|
|
387
|
+
const day = dirtyDays.get(date) || JSON.parse(JSON.stringify(this._loadDay(date)))
|
|
388
|
+
dirtyDays.set(date, day)
|
|
389
|
+
const hourBucket = day.hours[hour] || (day.hours[hour] = {})
|
|
390
|
+
addUsage(hourBucket[model] || (hourBucket[model] = emptyBucket()), model, period, usage)
|
|
391
|
+
processed++
|
|
375
392
|
})
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
393
|
+
if ([...counted.values()].some(count => count > 0)) throw new Error('stats-migration-usage-mismatch')
|
|
394
|
+
for (const day of dirtyDays.values()) this._saveDay(day)
|
|
395
|
+
if (lastSeq >= 0) this._setCursor(sessionId, lastSeq, generation)
|
|
396
|
+
if (onProgress) onProgress({ sessionId, processed })
|
|
397
|
+
return { sessionId, processed }
|
|
398
|
+
} catch (error) {
|
|
399
|
+
return { sessionId, processed: 0, error: error.code || error.message }
|
|
400
|
+
}
|
|
383
401
|
}
|
|
384
402
|
|
|
385
403
|
/** 扫描 ~/.dsh/sessions 下全部 session.jsonl.zstd。 */
|
|
386
404
|
async scanAll(sessionsRoot, onProgress) {
|
|
387
|
-
const root = sessionsRoot || path.join(os.homedir(), '.dsh', 'sessions')
|
|
405
|
+
const root = sessionsRoot || path.join(process.env.DSH_HOME || path.join(os.homedir(), '.dsh'), 'sessions')
|
|
388
406
|
let files = []
|
|
389
407
|
try {
|
|
390
408
|
const walk = async (dir) => {
|
|
391
409
|
const entries = await fs.promises.readdir(dir, { withFileTypes: true })
|
|
410
|
+
const logs = entries.filter(ent => ent.isFile() && logGeneration(ent.name) !== null)
|
|
411
|
+
.sort((a, b) => logGeneration(b.name) - logGeneration(a.name) || a.name.localeCompare(b.name))
|
|
412
|
+
if (logs.length) files.push(path.join(dir, logs[0].name))
|
|
392
413
|
for (const ent of entries) {
|
|
393
414
|
if (ent.isDirectory()) await walk(path.join(dir, ent.name))
|
|
394
|
-
else if (ent.name === 'session.jsonl.zstd') files.push(path.join(dir, ent.name))
|
|
395
415
|
}
|
|
396
416
|
}
|
|
397
417
|
await walk(root)
|
|
@@ -400,12 +420,14 @@ class StatsStore {
|
|
|
400
420
|
return { files: 0, processed: 0 }
|
|
401
421
|
}
|
|
402
422
|
let processed = 0
|
|
423
|
+
const errors = []
|
|
403
424
|
// 串行扫描, 避免大量并发 zstd 子进程
|
|
404
425
|
for (const file of files) {
|
|
405
426
|
const out = await this.scanFile(file, onProgress)
|
|
406
427
|
processed += out.processed || 0
|
|
428
|
+
if (out.error) errors.push({ sessionId: out.sessionId, error: out.error })
|
|
407
429
|
}
|
|
408
|
-
return { files: files.length, processed }
|
|
430
|
+
return { files: files.length, processed, ...(errors.length ? { errors } : {}) }
|
|
409
431
|
}
|
|
410
432
|
|
|
411
433
|
summary(days) {
|
package/gateway.cjs
CHANGED
|
@@ -72,6 +72,8 @@ const WS_IDLE_MS = durationEnv('GATEWAY_WS_IDLE_MS', 180000, 0, 24 * 60 * 60 * 1
|
|
|
72
72
|
const WS_UPGRADE_TIMEOUT_MS = durationEnv('GATEWAY_WS_UPGRADE_TIMEOUT_MS', 15000, 1000, 5 * 60 * 1000)
|
|
73
73
|
const UPSTREAM_REQUEST_TIMEOUT_MS = durationEnv('GATEWAY_UPSTREAM_TIMEOUT_MS', 30000, 1000, 10 * 60 * 1000)
|
|
74
74
|
const UPSTREAM = new URL(process.env.DSH_UPSTREAM || 'http://127.0.0.1:3080')
|
|
75
|
+
// URL 中 IPv6 带方括号,http.request 的 hostname 则要求裸地址。
|
|
76
|
+
const UPSTREAM_HOSTNAME = UPSTREAM.hostname.replace(/^\[|\]$/g, '')
|
|
75
77
|
const UPSTREAM_TRANSPORT = UPSTREAM.protocol === 'https:' ? https : http
|
|
76
78
|
const UPSTREAM_PORT = Number(UPSTREAM.port) || (UPSTREAM.protocol === 'https:' ? 443 : 80)
|
|
77
79
|
const UPSTREAM_AUTHORITY = `${UPSTREAM.hostname}${UPSTREAM.port ? ':' + UPSTREAM.port : ''}`
|
|
@@ -208,7 +210,7 @@ const MIME = {
|
|
|
208
210
|
|
|
209
211
|
// ---------- /fs 文件传输 ----------
|
|
210
212
|
// 允许访问的根目录: DSH_REMOTE_FS_ROOT 使用系统路径分隔符分隔多个根,
|
|
211
|
-
// POSIX 为 ':'、Windows 为 ';'
|
|
213
|
+
// POSIX 为 ':'、Windows 为 ';';Windows 默认用户目录及 C 盘以外的可用盘符。
|
|
212
214
|
// 所有 /fs/* 路径 resolve 后都必须位于某个根内, 已存在的路径还会用 realpath
|
|
213
215
|
// 复核一次, 防止 ../ 穿越与符号链接逃逸。
|
|
214
216
|
const FS_DEFAULT_ROOT = path.resolve(os.homedir())
|
|
@@ -218,10 +220,20 @@ function fsConfiguredRoot(value) {
|
|
|
218
220
|
if (/^~[\\/]/.test(raw)) return path.resolve(FS_DEFAULT_ROOT, raw.slice(2))
|
|
219
221
|
return path.resolve(raw)
|
|
220
222
|
}
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
223
|
+
function fsDefaultRoots(platform, home, isDirectory) {
|
|
224
|
+
const roots = [home]
|
|
225
|
+
if (platform === 'win32') {
|
|
226
|
+
for (const letter of 'ABDEFGHIJKLMNOPQRSTUVWXYZ') {
|
|
227
|
+
const root = `${letter}:\\`
|
|
228
|
+
try { if (isDirectory(root)) roots.push(root) } catch {}
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
return roots
|
|
232
|
+
}
|
|
233
|
+
const FS_ROOTS = process.env.DSH_REMOTE_FS_ROOT
|
|
234
|
+
? process.env.DSH_REMOTE_FS_ROOT.split(path.delimiter).map(fsConfiguredRoot).filter(Boolean)
|
|
235
|
+
: fsDefaultRoots(process.platform, FS_DEFAULT_ROOT, root => fs.statSync(root).isDirectory())
|
|
236
|
+
const FS_WINDOWS_DEFAULT = process.platform === 'win32' && !process.env.DSH_REMOTE_FS_ROOT
|
|
225
237
|
const FS_WORKSPACE_CACHE_MS = durationEnv('DSH_REMOTE_FS_WORKSPACE_CACHE_MS', 15_000, 1000, 10 * 60_000)
|
|
226
238
|
const FS_MAX_UPLOAD = Number(process.env.DSH_REMOTE_FS_MAX_UPLOAD) || 2 * 1024 * 1024 * 1024
|
|
227
239
|
const FS_UPLOAD_TTL_MS = durationEnv('DSH_REMOTE_FS_UPLOAD_TTL_MS', 24 * 60 * 60 * 1000, 60_000, 7 * 24 * 60 * 60 * 1000)
|
|
@@ -235,6 +247,8 @@ function fsRootReals() {
|
|
|
235
247
|
return FS_ROOT_REALS
|
|
236
248
|
}
|
|
237
249
|
function fsInsideReal(real) {
|
|
250
|
+
// 默认 Windows 授权不能被已登记工作区或盘符的 junction / SUBST 目标扩大。
|
|
251
|
+
if (FS_WINDOWS_DEFAULT) return FS_ROOTS.some(root => fsInsideRoot(real, root))
|
|
238
252
|
for (const root of [...fsRootReals(), ...fsWorkspaceRootsCache.reals]) {
|
|
239
253
|
if (fsInsideRoot(real, root)) return true
|
|
240
254
|
}
|
|
@@ -1401,6 +1415,7 @@ const modernState = {
|
|
|
1401
1415
|
sessionCursors: new Map(),
|
|
1402
1416
|
workspaces: { items: [], archivedSessionIds: [] },
|
|
1403
1417
|
pendingEvents: new Map(),
|
|
1418
|
+
assistantStreams: new Map(),
|
|
1404
1419
|
}
|
|
1405
1420
|
|
|
1406
1421
|
async function callUpstreamRemote(endpoint, args, rpcId = crypto.randomUUID()) {
|
|
@@ -1536,7 +1551,7 @@ async function translateModernRpc(method, payload, rpcId) {
|
|
|
1536
1551
|
...(payload.beforeSeq === undefined ? {} : { beforeSeq: payload.beforeSeq }),
|
|
1537
1552
|
...(payload.maxMessages === undefined ? {} : { maxMessages: payload.maxMessages }),
|
|
1538
1553
|
} }
|
|
1539
|
-
transform = value => legacyHistoryValue(value, summary)
|
|
1554
|
+
transform = value => ({ ...legacyHistoryValue(value, summary), ...modernReasoningValue(payload.sessionId) })
|
|
1540
1555
|
} else if (method === 'session.models') {
|
|
1541
1556
|
endpoint = 'session/modelCatalog'
|
|
1542
1557
|
args = {}
|
|
@@ -1670,6 +1685,7 @@ function rememberCollectorReplay(kind, full, raw) {
|
|
|
1670
1685
|
const replay = collectorReplay[kind]
|
|
1671
1686
|
let key = ''
|
|
1672
1687
|
if (payload.type === 'session/subscribed' && payload.sessionId) key = `session:${payload.sessionId}`
|
|
1688
|
+
else if (payload.type === 'session/reasoning' && payload.sessionId) key = `reasoning:${payload.sessionId}`
|
|
1673
1689
|
else if (payload.type === 'approval/requested' && payload.approvalId) key = `approval:${payload.approvalId}`
|
|
1674
1690
|
else if (payload.type === 'question/requested' && full.rpcId) key = `question:${full.rpcId}`
|
|
1675
1691
|
else if (payload.type === 'approval/resolved' && payload.approvalId) replay.delete(`approval:${payload.approvalId}`)
|
|
@@ -1940,7 +1956,7 @@ function openModernSessionStream(ws, sessionId) {
|
|
|
1940
1956
|
const streamId = 'session:' + sessionId
|
|
1941
1957
|
ws.send(JSON.stringify({
|
|
1942
1958
|
type: 'open', streamId, endpoint: 'session/follow',
|
|
1943
|
-
payload: { args: { request: { address: { kind: 'session', sessionId } } } },
|
|
1959
|
+
payload: { args: { request: { address: { kind: 'session', sessionId }, assistantStream: true } } },
|
|
1944
1960
|
}))
|
|
1945
1961
|
}
|
|
1946
1962
|
|
|
@@ -1997,8 +2013,59 @@ function applyModernWorkspaceFrame(value) {
|
|
|
1997
2013
|
}
|
|
1998
2014
|
}
|
|
1999
2015
|
|
|
2016
|
+
// 0.1.5 streams are process-local and have no durable seq. Keep a bounded,
|
|
2017
|
+
// replaceable reasoning baseline for HTTP history, polling and late WS clients.
|
|
2018
|
+
function modernReasoningValue(sessionId) {
|
|
2019
|
+
const stream = modernState.assistantStreams.get(sessionId)
|
|
2020
|
+
return stream ? { partialReasoning: [...stream.blocks.values()] } : {}
|
|
2021
|
+
}
|
|
2022
|
+
|
|
2023
|
+
function applyModernAssistantStream(sessionId, value, baseline = false) {
|
|
2024
|
+
let stream = modernState.assistantStreams.get(sessionId)
|
|
2025
|
+
const publish = () => legacyPush('mux', { type: 'session/reasoning', sessionId, ...modernReasoningValue(sessionId) })
|
|
2026
|
+
const fold = chunk => {
|
|
2027
|
+
if (!chunk || !Number.isSafeInteger(chunk.index) || chunk.index < 0 || chunk.index >= 64) return false
|
|
2028
|
+
const item = stream.blocks.get(chunk.index) || { turn: stream.turn, step: stream.step, index: chunk.index, text: '' }
|
|
2029
|
+
if (chunk.type === 'reasoning-delta') item.text = (item.text + String(chunk.text || '')).slice(0, 12000)
|
|
2030
|
+
else if (chunk.type === 'block-start' && chunk.blockType === 'reasoning') item.text = ''
|
|
2031
|
+
else if (chunk.type === 'block-end' && chunk.block?.type === 'reasoning') item.text = String(chunk.block.text || '').slice(0, 12000)
|
|
2032
|
+
else return false
|
|
2033
|
+
stream.blocks.set(chunk.index, item)
|
|
2034
|
+
return true
|
|
2035
|
+
}
|
|
2036
|
+
if (baseline || value?.type === 'start') {
|
|
2037
|
+
const attempt = baseline ? value?.activeAttempt : value
|
|
2038
|
+
stream = { revision: value?.revision || 0, attemptId: attempt?.attemptId, turn: attempt?.turn, step: attempt?.step, nextIndex: attempt?.nextIndex || 0, blocks: new Map() }
|
|
2039
|
+
modernState.assistantStreams.set(sessionId, stream)
|
|
2040
|
+
for (const record of attempt?.stream || []) {
|
|
2041
|
+
if (record.type === 'chunk') fold(record.chunk)
|
|
2042
|
+
else if (record.type === 'reasoning-chunks') fold({ type: 'reasoning-delta', index: record.index, text: (record.texts || []).join('') })
|
|
2043
|
+
}
|
|
2044
|
+
publish()
|
|
2045
|
+
return
|
|
2046
|
+
}
|
|
2047
|
+
if (!stream || value?.revision <= stream.revision) return
|
|
2048
|
+
if (value?.revision !== stream.revision + 1 || value.attemptId !== stream.attemptId || value.index !== stream.nextIndex) {
|
|
2049
|
+
stream.blocks.clear()
|
|
2050
|
+
stream.attemptId = undefined
|
|
2051
|
+
stream.revision = value?.revision || stream.revision
|
|
2052
|
+
publish()
|
|
2053
|
+
return
|
|
2054
|
+
}
|
|
2055
|
+
stream.revision = value.revision
|
|
2056
|
+
if (value.type === 'end') {
|
|
2057
|
+
stream.blocks.clear()
|
|
2058
|
+
stream.attemptId = undefined
|
|
2059
|
+
publish()
|
|
2060
|
+
} else if (value.type === 'chunk') {
|
|
2061
|
+
stream.nextIndex++
|
|
2062
|
+
if (fold(value.chunk)) publish()
|
|
2063
|
+
}
|
|
2064
|
+
}
|
|
2065
|
+
|
|
2000
2066
|
function applyModernSessionFrame(sessionId, value) {
|
|
2001
2067
|
if (value?.type === 'snapshot') {
|
|
2068
|
+
if (value.assistantStream) applyModernAssistantStream(sessionId, value.assistantStream, true)
|
|
2002
2069
|
modernState.sessionCursors.set(sessionId, value.cursor)
|
|
2003
2070
|
legacyPush('mux', { type: 'session/subscribed', sessionId, lastSeq: value.cursor })
|
|
2004
2071
|
for (const record of value.records || []) {
|
|
@@ -2012,6 +2079,8 @@ function applyModernSessionFrame(sessionId, value) {
|
|
|
2012
2079
|
if (value?.type === 'event' && value.event) {
|
|
2013
2080
|
modernState.sessionCursors.set(sessionId, value.event.seq)
|
|
2014
2081
|
legacyPush('mux', { type: 'session/event', sessionId, event: value.event })
|
|
2082
|
+
} else if (value?.type === 'assistant-stream') {
|
|
2083
|
+
applyModernAssistantStream(sessionId, value.frame)
|
|
2015
2084
|
}
|
|
2016
2085
|
}
|
|
2017
2086
|
|
|
@@ -2074,6 +2143,8 @@ function applyModernRemoteEvent(ws, value) {
|
|
|
2074
2143
|
} else if (value.event === 'api-session/removed') {
|
|
2075
2144
|
modernState.sessions.delete(args[0])
|
|
2076
2145
|
modernState.sessionCursors.delete(args[0])
|
|
2146
|
+
modernState.assistantStreams.delete(args[0])
|
|
2147
|
+
collectorReplay.mux.delete(`reasoning:${args[0]}`)
|
|
2077
2148
|
try { ws.send(JSON.stringify({ type: 'cancel', streamId: 'session:' + args[0] })) } catch {}
|
|
2078
2149
|
legacyPush('host', { type: 'host/session-removed', sessionId: args[0] })
|
|
2079
2150
|
} else if (value.event === 'api-session/status') {
|
|
@@ -2229,6 +2300,7 @@ async function scanStatsOnce(delay) {
|
|
|
2229
2300
|
try {
|
|
2230
2301
|
const out = await statsStore.scanAll()
|
|
2231
2302
|
if (out.files) console.log(`[stats] 历史回填扫描完成: ${out.processed} 个新事件 (${out.files} 个会话文件)`)
|
|
2303
|
+
if (out.errors?.length) console.warn(`[stats] ${out.errors.length} 个会话回填失败,保留原统计游标: ${out.errors.map(item => item.error).join(', ')}`)
|
|
2232
2304
|
} catch (err) {
|
|
2233
2305
|
console.warn('[stats] 历史回填扫描失败: ' + (err?.message || err))
|
|
2234
2306
|
} finally {
|
|
@@ -2696,7 +2768,7 @@ function serveStatic(req, res, url) {
|
|
|
2696
2768
|
// ---------- 管理 API ----------
|
|
2697
2769
|
function upstreamReachable(cb) {
|
|
2698
2770
|
const req = UPSTREAM_TRANSPORT.request({
|
|
2699
|
-
hostname:
|
|
2771
|
+
hostname: UPSTREAM_HOSTNAME,
|
|
2700
2772
|
port: UPSTREAM_PORT,
|
|
2701
2773
|
method: 'GET',
|
|
2702
2774
|
path: '/health',
|
|
@@ -3035,6 +3107,7 @@ async function fsResolve(input) {
|
|
|
3035
3107
|
else if (path.isAbsolute(raw)) abs = path.resolve(raw)
|
|
3036
3108
|
else abs = path.resolve(FS_ROOTS[0], raw) // 相对路径按默认根解析
|
|
3037
3109
|
if (FS_ROOTS.some(root => fsInsideRoot(abs, root))) return { abs }
|
|
3110
|
+
if (FS_WINDOWS_DEFAULT) return { error: 'forbidden' }
|
|
3038
3111
|
let workspaces = await loadFsWorkspaceRoots(false)
|
|
3039
3112
|
if (workspaces.roots.some(root => fsInsideRoot(abs, root))) return { abs }
|
|
3040
3113
|
// 新建/刚加入的工作区可能还没进入 15s 缓存,未命中时强制刷新一次。
|
|
@@ -3276,7 +3349,8 @@ function sha256FileHex(file, cb) {
|
|
|
3276
3349
|
const activeUploads = new Map()
|
|
3277
3350
|
const uploadPartDirs = new Set()
|
|
3278
3351
|
function fsActiveKey(dirReal, name, session) {
|
|
3279
|
-
|
|
3352
|
+
const key = fsPartPath(dirReal, name, session)
|
|
3353
|
+
return process.platform === 'win32' ? key.toLowerCase() : key
|
|
3280
3354
|
}
|
|
3281
3355
|
|
|
3282
3356
|
function rememberUploadDir(dirReal) {
|
|
@@ -3284,8 +3358,8 @@ function rememberUploadDir(dirReal) {
|
|
|
3284
3358
|
}
|
|
3285
3359
|
|
|
3286
3360
|
function uploadDirHasActive(dirReal) {
|
|
3287
|
-
const
|
|
3288
|
-
for (const key of activeUploads.keys()) if (
|
|
3361
|
+
const dir = process.platform === 'win32' ? dirReal.toLowerCase() : dirReal
|
|
3362
|
+
for (const key of activeUploads.keys()) if (path.dirname(key) === dir) return true
|
|
3289
3363
|
return false
|
|
3290
3364
|
}
|
|
3291
3365
|
|
|
@@ -3360,24 +3434,49 @@ function fsUploadPipe(res, url, dirLex, dirReal, name) {
|
|
|
3360
3434
|
return up ? fsUploadPipeFromTarget(res, up) : null
|
|
3361
3435
|
}
|
|
3362
3436
|
|
|
3437
|
+
/** 同目录提交:覆盖用原子 rename;禁止覆盖用排他 link,绝不先删旧文件。 */
|
|
3438
|
+
function fsCommitUpload(tmp, target, overwrite) {
|
|
3439
|
+
if (overwrite) fs.renameSync(tmp, target)
|
|
3440
|
+
else {
|
|
3441
|
+
fs.linkSync(tmp, target)
|
|
3442
|
+
// 目标已提交,临时名字清理失败不应把成功误报为失败。
|
|
3443
|
+
try { fs.unlinkSync(tmp) } catch {}
|
|
3444
|
+
}
|
|
3445
|
+
}
|
|
3446
|
+
|
|
3363
3447
|
function fsUploadPipeFromTarget(res, up) {
|
|
3364
3448
|
let finished = false
|
|
3449
|
+
let ending = false
|
|
3450
|
+
let written = false
|
|
3365
3451
|
const cleanup = () => {
|
|
3366
3452
|
if (finished) return
|
|
3367
3453
|
finished = true
|
|
3368
3454
|
try { up.stream.destroy() } catch {}
|
|
3369
|
-
try { fs.unlinkSync(up.tmp) } catch {}
|
|
3455
|
+
up.stream.once('close', () => { try { fs.unlinkSync(up.tmp) } catch {} })
|
|
3370
3456
|
}
|
|
3371
3457
|
up.stream.on('error', () => {
|
|
3372
3458
|
if (finished) return
|
|
3373
3459
|
finished = true
|
|
3374
|
-
try { fs.unlinkSync(up.tmp) } catch {}
|
|
3460
|
+
up.stream.once('close', () => { try { fs.unlinkSync(up.tmp) } catch {} })
|
|
3375
3461
|
if (!res.headersSent) fsJson(res, 500, { error: 'write-failed' })
|
|
3376
3462
|
else try { res.destroy() } catch {}
|
|
3377
3463
|
})
|
|
3464
|
+
up.stream.once('finish', () => { written = true })
|
|
3465
|
+
up.stream.once('close', () => {
|
|
3466
|
+
if (finished || !written) return
|
|
3467
|
+
finished = true
|
|
3468
|
+
try {
|
|
3469
|
+
fsCommitUpload(up.tmp, up.target, up.overwrite)
|
|
3470
|
+
} catch (err) {
|
|
3471
|
+
try { fs.unlinkSync(up.tmp) } catch {}
|
|
3472
|
+
if (!res.headersSent) fsJson(res, err.code === 'EEXIST' ? 409 : 403, { error: err.code === 'EEXIST' ? 'conflict' : 'write-failed', detail: err.message })
|
|
3473
|
+
return
|
|
3474
|
+
}
|
|
3475
|
+
fsJson(res, 201, { ok: true, path: up.displayPath, name: up.name, size: up.bytes })
|
|
3476
|
+
})
|
|
3378
3477
|
return {
|
|
3379
3478
|
write(chunk) {
|
|
3380
|
-
if (finished) return
|
|
3479
|
+
if (finished || ending) return
|
|
3381
3480
|
up.bytes += chunk.length
|
|
3382
3481
|
if (up.bytes > FS_MAX_UPLOAD) {
|
|
3383
3482
|
cleanup()
|
|
@@ -3388,19 +3487,9 @@ function fsUploadPipeFromTarget(res, up) {
|
|
|
3388
3487
|
up.stream.write(chunk)
|
|
3389
3488
|
},
|
|
3390
3489
|
end() {
|
|
3391
|
-
if (finished) return
|
|
3392
|
-
|
|
3393
|
-
up.stream.end(
|
|
3394
|
-
try {
|
|
3395
|
-
if (up.overwrite) fs.rmSync(up.target, { force: true })
|
|
3396
|
-
fs.renameSync(up.tmp, up.target)
|
|
3397
|
-
} catch (err) {
|
|
3398
|
-
try { fs.unlinkSync(up.tmp) } catch {}
|
|
3399
|
-
if (!res.headersSent) return fsJson(res, 403, { error: 'permission-denied', detail: err.message })
|
|
3400
|
-
return
|
|
3401
|
-
}
|
|
3402
|
-
fsJson(res, 201, { ok: true, path: up.displayPath, name: up.name, size: up.bytes })
|
|
3403
|
-
})
|
|
3490
|
+
if (finished || ending) return
|
|
3491
|
+
ending = true
|
|
3492
|
+
up.stream.end()
|
|
3404
3493
|
},
|
|
3405
3494
|
abort(status, msg) {
|
|
3406
3495
|
cleanup()
|
|
@@ -3588,6 +3677,8 @@ function fsUploadResumable(req, res, url, dirLex, dirReal) {
|
|
|
3588
3677
|
if (!fsValidName(name)) return fsJson(res, 400, { error: 'bad-name', detail: '文件名不能为空且不能包含路径分隔符' })
|
|
3589
3678
|
const session = url.searchParams.get('session') || ''
|
|
3590
3679
|
if (!session) return fsJson(res, 400, { error: 'missing-session', detail: '断点续传需要 session 参数' })
|
|
3680
|
+
const activeKey = fsActiveKey(dirReal, name, session)
|
|
3681
|
+
if (activeUploads.has(activeKey)) return fsJson(res, 409, { error: 'upload-busy' })
|
|
3591
3682
|
const queryOffsetRaw = url.searchParams.get('offset')
|
|
3592
3683
|
const headerOffsetRaw = req.headers['upload-offset']
|
|
3593
3684
|
const offsetRaw = queryOffsetRaw ?? headerOffsetRaw
|
|
@@ -3648,21 +3739,37 @@ function fsUploadResumable(req, res, url, dirLex, dirReal) {
|
|
|
3648
3739
|
} catch (err) {
|
|
3649
3740
|
return fsJson(res, 403, { error: 'permission-denied', detail: err.message })
|
|
3650
3741
|
}
|
|
3651
|
-
const activeKey = fsActiveKey(dirReal, name, session)
|
|
3652
3742
|
activeUploads.set(activeKey, stream)
|
|
3653
3743
|
|
|
3654
3744
|
let bytes = 0
|
|
3655
3745
|
let finished = false
|
|
3746
|
+
let written = false
|
|
3747
|
+
let cancelled = false
|
|
3748
|
+
const release = () => {
|
|
3749
|
+
if (activeUploads.get(activeKey) === stream) activeUploads.delete(activeKey)
|
|
3750
|
+
}
|
|
3751
|
+
res.once('finish', () => { finished = true; if (!cancelled) release() })
|
|
3656
3752
|
const abort = (status, msg, extra = {}) => {
|
|
3657
3753
|
if (finished) return
|
|
3658
3754
|
finished = true
|
|
3659
|
-
|
|
3755
|
+
cancelled = true
|
|
3756
|
+
const cleanup = () => {
|
|
3757
|
+
if (status === 413 || status === 500 || msg === 'cancelled') { try { fs.unlinkSync(part) } catch {} }
|
|
3758
|
+
release()
|
|
3759
|
+
}
|
|
3760
|
+
if (stream.closed) cleanup()
|
|
3761
|
+
else stream.once('close', cleanup)
|
|
3660
3762
|
try { stream.destroy() } catch {}
|
|
3661
3763
|
// 网络中断时保留分片, 客户端 probe 后续传; 只有超限/写失败才删
|
|
3662
|
-
if (status === 413 || status === 500) { try { fs.unlinkSync(part) } catch {} }
|
|
3663
3764
|
if (!res.headersSent) fsJson(res, status, { error: msg, ...extra })
|
|
3664
3765
|
else try { res.destroy() } catch {}
|
|
3665
3766
|
}
|
|
3767
|
+
stream.cancelUpload = () => {
|
|
3768
|
+
const closed = stream.closed ? Promise.resolve() : new Promise(resolve => stream.once('close', resolve))
|
|
3769
|
+
abort(409, 'cancelled')
|
|
3770
|
+
return closed
|
|
3771
|
+
}
|
|
3772
|
+
res.once('close', () => { if (!finished) abort(400, 'client-aborted') })
|
|
3666
3773
|
|
|
3667
3774
|
stream.on('error', (err) => {
|
|
3668
3775
|
abort(500, err.code === 'ENOENT' ? 'part-missing' : 'write-failed', { detail: err.message })
|
|
@@ -3680,9 +3787,11 @@ function fsUploadResumable(req, res, url, dirLex, dirReal) {
|
|
|
3680
3787
|
})
|
|
3681
3788
|
req.on('end', () => {
|
|
3682
3789
|
if (finished) return
|
|
3683
|
-
|
|
3684
|
-
|
|
3685
|
-
|
|
3790
|
+
stream.end()
|
|
3791
|
+
})
|
|
3792
|
+
stream.once('finish', () => { written = true })
|
|
3793
|
+
stream.once('close', () => {
|
|
3794
|
+
if (finished || !written) return
|
|
3686
3795
|
const total = offset + bytes
|
|
3687
3796
|
try {
|
|
3688
3797
|
const st = fs.statSync(part)
|
|
@@ -3700,21 +3809,22 @@ function fsUploadResumable(req, res, url, dirLex, dirReal) {
|
|
|
3700
3809
|
return
|
|
3701
3810
|
}
|
|
3702
3811
|
const commit = (actualSha256) => {
|
|
3812
|
+
if (cancelled) return
|
|
3703
3813
|
try {
|
|
3704
3814
|
const ts = fsTargetState(target)
|
|
3705
3815
|
if (ts.status) return fsJson(res, ts.status, { error: ts.error, detail: ts.detail })
|
|
3706
3816
|
if (ts.exists && !overwrite) return fsJson(res, 409, { error: 'conflict', detail: '文件已存在, overwrite=1 可覆盖' })
|
|
3707
|
-
|
|
3708
|
-
fs.renameSync(part, target)
|
|
3817
|
+
fsCommitUpload(part, target, overwrite)
|
|
3709
3818
|
fsJson(res, 201, { ok: true, name, path: path.join(dirLex, name), size: total, resumed: offset > 0, session, uploadLength, ...(actualSha256 ? { sha256: actualSha256 } : {}) }, fsUploadHeaders(total, uploadLength))
|
|
3710
3819
|
} catch (err) {
|
|
3711
|
-
if (!res.headersSent) fsJson(res, 403, { error: 'write-failed', detail: err.message })
|
|
3820
|
+
if (!res.headersSent) fsJson(res, err.code === 'EEXIST' ? 409 : 403, { error: err.code === 'EEXIST' ? 'conflict' : 'write-failed', detail: err.message })
|
|
3712
3821
|
else try { res.destroy() } catch {}
|
|
3713
3822
|
}
|
|
3714
3823
|
}
|
|
3715
3824
|
if (sha256Expected) {
|
|
3716
3825
|
// 落盘前校验: 不匹配保留分片并返回 422, 客户端可重传或取消
|
|
3717
3826
|
sha256FileHex(part, (err, actual) => {
|
|
3827
|
+
if (cancelled) return
|
|
3718
3828
|
if (err) return fsJson(res, 403, { error: 'checksum-failed', detail: err.message })
|
|
3719
3829
|
if (actual !== sha256Expected) {
|
|
3720
3830
|
return fsJson(res, 422, { error: 'checksum-mismatch', expected: sha256Expected, actual, partialSize: total, session }, fsUploadHeaders(total, uploadLength, Date.now() + FS_UPLOAD_TTL_MS))
|
|
@@ -3728,7 +3838,6 @@ function fsUploadResumable(req, res, url, dirLex, dirReal) {
|
|
|
3728
3838
|
if (!res.headersSent) fsJson(res, 403, { error: 'write-failed', detail: err.message })
|
|
3729
3839
|
else try { res.destroy() } catch {}
|
|
3730
3840
|
}
|
|
3731
|
-
})
|
|
3732
3841
|
})
|
|
3733
3842
|
}
|
|
3734
3843
|
|
|
@@ -3757,17 +3866,15 @@ async function fsUploadControl(req, res, url) {
|
|
|
3757
3866
|
const part = fsPartPath(checked.abs, name, session)
|
|
3758
3867
|
const active = activeUploads.get(fsActiveKey(checked.abs, name, session))
|
|
3759
3868
|
if (active) {
|
|
3760
|
-
|
|
3761
|
-
|
|
3869
|
+
await active.cancelUpload()
|
|
3870
|
+
return fsJson(res, 200, { ok: true, cancelled: true, session })
|
|
3762
3871
|
}
|
|
3763
|
-
//
|
|
3764
|
-
setTimeout(() => {
|
|
3872
|
+
// 无活动写流时同步删除,不留下可被新上传插入的延时窗口。
|
|
3765
3873
|
let removed = false
|
|
3766
3874
|
try { fs.unlinkSync(part); removed = true } catch (err) {
|
|
3767
3875
|
if (err.code !== 'ENOENT') return fsJson(res, 403, { error: 'permission-denied', detail: err.message })
|
|
3768
3876
|
}
|
|
3769
3877
|
fsJson(res, 200, { ok: true, cancelled: true, removed, session })
|
|
3770
|
-
}, 80)
|
|
3771
3878
|
}
|
|
3772
3879
|
|
|
3773
3880
|
async function serveFs(req, res, url) {
|
|
@@ -4093,7 +4200,7 @@ async function proxyLegacyApi(req, res, url) {
|
|
|
4093
4200
|
|
|
4094
4201
|
let responseDone = false
|
|
4095
4202
|
const upstreamReq = UPSTREAM_TRANSPORT.request({
|
|
4096
|
-
hostname:
|
|
4203
|
+
hostname: UPSTREAM_HOSTNAME,
|
|
4097
4204
|
port: UPSTREAM_PORT,
|
|
4098
4205
|
method: req.method,
|
|
4099
4206
|
path: url.pathname + url.search,
|
|
@@ -4481,7 +4588,7 @@ server.on('upgrade', (req, socket, head) => {
|
|
|
4481
4588
|
handshakeTimer = null
|
|
4482
4589
|
}
|
|
4483
4590
|
const upstreamReq = UPSTREAM_TRANSPORT.request({
|
|
4484
|
-
hostname:
|
|
4591
|
+
hostname: UPSTREAM_HOSTNAME,
|
|
4485
4592
|
port: UPSTREAM_PORT,
|
|
4486
4593
|
method: req.method,
|
|
4487
4594
|
path: url.pathname + url.search,
|