fix(host): 修掉会杀死 DSH 的 resolveModelInfo unhandled rejection;按真实事件结构(event.data)重写镜像映射;标题栏与指标显示打磨
This commit is contained in:
@@ -109,6 +109,9 @@ shell\run-float.cmd -Action pin # 给正在运行的实例
|
|||||||
8. **cordis:`ctx.set` 之前必须 `ctx.provide`**。漏了会抛 `cannot set property "floatWindow" without provide`,后果不是"少个功能"而是**整个插件树加载失败、DSH 进程直接退出**。本轮就是在隔离实例里实测复现后修掉的。
|
8. **cordis:`ctx.set` 之前必须 `ctx.provide`**。漏了会抛 `cannot set property "floatWindow" without provide`,后果不是"少个功能"而是**整个插件树加载失败、DSH 进程直接退出**。本轮就是在隔离实例里实测复现后修掉的。
|
||||||
9. **PowerShell 事件处理器里参数不能叫 `$args`**:它是自动变量,会把整个事件参数数组占住,于是 `$e.TryGetWebMessageAsString()` 变成"数组没有这个方法"——表现为**页面所有按钮(含右下角缩放、置顶、锁定、全屏、设置)全部失效**。正确写法是 `param($s, $e)`。
|
9. **PowerShell 事件处理器里参数不能叫 `$args`**:它是自动变量,会把整个事件参数数组占住,于是 `$e.TryGetWebMessageAsString()` 变成"数组没有这个方法"——表现为**页面所有按钮(含右下角缩放、置顶、锁定、全屏、设置)全部失效**。正确写法是 `param($s, $e)`。
|
||||||
10. **profile 的 `package.json` 不能带 BOM**:Node 读 JSON 会报 `SyntaxError: Unexpected token '锘?`。用 `[IO.File]::WriteAllText(..., UTF8Encoding($false))` 写。
|
10. **profile 的 `package.json` 不能带 BOM**:Node 读 JSON 会报 `SyntaxError: Unexpected token '锘?`。用 `[IO.File]::WriteAllText(..., UTF8Encoding($false))` 写。
|
||||||
|
11. **宿主里可能返回 Promise 的 API,必须 `.catch()`**:`ctx.get('llm').resolveModelInfo()` 在没注册 provider 时会抛错,而且它返回 Promise——只 `try/catch` 同步部分会留下 **unhandled rejection,直接把 DSH 进程 `fatal load failure` 干掉**(我在隔离实例里真把 DSH 搞崩过一次,1 秒一次的 SSE tick 是触发器)。
|
||||||
|
12. **会话事件载荷在 `event.data` 下**,不在顶层:`user/message` → `data.content[{type:'text',text}]`、`assistant/chunk` → `data.chunk{type,text,reason}`、`session/title` → `data.title`、`tool/call` → `data.name/args`。另外要过滤 `data.source.kind === 'plugin'` 的消息(系统提示快照),否则镜像里会混入一大段内部注入文本。
|
||||||
|
13. **宿主 RPC 的信封是 `{ rpcId, payload }`**:`ctx.apiProxy.sessions.prompt({ rpcId, payload: { sessionId, mode, content } })`。把字段平铺在顶层会得到 `Cannot read properties of undefined (reading 'sessionId')`。
|
||||||
|
|
||||||
## 怎么验证(可复现)
|
## 怎么验证(可复现)
|
||||||
|
|
||||||
@@ -134,6 +137,13 @@ curl -N http://127.0.0.1:8099/dsh-float-window/events # SSE,每秒一帧
|
|||||||
# 4) 让浮窗连到这个测试实例(看视觉效果时再加 -NoExclude)
|
# 4) 让浮窗连到这个测试实例(看视觉效果时再加 -NoExclude)
|
||||||
shell\run-float.cmd -DshHome $t -NoExclude
|
shell\run-float.cmd -DshHome $t -NoExclude
|
||||||
# 标题栏应变成绿点 + 127.0.0.1:8099
|
# 标题栏应变成绿点 + 127.0.0.1:8099
|
||||||
|
|
||||||
|
# 5) 造一个真实会话并灌一条消息,验证镜像与指标(不需要浏览器)
|
||||||
|
curl -X POST http://127.0.0.1:8099/dsh-float-window/session -H "content-type: application/json" -d "{\"action\":\"create\",\"cwd\":\"D:/project\"}"
|
||||||
|
curl -X POST http://127.0.0.1:8099/dsh-float-window/chat -H "content-type: application/json" -d "{\"text\":\"你好\"}"
|
||||||
|
curl http://127.0.0.1:8099/dsh-float-window/state
|
||||||
|
# -> messages 里出现 user 行;metrics 里 turns/steps/tokens 有数(没有 API key 时
|
||||||
|
# assistant 行会是一条 ⚠️ 报错,这正是 LLM 分支被正确映射的证据)
|
||||||
```
|
```
|
||||||
|
|
||||||
`tools/smoke-host.mjs` 则是更轻的一层:假 ctx 直接调 `apply()`,验证路由/SSE/工具注册不抛异常。
|
`tools/smoke-host.mjs` 则是更轻的一层:假 ctx 直接调 `apply()`,验证路由/SSE/工具注册不抛异常。
|
||||||
|
|||||||
+113
-26
@@ -28,24 +28,59 @@ function dshHome() {
|
|||||||
return process.env.DSH_HOME || path.join(os.homedir(), '.dsh')
|
return process.env.DSH_HOME || path.join(os.homedir(), '.dsh')
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 会话事件 → 浮窗消息行的宽松映射(不同版本字段不完全一致,宁可少显示也不要报错) */
|
/** 从 content 数组/字符串里取纯文本 */
|
||||||
|
function textOf(content) {
|
||||||
|
if (typeof content === 'string') return content
|
||||||
|
if (Array.isArray(content)) {
|
||||||
|
return content
|
||||||
|
.map((c) => (typeof c === 'string' ? c : (c?.type === 'text' || c?.text ? String(c.text ?? '') : '')))
|
||||||
|
.join('')
|
||||||
|
}
|
||||||
|
return ''
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 会话事件 → 浮窗消息行的宽松映射。
|
||||||
|
* 实测(dsh 0.1.x)事件载荷在 `event.data` 下:
|
||||||
|
* user/message { data: { content:[{type:'text',text}], role, source:{kind:'user'|'plugin'} , id } }
|
||||||
|
* assistant/chunk { data: { turn, step, chunk:{ type, text?, reason? } } }
|
||||||
|
* assistant/message{ data: { content:[...] } }
|
||||||
|
* tool/call { data: { name, args } }
|
||||||
|
* 插件注入的消息(source.kind === 'plugin',例如系统提示快照)不进镜像。
|
||||||
|
*/
|
||||||
function toRow(event) {
|
function toRow(event) {
|
||||||
if (!event || typeof event !== 'object') return null
|
if (!event || typeof event !== 'object') return null
|
||||||
const type = String(event.type || '')
|
const type = String(event.type || '')
|
||||||
const text = (v) => (typeof v === 'string' ? v : '')
|
const d = event.data || event
|
||||||
if (type === 'tool/call') {
|
const src = d.source || event.source
|
||||||
return { role: 'tool', text: `${event.name || event.tool || 'tool'}(${short(event.args ?? event.input)})` }
|
if (type === 'user/message') {
|
||||||
|
if (src && src.kind && src.kind !== 'user') return null
|
||||||
|
const text = textOf(d.content ?? d.message?.content ?? d.text)
|
||||||
|
return text ? { role: 'user', text: clip(text) } : null
|
||||||
}
|
}
|
||||||
if (type === 'tool/result') return null
|
if (type === 'assistant/message') {
|
||||||
const msg = event.message
|
const text = textOf(d.content ?? d.message?.content ?? d.text)
|
||||||
const role = msg?.role || event.role || (type.includes('user') ? 'user' : type.includes('assistant') ? 'assistant' : '')
|
return text ? { role: 'assistant', text: clip(text) } : null
|
||||||
if (!role) return null
|
}
|
||||||
let body = ''
|
if (type === 'assistant/chunk') {
|
||||||
const content = msg?.content ?? event.content ?? event.text
|
const c = d.chunk || {}
|
||||||
if (typeof content === 'string') body = content
|
const kind = String(c.type || '')
|
||||||
else if (Array.isArray(content)) body = content.map((c) => (typeof c === 'string' ? c : text(c?.text))).join('')
|
const piece = c.text ?? c.delta ?? c.value ?? (kind.includes('text') ? c.content : '')
|
||||||
if (!body) return null
|
if (piece) return { role: 'assistant', text: String(piece), streaming: true }
|
||||||
return { role, text: body.length > 4000 ? body.slice(0, 4000) + '…' : body, streaming: /delta|chunk|stream/.test(type) }
|
if (kind === 'finish' && c.reason?.kind === 'error') {
|
||||||
|
return { role: 'assistant', text: '⚠️ ' + short(c.reason.failure ?? c.reason, 240) }
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
if (type === 'tool/call') {
|
||||||
|
return { role: 'tool', text: `${d.name || d.tool || 'tool'}(${short(d.args ?? d.input ?? '', 160)})` }
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
function clip(text, n = 4000) {
|
||||||
|
const s = String(text)
|
||||||
|
return s.length > n ? s.slice(0, n) + '…' : s
|
||||||
}
|
}
|
||||||
|
|
||||||
function short(v, n = 120) {
|
function short(v, n = 120) {
|
||||||
@@ -55,11 +90,25 @@ function short(v, n = 120) {
|
|||||||
} catch { return '' }
|
} catch { return '' }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 脱敏采样:保留结构与短字符串,避免把长内容/密钥写进状态文件 */
|
||||||
|
function sanitize(v, depth = 0) {
|
||||||
|
if (v == null || typeof v === 'number' || typeof v === 'boolean') return v
|
||||||
|
if (typeof v === 'string') return v.length > 200 ? v.slice(0, 200) + '…' : v
|
||||||
|
if (depth >= 4) return Array.isArray(v) ? `[array ${v.length}]` : '[object]'
|
||||||
|
if (Array.isArray(v)) return v.slice(0, 6).map((x) => sanitize(x, depth + 1))
|
||||||
|
const out = {}
|
||||||
|
for (const [k, val] of Object.entries(v)) out[k] = sanitize(val, depth + 1)
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
const SAMPLE_TYPES = new Set(['user/message', 'assistant/chunk', 'assistant/message', 'tool/call', 'tool/result', 'session/title', 'request/header', 'request/context'])
|
||||||
|
|
||||||
export function apply(ctx) {
|
export function apply(ctx) {
|
||||||
// 运行状态与自检日志落盘:插件跑在 DSH 内部,控制台不一定看得到,
|
// 运行状态与自检日志落盘:插件跑在 DSH 内部,控制台不一定看得到,
|
||||||
// 有了这个文件(和 /health)才能在用户机器上远程排障。
|
// 有了这个文件(和 /health)才能在用户机器上远程排障。
|
||||||
const notes = []
|
const notes = []
|
||||||
const seenTypes = new Map() // 事件类型直方图:写进状态文件,便于在用户机器上核对字段名
|
const seenTypes = new Map() // 事件类型直方图:写进状态文件,便于在用户机器上核对字段名
|
||||||
|
const samples = {} // 每种关键事件的脱敏样本(最多 3 条),用于对齐不同 DSH 版本的字段名
|
||||||
let lastRow = null
|
let lastRow = null
|
||||||
const statusFile0 = path.join(dshHome(), 'float-window', 'plugin-status.json')
|
const statusFile0 = path.join(dshHome(), 'float-window', 'plugin-status.json')
|
||||||
const status = { version: '0.1.0', pid: process.pid, startedAt: new Date().toISOString(), tool: false, settings: false, notes }
|
const status = { version: '0.1.0', pid: process.pid, startedAt: new Date().toISOString(), tool: false, settings: false, notes }
|
||||||
@@ -67,6 +116,7 @@ export function apply(ctx) {
|
|||||||
try {
|
try {
|
||||||
status.notes = notes.slice(-40)
|
status.notes = notes.slice(-40)
|
||||||
status.eventTypes = Object.fromEntries([...seenTypes].slice(-40))
|
status.eventTypes = Object.fromEntries([...seenTypes].slice(-40))
|
||||||
|
status.samples = samples
|
||||||
status.lastRow = lastRow
|
status.lastRow = lastRow
|
||||||
fs.mkdirSync(path.dirname(statusFile0), { recursive: true })
|
fs.mkdirSync(path.dirname(statusFile0), { recursive: true })
|
||||||
fs.writeFileSync(statusFile0, JSON.stringify(status, null, 2))
|
fs.writeFileSync(statusFile0, JSON.stringify(status, null, 2))
|
||||||
@@ -102,6 +152,7 @@ export function apply(ctx) {
|
|||||||
const MAX_ROWS = 120
|
const MAX_ROWS = 120
|
||||||
const rows = new Map() // sessionId -> row[]
|
const rows = new Map() // sessionId -> row[]
|
||||||
const counts = new Map() // sessionId -> 事件计数兜底(没有 sessionProjection 也能出数字)
|
const counts = new Map() // sessionId -> 事件计数兜底(没有 sessionProjection 也能出数字)
|
||||||
|
const titles = new Map() // sessionId -> 会话标题(来自 session/title 事件)
|
||||||
let lastSessionId = null
|
let lastSessionId = null
|
||||||
|
|
||||||
function counter(id) {
|
function counter(id) {
|
||||||
@@ -116,8 +167,16 @@ export function apply(ctx) {
|
|||||||
lastSessionId = id
|
lastSessionId = id
|
||||||
const c = counter(id)
|
const c = counter(id)
|
||||||
const type = String(event?.type || '')
|
const type = String(event?.type || '')
|
||||||
|
const d2 = () => event?.data || event || {}
|
||||||
seenTypes.set(type, (seenTypes.get(type) || 0) + 1)
|
seenTypes.set(type, (seenTypes.get(type) || 0) + 1)
|
||||||
|
if (SAMPLE_TYPES.has(type) && (samples[type]?.length ?? 0) < 3) {
|
||||||
|
(samples[type] ||= []).push(sanitize(event))
|
||||||
|
}
|
||||||
if (type === 'turn/end') c.turns++
|
if (type === 'turn/end') c.turns++
|
||||||
|
else if (type === 'session/title') {
|
||||||
|
const t = d2(event)?.title
|
||||||
|
if (t) titles.set(id, String(t))
|
||||||
|
}
|
||||||
else if (type === 'step/start') c.stepStart = Date.now()
|
else if (type === 'step/start') c.stepStart = Date.now()
|
||||||
else if (type === 'step/end') c.steps++
|
else if (type === 'step/end') c.steps++
|
||||||
else if (type === 'assistant/message') {
|
else if (type === 'assistant/message') {
|
||||||
@@ -175,8 +234,7 @@ export function apply(ctx) {
|
|||||||
const denom = input + read
|
const denom = input + read
|
||||||
if (denom > 0) out.cacheHit = read / denom
|
if (denom > 0) out.cacheHit = read / denom
|
||||||
}
|
}
|
||||||
let capacity = null
|
let capacity = modelContext()
|
||||||
try { capacity = ctx.get('llm')?.resolveModelInfo?.()?.context ?? null } catch { }
|
|
||||||
const used = m.surfaceTokens ?? m.totalTokens
|
const used = m.surfaceTokens ?? m.totalTokens
|
||||||
if (capacity && used) out.contextUsed = Math.min(1, used / capacity)
|
if (capacity && used) out.contextUsed = Math.min(1, used / capacity)
|
||||||
}
|
}
|
||||||
@@ -184,6 +242,27 @@ export function apply(ctx) {
|
|||||||
return out
|
return out
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 模型上下文容量。
|
||||||
|
* 踩坑(实测):`llm.resolveModelInfo()` 在没注册 provider 时**会抛错**,而且它返回
|
||||||
|
* Promise —— 只 try/catch 同步部分会留下 unhandled rejection,直接让 DSH 进程
|
||||||
|
* "fatal load failure" 退出(我在隔离实例里把 DSH 弄崩过一次)。
|
||||||
|
* 所以这里:同步异常、异步 reject、undefined 三种情况全部吞掉,并且只在拿到值时缓存。
|
||||||
|
*/
|
||||||
|
let cachedCapacity = null
|
||||||
|
function modelContext() {
|
||||||
|
try {
|
||||||
|
const llm = ctx.get('llm')
|
||||||
|
const r = llm?.resolveModelInfo?.()
|
||||||
|
if (r && typeof r.then === 'function') {
|
||||||
|
r.then((v) => { cachedCapacity = v?.context ?? cachedCapacity }).catch(() => { })
|
||||||
|
return cachedCapacity
|
||||||
|
}
|
||||||
|
if (r?.context) cachedCapacity = r.context
|
||||||
|
return cachedCapacity
|
||||||
|
} catch { return cachedCapacity }
|
||||||
|
}
|
||||||
|
|
||||||
function snapshot() {
|
function snapshot() {
|
||||||
const sessions = (() => { try { return ctx.get('sessions')?.list?.() || [] } catch { return [] } })()
|
const sessions = (() => { try { return ctx.get('sessions')?.list?.() || [] } catch { return [] } })()
|
||||||
let session = sessions.find((s) => s?.id === lastSessionId) || sessions[sessions.length - 1] || null
|
let session = sessions.find((s) => s?.id === lastSessionId) || sessions[sessions.length - 1] || null
|
||||||
@@ -192,7 +271,7 @@ export function apply(ctx) {
|
|||||||
ok: true,
|
ok: true,
|
||||||
at: Date.now(),
|
at: Date.now(),
|
||||||
sessionId: id || null,
|
sessionId: id || null,
|
||||||
title: session?.meta?.title || session?.title || null,
|
title: (id && titles.get(id)) || session?.meta?.title || session?.title || null,
|
||||||
messages: (id && rows.get(id)) || [],
|
messages: (id && rows.get(id)) || [],
|
||||||
metrics: session ? metricsFor(session) : { turns: null, steps: null, tps: null, tokens: null, cacheHit: null, contextUsed: null },
|
metrics: session ? metricsFor(session) : { turns: null, steps: null, tps: null, tokens: null, cacheHit: null, contextUsed: null },
|
||||||
sessions: sessions.slice(-10).map((s) => ({ id: s?.id, title: s?.meta?.title || s?.title || null })),
|
sessions: sessions.slice(-10).map((s) => ({ id: s?.id, title: s?.meta?.title || s?.title || null })),
|
||||||
@@ -264,10 +343,12 @@ export function apply(ctx) {
|
|||||||
if (!target) throw new Error('no session yet')
|
if (!target) throw new Error('no session yet')
|
||||||
const r = await api.sessions.prompt({
|
const r = await api.sessions.prompt({
|
||||||
rpcId: randomUUID(),
|
rpcId: randomUUID(),
|
||||||
sessionId: target,
|
payload: {
|
||||||
mode: 'queue',
|
sessionId: target,
|
||||||
content: [{ type: 'text', text }],
|
mode: 'queue',
|
||||||
clientTimeZone: Intl.DateTimeFormat().resolvedOptions().timeZone,
|
content: [{ type: 'text', text }],
|
||||||
|
clientTimeZone: Intl.DateTimeFormat().resolvedOptions().timeZone,
|
||||||
|
},
|
||||||
})
|
})
|
||||||
const result = r?.result ?? r
|
const result = r?.result ?? r
|
||||||
log(`chat -> session ${target}: ${JSON.stringify(result).slice(0, 160)}`)
|
log(`chat -> session ${target}: ${JSON.stringify(result).slice(0, 160)}`)
|
||||||
@@ -296,21 +377,27 @@ export function apply(ctx) {
|
|||||||
if (body.action === 'create') {
|
if (body.action === 'create') {
|
||||||
const r = await api.sessions.create({
|
const r = await api.sessions.create({
|
||||||
rpcId: randomUUID(),
|
rpcId: randomUUID(),
|
||||||
sessionId: body.sessionId ? String(body.sessionId) : randomUUID(),
|
payload: {
|
||||||
...(body.cwd ? { cwd: String(body.cwd) } : {}),
|
sessionId: body.sessionId ? String(body.sessionId) : randomUUID(),
|
||||||
...(body.workspaceId ? { workspaceId: body.workspaceId } : {}),
|
...(body.cwd ? { cwd: String(body.cwd) } : {}),
|
||||||
|
...(body.workspaceId ? { workspaceId: body.workspaceId } : {}),
|
||||||
|
},
|
||||||
})
|
})
|
||||||
const result = r?.result ?? r
|
const result = r?.result ?? r
|
||||||
if (result?.sessionId) lastSessionId = result.sessionId
|
if (result?.sessionId) lastSessionId = result.sessionId
|
||||||
log('session created: ' + short(result, 160))
|
log('session created: ' + short(result, 160))
|
||||||
json({ ok: true, ...result })
|
json({ ok: true, ...result })
|
||||||
} else if (body.action === 'cancel') {
|
} else if (body.action === 'cancel') {
|
||||||
const r = await api.sessions.cancel({ rpcId: randomUUID(), sessionId: body.sessionId || lastSessionId })
|
const r = await api.sessions.cancel({ rpcId: randomUUID(), payload: { sessionId: body.sessionId || lastSessionId } })
|
||||||
json({ ok: true, result: r?.result ?? r })
|
json({ ok: true, result: r?.result ?? r })
|
||||||
} else {
|
} else {
|
||||||
json({ ok: true, sessions: snapshot().sessions, current: lastSessionId })
|
json({ ok: true, sessions: snapshot().sessions, current: lastSessionId })
|
||||||
}
|
}
|
||||||
} catch (e) { json({ ok: false, error: short(e?.data || e?.message || e, 300) }) }
|
} catch (e) {
|
||||||
|
log('session route failed: ' + short(e?.data || e?.message || e, 200))
|
||||||
|
if (e?.stack) log('stack: ' + String(e.stack).split('\n').slice(0, 4).join(' | '))
|
||||||
|
json({ ok: false, error: short(e?.data || e?.message || e, 300) })
|
||||||
|
}
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
res.writeHead(404, { ...cors, 'Content-Type': 'text/plain; charset=utf-8' })
|
res.writeHead(404, { ...cors, 'Content-Type': 'text/plain; charset=utf-8' })
|
||||||
|
|||||||
+28
-11
@@ -82,18 +82,27 @@
|
|||||||
|
|
||||||
#title {
|
#title {
|
||||||
flex: 1 1 auto;
|
flex: 1 1 auto;
|
||||||
|
min-width: 0;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
font-size: var(--title-font);
|
font-size: var(--title-font);
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
white-space: nowrap;
|
|
||||||
overflow: hidden;
|
|
||||||
text-overflow: ellipsis;
|
|
||||||
letter-spacing: .2px;
|
letter-spacing: .2px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#titleText {
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
flex: 1 1 auto;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
#title small {
|
#title small {
|
||||||
|
flex: 0 0 auto;
|
||||||
color: var(--fg-dim);
|
color: var(--fg-dim);
|
||||||
font-weight: 400;
|
font-weight: 400;
|
||||||
margin-left: 6px;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.tbtn {
|
.tbtn {
|
||||||
@@ -472,7 +481,7 @@
|
|||||||
<div id="app">
|
<div id="app">
|
||||||
<div id="bar">
|
<div id="bar">
|
||||||
<div class="dot wait" id="dot"></div>
|
<div class="dot wait" id="dot"></div>
|
||||||
<div id="title">DSH 悬浮窗<small id="sub">未连接</small></div>
|
<div id="title"><span id="titleText">DSH 悬浮窗</span><small id="sub">未连接</small></div>
|
||||||
<button class="tbtn" id="bPin" title="固定位置">
|
<button class="tbtn" id="bPin" title="固定位置">
|
||||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round">
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round">
|
||||||
<path d="M9 4h6l-1 6 3 3v1H7v-1l3-3-1-6z" />
|
<path d="M9 4h6l-1 6 3 3v1H7v-1l3-3-1-6z" />
|
||||||
@@ -650,18 +659,26 @@
|
|||||||
if (msg.type === 'cmd') return handleCmd(msg.cmd)
|
if (msg.type === 'cmd') return handleCmd(msg.cmd)
|
||||||
if (msg.metrics) metrics(msg.metrics)
|
if (msg.metrics) metrics(msg.metrics)
|
||||||
if (msg.messages) messages(msg.messages)
|
if (msg.messages) messages(msg.messages)
|
||||||
if (msg.title) $('title').firstChild.textContent = msg.title
|
if (msg.title) {
|
||||||
|
const t = String(msg.title).slice(0, 42)
|
||||||
|
$('titleText').textContent = 'DSH · ' + t
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function metrics(m) {
|
function metrics(m) {
|
||||||
$('m-turn').textContent = m.turns ?? '—'
|
$('m-turn').textContent = m.turns ?? '—'
|
||||||
$('m-step').textContent = m.steps ?? '—'
|
$('m-step').textContent = m.steps ?? '—'
|
||||||
$('m-tps').textContent = (m.tps ?? 0).toFixed ? (m.tps ?? 0).toFixed(1) : '—'
|
$('m-tps').textContent = m.tps == null ? '—' : Number(m.tps).toFixed(1)
|
||||||
$('m-mtok').textContent = ((m.tokens ?? 0) / 1e6).toFixed(3)
|
$('m-mtok').textContent = m.tokens == null ? '—' : (m.tokens / 1e6).toFixed(3)
|
||||||
$('m-cache').textContent = m.cacheHit == null ? '—' : Math.round(m.cacheHit * 100) + '%'
|
$('m-cache').textContent = m.cacheHit == null ? '—' : Math.round(m.cacheHit * 100) + '%'
|
||||||
const p = Math.max(0, Math.min(1, m.contextUsed ?? 0))
|
if (m.contextUsed == null) {
|
||||||
$('m-ctxbar').style.right = (100 - p * 100) + '%'
|
$('m-ctxbar').style.right = '100%'
|
||||||
$('m-ctx').textContent = Math.round(p * 100) + '%'
|
$('m-ctx').textContent = '—'
|
||||||
|
} else {
|
||||||
|
const p = Math.max(0, Math.min(1, m.contextUsed))
|
||||||
|
$('m-ctxbar').style.right = (100 - p * 100) + '%'
|
||||||
|
$('m-ctx').textContent = Math.round(p * 100) + '%'
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function messages(list) {
|
function messages(list) {
|
||||||
|
|||||||
Reference in New Issue
Block a user