dsh-in-hand 1.5.1 → 1.6.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/README.md +146 -9
- package/lib/index.js +296 -19
- package/lib/page.html +1006 -215
- package/lib/sw.js +14 -4
- package/package.json +2 -2
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 **自己的**会话运行时,于是「写」只用了
|
|
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
|
-
逐字收回复、随时停止、切换会话与模型。写入口**只有
|
|
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`)—— **只有这
|
|
114
|
+
写(`POST`)—— **只有这 5 条**:
|
|
115
115
|
|
|
116
116
|
| 路径 | 请求体 | 说明 |
|
|
117
117
|
|---|---|---|
|
|
118
|
-
| `/taskwatch/chat/
|
|
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
|
-
写入口就只有上面
|
|
125
|
-
(早先那条断言写的是"源码里不出现 `method === 'POST'`"。加了
|
|
125
|
+
写入口就只有上面 5 条 —— 契约测试会**正面清点**路由表,增删都会让构建失败
|
|
126
|
+
(早先那条断言写的是"源码里不出现 `method === 'POST'`"。加了 5 个写路由之后它
|
|
126
127
|
照样通过,是条假绿灯:它证明的是"没这么写",不是"没有这个能力")。
|
|
127
128
|
|
|
128
129
|
### 手机能选工作区,但选不了任意目录
|
|
@@ -330,6 +331,138 @@ dsh plugin --profile web add <你的绝对路径>/dsh-in-hand
|
|
|
330
331
|
PWA 外壳由中继本地提供(而不是转发上游),这样它不随上游插件的版本变化而失效;
|
|
331
332
|
页面文件与插件共用同一份,避免两边漂移。
|
|
332
333
|
|
|
334
|
+
## 局域网直连(同一 Wi-Fi)
|
|
335
|
+
|
|
336
|
+
上面那条路要域名、要 nginx、要一条 `ssh -R` —— 适合"人在外面也想看"。如果手机和电脑
|
|
337
|
+
就在同一个屋子里,还有一条更短的路:**让中继直接监听内网地址,手机连同一个 Wi-Fi
|
|
338
|
+
直连它。**
|
|
339
|
+
|
|
340
|
+
### 怎么开(中继侧开关)
|
|
341
|
+
|
|
342
|
+
中继默认只监听回环(`127.0.0.1`),连局域网都到不了,只有隧道能进。开局域网模式就是
|
|
343
|
+
一个开关:在中继目录里放一个空的 `.lan` 标记文件,或者给它设环境变量 `RELAY_LAN=1`,
|
|
344
|
+
它就从 `127.0.0.1` 改为绑定 `0.0.0.0`;删掉标记文件即关闭(这个开关是中继**启动时**
|
|
345
|
+
读的,改完要重启它才生效)。中继自己的
|
|
346
|
+
`/taskwatch/lan` 页面(**不需要令牌,也不含任何凭据**)会列出当前是否已开启、本机可用
|
|
347
|
+
的内网地址和连接步骤。
|
|
348
|
+
|
|
349
|
+
### 手机怎么进
|
|
350
|
+
|
|
351
|
+
1. 在跑中继的电脑上打开 `http://127.0.0.1:3099/taskwatch/pair`,页面上会显示一个
|
|
352
|
+
**6 位配对码**(只有从本机访问才显示)。
|
|
353
|
+
2. 手机连同一个 Wi-Fi,打开 `http://192.168.1.20:3099/taskwatch`(把 `192.168.1.20`
|
|
354
|
+
换成电脑的内网地址,以中继 `/taskwatch/lan` 列出的为准)。它会进配对页。
|
|
355
|
+
3. 输入那 6 位码即可。配对码 **2 分钟有效、最多试 5 次、只能用一次**;换到的 cookie
|
|
356
|
+
有效期 30 天,并且每次带着它访问都会滑动续期。
|
|
357
|
+
|
|
358
|
+
页面右上角那个双入口胶囊也可以在这条路上用(见下一节)。
|
|
359
|
+
|
|
360
|
+
### 这条路的硬限制
|
|
361
|
+
|
|
362
|
+
- **`http://` 不是安全上下文。** 装不成 PWA、没有 service worker 离线外壳,摄像头 /
|
|
363
|
+
麦克风、剪贴板 API 也用不了。页面本身照常能用 —— 但"添加到主屏幕当一个 App 用"
|
|
364
|
+
这件事只在 HTTPS 那条路上成立。
|
|
365
|
+
- **IP 会随 DHCP 变。** 路由器重新分配地址之后,手机上存的那条
|
|
366
|
+
`http://192.168.x.x:3099` 就失效了;以中继 `/taskwatch/lan` 当前列出的地址为准。
|
|
367
|
+
- **开启客户端隔离(AP isolation)的 Wi-Fi 连不上。** 那种设置下同一 Wi-Fi 的设备之间
|
|
368
|
+
互相不可见,手机到不了电脑 —— 这是路由器的行为,不是这条路能绕开的。
|
|
369
|
+
- **出了这个 Wi-Fi 就连不上。** 要外网访问就用上面的隧道那条路。
|
|
370
|
+
|
|
371
|
+
### 安全提醒
|
|
372
|
+
|
|
373
|
+
**拿到这条地址 + 配对码,等于拿到你机器上的一个终端。** 手机端能驱动 agent,而 DSH
|
|
374
|
+
默认是 `danger-full-access`。所以:只在自己家、朋友家这类你信得过的网络里开;公司 /
|
|
375
|
+
公共网络的 Wi-Fi 不要常开;不用的时候就删掉 `.lan` 标记文件,让它退回只监听回环。
|
|
376
|
+
|
|
377
|
+
配对码这一层是刻意收紧的:它一次性,并且只在**本机 `127.0.0.1`** 的页面上显示 ——
|
|
378
|
+
能操作这台电脑的人,才能授权一台手机。
|
|
379
|
+
|
|
380
|
+
## 连接原理(实现路径)
|
|
381
|
+
|
|
382
|
+
### 两条路
|
|
383
|
+
|
|
384
|
+
远端(人在外面):
|
|
385
|
+
|
|
386
|
+
```
|
|
387
|
+
手机
|
|
388
|
+
└─ HTTPS https://dsh.example.com (示例域名,不是真实部署)
|
|
389
|
+
└─ nginx(服务器) 反向代理:TLS 终止
|
|
390
|
+
└─ SSH 反向隧道 ssh -R 本机主动连出,不需要公网 IP 与端口映射
|
|
391
|
+
└─ 本机中继 127.0.0.1:3099 校验中继令牌,按白名单转发
|
|
392
|
+
└─ DSH 上游 127.0.0.1:3080 /taskwatch/*(读 + /taskwatch/chat/* 写)
|
|
393
|
+
```
|
|
394
|
+
|
|
395
|
+
局域网(同一个 Wi-Fi):
|
|
396
|
+
|
|
397
|
+
```
|
|
398
|
+
手机
|
|
399
|
+
└─ HTTP http://192.168.1.20:3099/taskwatch 直连中继(中继绑定 0.0.0.0)
|
|
400
|
+
└─ 同一套 6 位配对码 → 30 天 cookie
|
|
401
|
+
└─ DSH 上游 127.0.0.1:3080
|
|
402
|
+
```
|
|
403
|
+
|
|
404
|
+
### 为什么用 SSH 反向隧道
|
|
405
|
+
|
|
406
|
+
家里的机器通常在 NAT 后面:没有固定公网 IP,也没开入站端口。反向隧道是**本机主动连出**
|
|
407
|
+
到服务器,服务器再顺着这条已经建立的连接把请求送回来 —— 所以不需要公网 IP,也不需要在
|
|
408
|
+
路由器上做端口映射。服务器那一侧只多一个 nginx 反代负责 TLS 终止。
|
|
409
|
+
|
|
410
|
+
### 令牌和 6 位配对码各解决什么
|
|
411
|
+
|
|
412
|
+
两段路,两道锁,解的不是同一个问题:
|
|
413
|
+
|
|
414
|
+
- **中继令牌**(中继侧的 `RELAY_TOKEN`,或它自己生成在 `.token` 里的值)管的是
|
|
415
|
+
「**服务器 → 本机中继**」这一段:证明这个请求是从被授权的隧道入口进来的。比较是定长
|
|
416
|
+
的(`timingSafeEqual`),并且令牌不会被转发给上游。
|
|
417
|
+
- **6 位配对码**管的是「**手机 → 中继**」这一段:手机第一次访问时用它换一张 30 天、会
|
|
418
|
+
滑动续期的 `HttpOnly` cookie。配对码 2 分钟有效、最多试 5 次、只能用一次,并且只在
|
|
419
|
+
**`127.0.0.1`** 的页面上显示。
|
|
420
|
+
|
|
421
|
+
### 为什么中继看不出手机在哪个网络
|
|
422
|
+
|
|
423
|
+
隧道那条路上,请求在中继眼里**来源都是 `127.0.0.1`** —— 它看到的对端是隧道,不是手机。
|
|
424
|
+
所以中继分不清手机是在家里、在 4G,还是在同一个 Wi-Fi 里;同理,HTTPS 页面也没法探测
|
|
425
|
+
`http://` 内网地址(浏览器按混合内容拦掉,IP 主机连"可升级"都不算)。
|
|
426
|
+
|
|
427
|
+
这就是手机页右上角那个双入口胶囊只能"事后切"、不能"事先测"的原因:
|
|
428
|
+
|
|
429
|
+
- 胶囊上显示当前走的是哪条路(远端 / 局域网),点一下在 **自动 → 固定局域网 →
|
|
430
|
+
固定远端** 之间循环,选择记在 `localStorage`。
|
|
431
|
+
- **自动**只用两个信号:这条路刚才通不通、你上次选了哪条。远端路上
|
|
432
|
+
`GET /taskwatch/upstream-health` 体检失败(隧道或 DSH 挂了)就切局域网(若可用);
|
|
433
|
+
局域网路上请求失败(手机离开了 Wi-Fi)就切回远端。能用的那条路不会被主动换掉。
|
|
434
|
+
|
|
435
|
+
### 换路为什么不重输配对码
|
|
436
|
+
|
|
437
|
+
换路等于换了一个**源**(`https://…` ↔ `http://192.168.x.x:3099`),而浏览器的 cookie 是
|
|
438
|
+
**按源隔离**的 —— 在远端那条路上换到的 cookie,到了局域网这条路等于不存在。
|
|
439
|
+
|
|
440
|
+
所以页面点切换时,先向当前这条路的中继要一张凭据:
|
|
441
|
+
|
|
442
|
+
1. `POST /taskwatch/ticket`(带令牌)→ 中继返回另一条路的 URL,末尾带一次性 `?t=…`;
|
|
443
|
+
2. 手机跳过去,中继兑换这张票:**60 秒有效、兑换即作废、重放无效**,同时给那条路种下
|
|
444
|
+
cookie;
|
|
445
|
+
3. 于是不用重新输那 6 位配对码。
|
|
446
|
+
|
|
447
|
+
这三个接口都在中继侧,**中继不在本仓库**:`GET /taskwatch/entries`(入口清单与当前入口)、
|
|
448
|
+
`POST /taskwatch/ticket`(换路凭据)、`GET /taskwatch/upstream-health`(上游体检)。
|
|
449
|
+
详细的实现与部署文档在中继仓库 **`dsh-in-hand-relay`**(该仓库尚未创建,此处只写仓库名)。
|
|
450
|
+
中继没升级时,页面上的胶囊不会出现,其余功能不受影响。
|
|
451
|
+
|
|
452
|
+
> 注:上面「在外网 / 手机上访问」一节描述的是只转发读路径的只读中继;实际的中继同时
|
|
453
|
+
> 承载 `/taskwatch/chat/*` 的写入口(见上面的路由表)和本节这三个双入口接口。中继代码
|
|
454
|
+
> 不在本仓库。
|
|
455
|
+
|
|
456
|
+
### 502 排查
|
|
457
|
+
|
|
458
|
+
看到 502 先分清它从哪一段来,三种情况的处置完全不同:
|
|
459
|
+
|
|
460
|
+
| 现象 | 断在哪 | 说明 |
|
|
461
|
+
|---|---|---|
|
|
462
|
+
| 服务器 nginx 回的 502 | 隧道那一段 | `ssh -R` 断了,或者本机中继没在跑;重新拉起隧道 / 中继 |
|
|
463
|
+
| 中继回的 502,响应头带 `x-taskwatch-hop: relay` | 中继 → DSH 上游 | 中继自己发的:隧道是通的,但上游 DSH 不可达(回环端口没在监听,或插件没挂载) |
|
|
464
|
+
| 手机上**反复**看到 502 | 手机的 service worker | v1.6.0 之前的 SW 会把那次 502 存成外壳,之后离线导航一直吃它,而服务端早已恢复 —— v1.6.0 起只缓存 2xx,并把 `CACHE` 升到 v13 清掉已存的 502 |
|
|
465
|
+
|
|
333
466
|
## 安全边界
|
|
334
467
|
|
|
335
468
|
- **写入口等于一台终端**。一旦放行 `/taskwatch/chat/send`,能打开这个页面的人就能在
|
|
@@ -391,7 +524,7 @@ PWA 外壳由中继本地提供(而不是转发上游),这样它不随上
|
|
|
391
524
|
## 开发
|
|
392
525
|
|
|
393
526
|
```sh
|
|
394
|
-
|
|
527
|
+
npm run check # bundle、文件边界、上传路由与页面契约测试
|
|
395
528
|
node scripts/make-icons.mjs # 重新生成图标(自实现 PNG 编码,零依赖)
|
|
396
529
|
node scripts/make-demo.mjs # 生成 docs/demo.html(假数据,供截图与预览)
|
|
397
530
|
```
|
|
@@ -402,6 +535,10 @@ node scripts/make-demo.mjs # 生成 docs/demo.html(假数据,供截图
|
|
|
402
535
|
`name` / `apply` / `inject` 三个导出都在,并把 `cordis.patch.yml`、`exports`、
|
|
403
536
|
`dsh` 元数据一并核对。
|
|
404
537
|
|
|
538
|
+
`check-uploads.mjs` 会起一个本地 `node:http` 测试服务器,覆盖混合附件发送、会话隔离、
|
|
539
|
+
失败重试、数量 / 总大小限制、错误的请求方法与类型,以及宿主缺少上传服务时的错误提示;
|
|
540
|
+
测试只使用内存 fake 服务,不会连接真实模型。
|
|
541
|
+
|
|
405
542
|
为什么宿主也要钉住:Cordis 的 `inject` 决定框架等哪些服务就绪后才调用 `apply`。
|
|
406
543
|
`webServer` 的服务是在启动阶段之后才发布的,而 `apply` 在组合期就会被调用——
|
|
407
544
|
只写 `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 (!
|
|
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
|
|
1093
|
+
content,
|
|
894
1094
|
}
|
|
895
1095
|
if (asText(body.timeZone)) request.clientTimeZone = asText(body.timeZone)
|
|
896
|
-
const value = await chatOf().prompt(request,
|
|
897
|
-
|
|
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
|
-
//
|
|
972
|
-
//
|
|
973
|
-
//
|
|
974
|
-
//
|
|
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
|
-
|
|
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) })
|