dsh-in-hand 1.5.0 → 1.5.2

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/README.md CHANGED
@@ -3,7 +3,7 @@
3
3
  **掌上 DSH(dsh-in-hand)** —— 手机端的 DeepSeek Harness,不用装 App。 它起初只是一份只读的**任务监控页**——让你在手机上
4
4
  瞄一眼 DSH 有几个任务在跑、有没有事在等你;后来接上了 DSH 自己的会话运行时,长成了手机浏览器里
5
5
  的 DSH 客户端:左上角会话列表、右上角新建对话、输入框上方是当前会话的实时指标(轮次 / 步数 /
6
- token 用量 / 缓存命中率 / 上下文占用),可以直接对话、逐字跟随输出、随时打断。
6
+ token/s / Token 消耗 / 缓存命中率 / 上下文占用),可以直接对话、逐字跟随输出、随时打断。
7
7
 
8
8
  没有 App:不需要原生代码、不需要打包、不需要上架、不需要装任何东西——浏览器打开,用「添加到
9
9
  主屏幕」就当一个 App 用(PWA,只要求 HTTPS 或 localhost)。
@@ -36,8 +36,8 @@ metrics row above the composer.
36
36
  渲染成手机上看得懂的样子。目的很朴素——不用坐到电脑前,也能知道它现在在干什么、有没有
37
37
  事在等你。
38
38
 
39
- 后来发现同一组窄接口能接上 DSH **自己的**会话运行时,于是「写」只用了 4 条路由就够:
40
- 对话、逐字收回复、随时停止、切换会话与模型。**读是监控,写是对话**——两者共用一套令牌和
39
+ 后来发现同一组窄接口能接上 DSH **自己的**会话运行时,于是「写」只用了 5 条路由就够:
40
+ 附件导入、对话、逐字收回复、随时停止、建会话与切换模型。**读是监控,写是对话**——两者共用一套令牌和
41
41
  一条反向隧道,始终不碰 GUI。
42
42
 
43
43
  所以这里没有 App 可装:没有原生代码、没有打包产物、没有应用商店。手机上进一次「添加到主
@@ -72,7 +72,7 @@ DSH 的 Web GUI 没有内置鉴权,而默认 preset 往往是 `danger-full-acc
72
72
 
73
73
  - **读**:把状态渲染成一份手机看得懂的页面,只放行这一条路径就够。
74
74
  - **写**:`/taskwatch/chat/*` 把 DSH **自己的**会话运行时接到手机上 —— 直接对话、
75
- 逐字收回复、随时停止、切换会话与模型。写入口**只有 4 条**,全部列在下面。
75
+ 逐字收回复、随时停止、切换会话与模型。写入口**只有 5 条**,全部列在下面。
76
76
 
77
77
  采集面完全通过 `ctx.get(...)` 软读取服务,任何一个服务不存在都只会让对应的那一块
78
78
  留空,不会让插件挂载失败。对话那一组同理:拿不到 `sessionController` 时只让这一个
@@ -111,18 +111,19 @@ DSH 的 Web GUI 没有内置鉴权,而默认 preset 往往是 `danger-full-acc
111
111
  | `/taskwatch/sw.js` | JS | service worker |
112
112
  | `/taskwatch/icon-192.png` · `/taskwatch/icon-512.png` | PNG | 图标 |
113
113
 
114
- 写(`POST`)—— **只有这 4 条**:
114
+ 写(`POST`)—— **只有这 5 条**:
115
115
 
116
116
  | 路径 | 请求体 | 说明 |
117
117
  |---|---|---|
118
- | `/taskwatch/chat/send` | `{sessionId,text,mode?,timeZone?}` | 给会话发一条消息 |
118
+ | `/taskwatch/chat/upload` | 原始二进制(`sessionId/kind/name/mime` 查询参数) | 导入文件或图片;图片由宿主校验,文件按会话签发 receipt |
119
+ | `/taskwatch/chat/send` | `{sessionId,text,attachments?,mode?,timeZone?}` | 给会话发文字和已上传附件 |
119
120
  | `/taskwatch/chat/cancel` | `{sessionId}` | 停止当前这一轮 |
120
121
  | `/taskwatch/chat/create` | `{}` 或 `{workspace}` | 新建会话;`workspace` 必须命中 `/taskwatch/chat/workspaces` 列出的路径 |
121
122
  | `/taskwatch/chat/model` | `{sessionId,provider,model}` | 切换该会话使用的模型 |
122
123
 
123
124
  全部注册在 `ctx.webServer` 上,不代理 DSH 自身的 `/api`,也不转发任意路径。
124
- 写入口就只有上面 4 条 —— 契约测试会**正面清点**路由表,增删都会让构建失败
125
- (早先那条断言写的是"源码里不出现 `method === 'POST'`"。加了 4 个写路由之后它
125
+ 写入口就只有上面 5 条 —— 契约测试会**正面清点**路由表,增删都会让构建失败
126
+ (早先那条断言写的是"源码里不出现 `method === 'POST'`"。加了 5 个写路由之后它
126
127
  照样通过,是条假绿灯:它证明的是"没这么写",不是"没有这个能力")。
127
128
 
128
129
  ### 手机能选工作区,但选不了任意目录
@@ -285,6 +286,11 @@ dsh plugin --profile web add dsh-in-hand
285
286
  装完需要**重启 `dsh web`**:插件行来自 profile 的 bundle 图,而 client 侧模块元数据
286
287
  按 specifier 缓存且没有失效路径,所以增删插件一律要重启才生效。
287
288
 
289
+ 环境:Node ≥ 20(见 `engines.node`)。本包开发与实测的环境是 Windows 上的 DSH 桌面版
290
+ **0.2.0-rc.2(nightly 通道)**。这个版本号是**实测记录,不是强制门槛**:我们刻意不声明
291
+ `engines.dsh` 范围,因为没在稳定线(0.1.x-rc.*)上端到端跑过,写一个没验证过的下限
292
+ 只会把稳定版用户挡在门外。
293
+
288
294
  本包**没有构建步骤**:`lib/` 是提交进仓库的、可直接运行的产物,客户端 bundle 就是
289
295
  一个调用 `window.__ModuleLoader__.load({ id, factory })` 的普通脚本。从源码安装不需要
290
296
  任何构建授权(`allowBuilds`)。
@@ -386,7 +392,7 @@ PWA 外壳由中继本地提供(而不是转发上游),这样它不随上
386
392
  ## 开发
387
393
 
388
394
  ```sh
389
- node scripts/check-bundle.mjs # 客户端 bundle + 宿主契约 + 页面的测试(54 项)
395
+ npm run check # bundle、文件边界、上传路由与页面契约测试
390
396
  node scripts/make-icons.mjs # 重新生成图标(自实现 PNG 编码,零依赖)
391
397
  node scripts/make-demo.mjs # 生成 docs/demo.html(假数据,供截图与预览)
392
398
  ```
@@ -397,6 +403,10 @@ node scripts/make-demo.mjs # 生成 docs/demo.html(假数据,供截图
397
403
  `name` / `apply` / `inject` 三个导出都在,并把 `cordis.patch.yml`、`exports`、
398
404
  `dsh` 元数据一并核对。
399
405
 
406
+ `check-uploads.mjs` 会起一个本地 `node:http` 测试服务器,覆盖混合附件发送、会话隔离、
407
+ 失败重试、数量 / 总大小限制、错误的请求方法与类型,以及宿主缺少上传服务时的错误提示;
408
+ 测试只使用内存 fake 服务,不会连接真实模型。
409
+
400
410
  为什么宿主也要钉住:Cordis 的 `inject` 决定框架等哪些服务就绪后才调用 `apply`。
401
411
  `webServer` 的服务是在启动阶段之后才发布的,而 `apply` 在组合期就会被调用——
402
412
  只写 `ctx.get('webServer')` 软读会在那一刻拿到 `undefined`,于是整段路由注册被
package/lib/index.js CHANGED
@@ -184,6 +184,13 @@ export function apply(ctx) {
184
184
  const questions = new Map()
185
185
  const runningById = new Map()
186
186
  const runs = new Map()
187
+ // 只保存本入口签发的会话内上传记录;大小来自实际字节流而非客户端声明。
188
+ // 文件仍由宿主 receipt 授权,图片由不透明 token 授权;成功接收后才消费。
189
+ const pendingUploads = new Map()
190
+ const UPLOAD_TTL_MS = 30 * 60 * 1000
191
+ const MAX_PENDING_UPLOADS = 128
192
+ const MAX_SESSION_UPLOADS = 16
193
+ let activeUploads = 0
187
194
 
188
195
  ctx.on('agent/status', (payload) => {
189
196
  try {
@@ -743,6 +750,25 @@ export function apply(ctx) {
743
750
  req.on('error', reject)
744
751
  })
745
752
 
753
+ const readBytes = async (req, limit, signal, measure) => {
754
+ const chunks = []
755
+ for await (const chunk of limitedChunks(req, limit, signal, measure)) chunks.push(chunk)
756
+ return Buffer.concat(chunks)
757
+ }
758
+
759
+ const limitedChunks = async function* (req, limit, signal, measure) {
760
+ signal.throwIfAborted()
761
+ for await (const chunk of req) {
762
+ signal.throwIfAborted()
763
+ const part = Buffer.from(chunk)
764
+ measure.bytes += part.length
765
+ if (measure.bytes > limit) throw new Error('附件不能超过 32 MB')
766
+ yield part
767
+ }
768
+ signal.throwIfAborted()
769
+ if (req.complete === false) throw new Error('上传已中断')
770
+ }
771
+
746
772
  const readJson = async (req) => {
747
773
  const raw = await readRaw(req, 32 * 1024)
748
774
  if (!raw) return {}
@@ -750,6 +776,128 @@ export function apply(ctx) {
750
776
  return parsed && typeof parsed === 'object' ? parsed : {}
751
777
  }
752
778
 
779
+ const IMAGE_TYPES = new Set(['image/png', 'image/jpeg', 'image/webp', 'image/gif'])
780
+ const pruneUploads = () => {
781
+ const cutoff = Date.now() - UPLOAD_TTL_MS
782
+ for (const [key, value] of pendingUploads) {
783
+ if (!value.sending && value.createdAt <= cutoff) pendingUploads.delete(key)
784
+ }
785
+ }
786
+ const checkUploadRoom = (sessionId) => {
787
+ pruneUploads()
788
+ let count = 0
789
+ for (const value of pendingUploads.values()) if (value.sessionId === sessionId) count++
790
+ if (pendingUploads.size >= MAX_PENDING_UPLOADS || count >= MAX_SESSION_UPLOADS) {
791
+ throw new Error('待发送附件过多,请先发送已有附件或稍后再试')
792
+ }
793
+ }
794
+ const rememberUpload = (key, value) => {
795
+ checkUploadRoom(value.sessionId)
796
+ pendingUploads.set(key, value)
797
+ }
798
+ const requireUploadAgent = async (sessionId) => {
799
+ const service = chatOf()
800
+ if (typeof service.resolveAgent !== 'function') throw new Error('宿主缺少附件会话校验服务')
801
+ const found = await service.resolveAgent(sessionId)
802
+ if (found && found.agent) return found.agent
803
+ // Older session-controller builds return Agent directly; current public
804
+ // API returns { agent } or { error }.
805
+ if (found && found.error) throw found.error
806
+ if (found && found.session) return found
807
+ throw new Error('会话不存在')
808
+ }
809
+ const requestLifetime = (req, res) => {
810
+ const controller = new AbortController()
811
+ const abort = () => controller.abort(new Error('请求已中断'))
812
+ const closed = () => { if (!res.writableEnded) abort() }
813
+ req.on('aborted', abort)
814
+ res.on('close', closed)
815
+ return {
816
+ signal: controller.signal,
817
+ dispose() { req.off('aborted', abort); res.off('close', closed) },
818
+ }
819
+ }
820
+ const requirePost = (req, res, type) => {
821
+ if (req.method !== 'POST') {
822
+ res.setHeader('allow', 'POST')
823
+ json(res, 405, { error: '只接受 POST' })
824
+ return false
825
+ }
826
+ if (String(req.headers['content-type'] || '').split(';', 1)[0].trim().toLowerCase() !== type) {
827
+ json(res, 415, { error: '请求必须使用 ' + type })
828
+ return false
829
+ }
830
+ return true
831
+ }
832
+
833
+ // 原始二进制上传:文件由 fileUploads 按 Session 暂存,图片由 attachments
834
+ // 先做图像准入再用一次性 token 交给后续 prompt。请求体硬上限沿用交付文件
835
+ // 的 32 MiB 边界,名称只作为显示名传给宿主,绝不解释为路径。
836
+ ctx.effect(() => webServer.register({
837
+ kind: 'exact',
838
+ path: '/taskwatch/chat/upload',
839
+ handler: async (req, res) => {
840
+ if (!requirePost(req, res, 'application/octet-stream')) return
841
+ if (Number(req.headers['content-length']) > MAX_BYTES) {
842
+ json(res, 413, { error: '附件不能超过 32 MB' })
843
+ return
844
+ }
845
+ const lifetime = requestLifetime(req, res)
846
+ let active = false
847
+ try {
848
+ const url = new URL(req.url || '/', 'http://127.0.0.1')
849
+ const sessionId = asText(url.searchParams.get('sessionId')).trim()
850
+ const kind = asText(url.searchParams.get('kind')).trim().toLowerCase()
851
+ const name = asText(url.searchParams.get('name')).trim().slice(0, 200)
852
+ const mime = asText(url.searchParams.get('mime')).trim().toLowerCase()
853
+ if (!sessionId) throw new Error('缺少 sessionId')
854
+ if (kind !== 'image' && kind !== 'file') throw new Error('上传类型无效')
855
+ if (activeUploads >= 8) throw new Error('正在上传的附件过多,请稍后重试')
856
+ activeUploads++; active = true
857
+ checkUploadRoom(sessionId)
858
+ await requireUploadAgent(sessionId)
859
+ lifetime.signal.throwIfAborted()
860
+ const measure = { bytes: 0 }
861
+ if (kind === 'image') {
862
+ if (!IMAGE_TYPES.has(mime)) throw new Error('只支持 PNG、JPEG、WEBP、GIF 图片')
863
+ const store = ctx.get('attachments')
864
+ if (!store || typeof store.saveImage !== 'function') throw new Error('宿主没有图片附件服务')
865
+ const ref = await store.saveImage({ data: await readBytes(req, MAX_BYTES, lifetime.signal, measure), mediaType: mime, ...(name ? { name } : {}) })
866
+ lifetime.signal.throwIfAborted()
867
+ if (!ref || !Number.isSafeInteger(ref.bytes) || ref.bytes < 0 || ref.bytes > MAX_BYTES) throw new Error('图片处理后超过 32 MB 或附件信息无效')
868
+ const token = randomUUID()
869
+ rememberUpload('image:' + token, { sessionId, kind, ref, bytes: Math.max(measure.bytes, ref.bytes), createdAt: Date.now() })
870
+ json(res, 200, { ok: true, upload: {
871
+ kind: 'image', token, name: ref.name || name || '图片', bytes: ref.bytes,
872
+ mime: ref.mediaType, width: ref.width, height: ref.height,
873
+ } })
874
+ return
875
+ }
876
+ const uploads = ctx.get('fileUploads')
877
+ if (!uploads || typeof uploads.uploadStream !== 'function') throw new Error('宿主没有文件上传服务')
878
+ const value = await uploads.uploadStream({
879
+ sessionId,
880
+ data: limitedChunks(req, MAX_BYTES, lifetime.signal, measure),
881
+ signal: lifetime.signal,
882
+ ...(name ? { name } : {}),
883
+ })
884
+ lifetime.signal.throwIfAborted()
885
+ if (!value || !asText(value.receiptId) || !value.file || value.file.bytes !== measure.bytes) throw new Error('宿主返回了无效的文件上传信息')
886
+ rememberUpload('file:' + value.receiptId, { sessionId, kind, ref: value.file, bytes: measure.bytes, createdAt: Date.now() })
887
+ json(res, 200, { ok: true, upload: {
888
+ kind: 'file', receiptId: String(value.receiptId),
889
+ name: value.file && value.file.name ? value.file.name : name || '文件',
890
+ bytes: value.file && value.file.bytes ? value.file.bytes : 0,
891
+ } })
892
+ } catch (e) {
893
+ if (!res.destroyed && !res.writableEnded) json(res, 200, { error: why(e) })
894
+ } finally {
895
+ if (active) activeUploads--
896
+ lifetime.dispose()
897
+ }
898
+ },
899
+ }), 'in-hand: chat upload route')
900
+
753
901
  const ctl = () => new AbortController()
754
902
 
755
903
  // 会话列表。标题只对**活着的**会话可得(sessionTitle 需要一个 Session 对象),
@@ -880,23 +1028,80 @@ export function apply(ctx) {
880
1028
  kind: 'exact',
881
1029
  path: '/taskwatch/chat/send',
882
1030
  handler: async (req, res) => {
1031
+ if (!requirePost(req, res, 'application/json')) return
1032
+ const lifetime = requestLifetime(req, res)
1033
+ const locked = []
883
1034
  try {
884
1035
  const body = await readJson(req)
885
1036
  const sessionId = asText(body.sessionId)
886
1037
  const text = asText(body.text)
887
1038
  if (!sessionId) throw new Error('缺少 sessionId')
888
- if (!text.trim()) throw new Error('消息不能为空')
1039
+ if (body.attachments !== undefined && !Array.isArray(body.attachments)) throw new Error('附件格式无效')
1040
+ const uploads = body.attachments || []
1041
+ if (uploads.length > 4) throw new Error('一次最多添加 4 个附件')
1042
+ if (!text.trim() && !uploads.length) throw new Error('消息不能为空')
1043
+ pruneUploads()
1044
+ let totalBytes = 0
1045
+ const seen = new Set()
1046
+ // 先原子校验/锁定整批元数据,再做异步读盘,失败时解锁而不消费。
1047
+ for (const item of uploads) {
1048
+ if (!item || typeof item !== 'object') throw new Error('附件格式无效')
1049
+ const kind = asText(item.kind)
1050
+ if (kind !== 'image' && kind !== 'file') throw new Error('附件类型无效')
1051
+ const key = kind + ':' + asText(kind === 'image' ? item.token : item.receiptId)
1052
+ const record = pendingUploads.get(key)
1053
+ if (!record || record.sessionId !== sessionId || record.kind !== kind) throw new Error('附件已失效或不属于当前会话,请重新上传')
1054
+ if (seen.has(key)) throw new Error('请勿重复添加同一个附件')
1055
+ if (record.sending) throw new Error('附件正在发送,请勿重复提交')
1056
+ seen.add(key)
1057
+ totalBytes += record.bytes
1058
+ if (totalBytes > MAX_BYTES) throw new Error('每条消息的附件总大小不能超过 32 MB')
1059
+ record.sending = true
1060
+ locked.push({ key, record, item })
1061
+ }
1062
+ const content = []
1063
+ if (text.trim()) content.push({ type: 'text', text })
1064
+ const agent = uploads.length ? await requireUploadAgent(sessionId) : null
1065
+ lifetime.signal.throwIfAborted()
1066
+ for (const { record, item } of locked) {
1067
+ if (record.kind === 'file') {
1068
+ const receiptId = asText(item.receiptId)
1069
+ const service = ctx.get('fileUploads')
1070
+ if (!service || typeof service.resolve !== 'function') throw new Error('宿主没有文件上传服务')
1071
+ const ref = service.resolve(agent, receiptId)
1072
+ if (!ref || ref.attachmentId !== record.ref.attachmentId || ref.bytes !== record.bytes) throw new Error('文件上传已失效,请重新上传')
1073
+ content.push({ type: 'file', receiptId })
1074
+ continue
1075
+ }
1076
+ if (record.kind === 'image') {
1077
+ const store = ctx.get('attachments')
1078
+ if (!store || typeof store.readImage !== 'function') throw new Error('宿主没有图片附件服务')
1079
+ const stored = await store.readImage(record.ref, lifetime.signal)
1080
+ lifetime.signal.throwIfAborted()
1081
+ content.push({
1082
+ type: 'image', mediaType: stored.ref.mediaType,
1083
+ data: Buffer.from(stored.data).toString('base64'),
1084
+ ...(stored.ref.name ? { name: stored.ref.name } : {}),
1085
+ })
1086
+ continue
1087
+ }
1088
+ }
889
1089
  const request = {
890
1090
  requestId: randomUUID(),
891
1091
  sessionId,
892
1092
  mode: body.mode === 'steer' ? 'steer' : 'queue',
893
- content: [{ type: 'text', text }],
1093
+ content,
894
1094
  }
895
1095
  if (asText(body.timeZone)) request.clientTimeZone = asText(body.timeZone)
896
- const value = await chatOf().prompt(request, ctl().signal)
897
- json(res, 200, { ok: true, accepted: !!(value && value.accepted) })
1096
+ const value = await chatOf().prompt(request, lifetime.signal)
1097
+ if (!value || value.accepted !== true) throw new Error('宿主未接收消息,请重试')
1098
+ for (const { key, record } of locked) if (pendingUploads.get(key) === record) pendingUploads.delete(key)
1099
+ if (!res.destroyed && !res.writableEnded) json(res, 200, { ok: true, accepted: true })
898
1100
  } catch (e) {
899
- json(res, 200, { error: why(e) })
1101
+ if (!res.destroyed && !res.writableEnded) json(res, 200, { error: why(e) })
1102
+ } finally {
1103
+ for (const { record } of locked) record.sending = false
1104
+ lifetime.dispose()
900
1105
  }
901
1106
  },
902
1107
  }), 'in-hand: chat send route')
@@ -968,14 +1173,10 @@ export function apply(ctx) {
968
1173
  },
969
1174
  }), 'in-hand: chat stream route')
970
1175
 
971
- // 会话用量:把「几轮 / 几步 / 多少 token / 缓存命中 / 上下文占用」一次交给手机。
972
- //
973
- // 数据来源都是**真东西**,不是估算:
974
- // - 轮次与步数直接数会话事件流里的 turn/start 与 step/start;
975
- // - token 走 tokenMeter.measure()(宿主自己的计量服务);
976
- // - 上下文窗口取自 requestContext()。
977
- // tokenMeter 是可选服务:拿不到就**不带这些字段**,让页面显示"暂无",
978
- // 而不是填 0 —— 0 和"不知道"在监控页上是两件完全不同的事。
1176
+ // 会话用量:把「轮 / 步 / token/s / 缓存命中 / token 消耗 / 上下文占用」一次交给手机。
1177
+ // 优先读取 sessionController.projections() 的官方持久投影:它不需要激活会话,
1178
+ // 冷会话也能显示完整数字。旧宿主没有投影时,再退回 live 事件和 tokenMeter,
1179
+ // 但不会把估算值冒充成精确的 token 消耗。
979
1180
  ctx.effect(() => webServer.register({
980
1181
  kind: 'exact',
981
1182
  path: '/taskwatch/chat/usage',
@@ -984,19 +1185,80 @@ export function apply(ctx) {
984
1185
  const url = new URL(req.url || '/', 'http://127.0.0.1')
985
1186
  const sessionId = url.searchParams.get('sessionId') || ''
986
1187
  if (!sessionId) throw new Error('缺少 sessionId')
1188
+ const chat = chatOf()
987
1189
  const store = ctx.get('sessions')
988
1190
  const live = store !== undefined ? store.get(sessionId) : undefined
989
- if (live === undefined) { json(res, 200, { live: false }); return }
1191
+ let baseline = null
1192
+ if (typeof chat.projections === 'function') {
1193
+ try { baseline = await chat.projections({ sessionId }, ctl().signal) } catch { baseline = null }
1194
+ }
1195
+ if (live === undefined && !baseline) { json(res, 200, { live: false }); return }
990
1196
  const out = { live: true, turns: 0, steps: 0, toolCalls: 0 }
1197
+ const values = baseline && baseline.values && typeof baseline.values === 'object' ? baseline.values : {}
1198
+ const sessionStats = values.sessionStats
1199
+ const tokenUsage = values.tokenUsage
1200
+ const contextPressure = values.contextPressure
1201
+ const contextBreakdown = values.contextBreakdown
1202
+ let hasSessionStats = false
1203
+ let hasTokenUsage = false
1204
+
1205
+ if (sessionStats && typeof sessionStats === 'object') {
1206
+ hasSessionStats = true
1207
+ out.turns = asNum(sessionStats.turns)
1208
+ out.steps = asNum(sessionStats.steps)
1209
+ out.llmMs = asNum(sessionStats.llmMs)
1210
+ out.toolMs = asNum(sessionStats.toolMs)
1211
+ out.ttftMs = asNum(sessionStats.ttftMs)
1212
+ out.ttftSteps = asNum(sessionStats.ttftSteps)
1213
+ out.decodeMs = asNum(sessionStats.decodeMs)
1214
+ out.decodeTokens = asNum(sessionStats.decodeTokens)
1215
+ if (out.decodeMs > 0 && out.decodeTokens >= 0) {
1216
+ out.tokensPerSecond = out.decodeTokens * 1000 / out.decodeMs
1217
+ }
1218
+ }
1219
+
1220
+ if (tokenUsage && typeof tokenUsage === 'object') {
1221
+ hasTokenUsage = true
1222
+ out.uncachedInputTokens = asNum(tokenUsage.uncachedInputTokens)
1223
+ out.outputTokens = asNum(tokenUsage.outputTokens)
1224
+ out.cacheReadTokens = asNum(tokenUsage.cacheReadTokens)
1225
+ out.cacheWriteTokens = asNum(tokenUsage.cacheWriteTokens)
1226
+ }
1227
+
1228
+ if (contextPressure && typeof contextPressure === 'object') {
1229
+ const projected = typeof contextPressure.projectedTokens === 'number' && isFinite(contextPressure.projectedTokens)
1230
+ ? contextPressure.projectedTokens : null
1231
+ const pressure = typeof contextPressure.pressureTokens === 'number' && isFinite(contextPressure.pressureTokens)
1232
+ ? contextPressure.pressureTokens : null
1233
+ if (projected !== null || pressure !== null) {
1234
+ out.contextTokens = projected === null ? pressure : projected
1235
+ }
1236
+ out.contextWindow = asNum(contextPressure.contextWindow)
1237
+ }
1238
+ if (contextBreakdown && typeof contextBreakdown === 'object') {
1239
+ out.contextBreakdown = {
1240
+ systemTokens: asNum(contextBreakdown.systemTokens),
1241
+ toolsTokens: asNum(contextBreakdown.toolsTokens),
1242
+ messageTokens: asNum(contextBreakdown.messageTokens),
1243
+ }
1244
+ }
1245
+
1246
+ // 这几个字段是前端详情与指标条共用的规范化口径。
1247
+ if (hasTokenUsage) {
1248
+ out.tokenConsumption = out.uncachedInputTokens + out.outputTokens +
1249
+ out.cacheReadTokens + out.cacheWriteTokens
1250
+ const promptTokens = out.uncachedInputTokens + out.cacheReadTokens + out.cacheWriteTokens
1251
+ if (promptTokens > 0) out.cacheHitPercent = 100 * out.cacheReadTokens / promptTokens
1252
+ }
991
1253
 
992
1254
  try {
993
1255
  const events = typeof live.ownEvents === 'function' ? live.ownEvents() : []
994
1256
  for (const e of events) {
995
1257
  if (!e || !e.type) continue
996
- if (e.type === 'turn/start') {
1258
+ if (!hasSessionStats && e.type === 'turn/start') {
997
1259
  const n = asNum(e.data && e.data.turn)
998
1260
  if (n > out.turns) out.turns = n
999
- } else if (e.type === 'step/start') out.steps += 1
1261
+ } else if (!hasSessionStats && e.type === 'step/start') out.steps += 1
1000
1262
  else if (e.type === 'tool/call') out.toolCalls += 1
1001
1263
  }
1002
1264
  } catch (e) { out.eventsError = why(e) }
@@ -1005,30 +1267,45 @@ export function apply(ctx) {
1005
1267
  if (meter !== undefined && typeof meter.measure === 'function') {
1006
1268
  try {
1007
1269
  const m = meter.measure(live)
1008
- out.totalTokens = asNum(m.totalTokens)
1270
+ if (!hasTokenUsage) out.totalTokens = asNum(m.totalTokens)
1009
1271
  out.surfaceTokens = asNum(m.surfaceTokens)
1010
1272
  const base = m.baseline
1011
- if (base && base.kind === 'usage' && base.usage) {
1273
+ if (!hasTokenUsage && base && base.kind === 'usage' && base.usage) {
1012
1274
  out.inputTokens = asNum(base.usage.inputTokens)
1013
1275
  out.outputTokens = asNum(base.usage.outputTokens)
1014
1276
  out.cacheReadTokens = asNum(base.usage.cacheReadTokens)
1015
1277
  out.cacheWriteTokens = asNum(base.usage.cacheWriteTokens)
1016
1278
  out.reasoningTokens = asNum(base.usage.reasoningTokens)
1279
+ out.tokenConsumption = out.inputTokens + out.outputTokens + out.cacheReadTokens + out.cacheWriteTokens
1280
+ const promptTokens = out.inputTokens + out.cacheReadTokens + out.cacheWriteTokens
1281
+ if (promptTokens > 0) out.cacheHitPercent = 100 * out.cacheReadTokens / promptTokens
1017
1282
  }
1018
1283
  } catch (e) { out.meterError = why(e) }
1019
1284
  } else {
1020
1285
  out.noTokenMeter = true
1021
1286
  }
1022
1287
 
1288
+ if (typeof out.contextTokens !== 'number' && out.surfaceTokens) out.contextTokens = out.surfaceTokens
1289
+ if (!out.tokensPerSecond && out.decodeMs > 0 && out.outputTokens >= 0) {
1290
+ out.tokensPerSecond = out.outputTokens * 1000 / out.decodeMs
1291
+ }
1292
+ if (out.contextWindow > 0 && typeof out.contextTokens === 'number') {
1293
+ out.contextPercent = Math.min(100, 100 * out.contextTokens / out.contextWindow)
1294
+ }
1295
+
1023
1296
  try {
1024
1297
  const rc = typeof live.requestContext === 'function' ? live.requestContext() : undefined
1025
1298
  if (rc) {
1026
1299
  out.provider = asText(rc.provider)
1027
1300
  out.model = asText(rc.model)
1028
- out.contextWindow = asNum(rc.contextWindow)
1301
+ if (!out.contextWindow) out.contextWindow = asNum(rc.contextWindow)
1029
1302
  }
1030
1303
  } catch (e) { /* 上下文窗口拿不到就不带,页面自然不显示百分比 */ }
1031
1304
 
1305
+ if (out.contextWindow > 0 && typeof out.contextTokens === 'number') {
1306
+ out.contextPercent = Math.min(100, 100 * out.contextTokens / out.contextWindow)
1307
+ }
1308
+
1032
1309
  json(res, 200, out)
1033
1310
  } catch (e) {
1034
1311
  json(res, 200, { error: why(e) })