dsh-mobile-plugin 0.5.16 → 0.5.18
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/lib/impl.js +6 -0
- package/lib/mobile/cli/acp.js +216 -0
- package/lib/mobile/cli/agents.js +217 -0
- package/lib/mobile/cli/blocks.js +228 -0
- package/lib/mobile/cli/config-prefs.js +172 -0
- package/lib/mobile/cli/icons.js +138 -0
- package/lib/mobile/cli/images.js +132 -0
- package/lib/mobile/cli/mobile.js +1520 -0
- package/lib/mobile/cli/model-prefs.js +77 -0
- package/lib/mobile/cli/model-visibility.js +92 -0
- package/lib/mobile/cli/registry.js +207 -0
- package/lib/mobile/cli/service.js +901 -0
- package/lib/mobile/cli/usage.js +133 -0
- package/lib/mobile/host-settings.js +23 -1
- package/lib/mobile/monitor.js +17 -6
- package/lib/mobile/narration.js +205 -0
- package/lib/mobile/phone/binding.js +247 -0
- package/lib/mobile/phone/index.js +63 -0
- package/lib/mobile/phone/routes.js +168 -0
- package/lib/mobile/phone/service.js +327 -0
- package/lib/mobile/phone/tools.js +471 -0
- package/lib/mobile/services.js +16 -0
- package/lib/mobile/session-send.js +1 -0
- package/lib/mobile/terminal.js +574 -0
- package/lib/routes/mobile-api.js +139 -3
- package/package.json +2 -1
|
@@ -0,0 +1,1520 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* `cli-*` 会话的移动端适配层:让一条 CLI 对话走宿主普通会话那一整套接口。
|
|
5
|
+
*
|
|
6
|
+
* CLI 会话不是宿主的会话 —— 它是一条 ACP 子进程,块表在 `cli/blocks.js` 里。但对手机来说
|
|
7
|
+
* 它必须长得跟普通会话一模一样:出现在侧栏列表里、点进去有历史、能发、能停、能选模型、
|
|
8
|
+
* 会弹审批卡。所以这一层只做两个方向的翻译,不新造任何接口:
|
|
9
|
+
*
|
|
10
|
+
* 1. **请求方向**:`sessionId` 以 `cli-` 开头的,路由层先看一眼再交给这里(见
|
|
11
|
+
* `lib/routes/mobile-api.js`)。`sessionId` 在查询串(GET)或请求体(POST)里,所以
|
|
12
|
+
* 分派只发生一次,不抄任何 handler。
|
|
13
|
+
* 2. **帧方向**:ACP 的块表 → 宿主那套 `session/event` 帧。App 认哪几种、缺哪个就转圈,
|
|
14
|
+
* 是按 `app/src/hooks/streamFrame.ts` 与 `app/src/utils/frameWatermark.ts` 逐条对出来的:
|
|
15
|
+
* `turn/start` / `turn/end` / `user/message` / `assistant/chunk` / `tool/result` /
|
|
16
|
+
* `approval/*` / `session/title` / `session/heartbeat`。
|
|
17
|
+
*
|
|
18
|
+
* 三条刻意的减法(都比着「App 到底靠什么落地」做的,不是省事):
|
|
19
|
+
*
|
|
20
|
+
* - **正文与工具的过程用草稿帧,落定的内容一律走历史。** 草稿(`assistant/chunk`)不需要
|
|
21
|
+
* seq、不会被序门丢掉;一轮结束先发 `turn/end`(reason=completed),再把这一轮每个块
|
|
22
|
+
* 以稳定 id 发一条 `assistant/message`。id 与 `/session/messages` 的行 id 是同一个
|
|
23
|
+
* (块的 id),所以重连 / resync 按 id 合并,不会出现两遍。`assistant/message` 放在
|
|
24
|
+
* `turn/end` **之后**:App 在「当前回合」收尾时会把这条消息当成终局消息再收一次尾,
|
|
25
|
+
* 先发 turn/end 就只是就地更新,不会提前把这一轮判成结束。
|
|
26
|
+
* - **审批卡照抄宿主那一种形状**(帧级 `rpcId` + payload `approvalId`,见 `approvals.js`)。
|
|
27
|
+
* App 只认这种卡;新造一种等于卡片永远不出现。回答仍走 `/approval/respond`,在这一层
|
|
28
|
+
* 按 rpcId 找回 ACP 的挂起项。
|
|
29
|
+
* - **拿不到的字段给合理缺省,不报错。** 模型目录拿不到就回只有一项的清单;统计、队列、
|
|
30
|
+
* 搜索这类一律回空;这条会话在每种接口上都可用,比「主链路通了、点别的报错」重要。
|
|
31
|
+
*
|
|
32
|
+
* 会话本身仍归 `cli/service.js` 托管(进程、块表、审批挂起项都在那儿),这里只拿它的
|
|
33
|
+
* 只读门面。
|
|
34
|
+
*/
|
|
35
|
+
|
|
36
|
+
const fs = require('node:fs')
|
|
37
|
+
const fsp = require('node:fs/promises')
|
|
38
|
+
const { Readable } = require('node:stream')
|
|
39
|
+
const { readJsonBody, sendJson } = require('../../shared/http-util')
|
|
40
|
+
const { chunksHeaders, pipeEncrypted } = require('../../shared/e2ee-chunks.js')
|
|
41
|
+
const { listRevision } = require('../../shared/list-revision.js')
|
|
42
|
+
const { listSignal } = require('../../shared/list-signal.js')
|
|
43
|
+
const { writeSse, waitForDrain, ssePadding } = require('../../shared/sse-write.js')
|
|
44
|
+
const streamRegistry = require('../stream-registry.js')
|
|
45
|
+
const { createFrameSealer } = require('../stream.js')
|
|
46
|
+
const { imageBlockOf, metaOf, openImage, saveImage, MAX_IMAGE_BYTES, MEDIA_EXT } = require('./images.js')
|
|
47
|
+
const { resolveFinishedUpload } = require('../uploads.js')
|
|
48
|
+
const { rememberModel } = require('./model-prefs.js')
|
|
49
|
+
const { statsOf } = require('./usage.js')
|
|
50
|
+
const { hiddenOf, setVisible } = require('./model-visibility.js')
|
|
51
|
+
const { lookupModelMeta } = require('../model-meta.js')
|
|
52
|
+
const { identifyVendor, vendorLogoOf } = require('../model-vendors.js')
|
|
53
|
+
|
|
54
|
+
/** 会话 id 的形状:`cli-*`。宿主自己的会话 id 是 uuid,不会撞。 */
|
|
55
|
+
const CLI_PREFIX = 'cli-'
|
|
56
|
+
/**
|
|
57
|
+
* POST 分派要先读一次请求体才能看见 sessionId,所以上限必须盖住 `/session/send` 那一档
|
|
58
|
+
* (图片 base64),不能拿默认的 344KB —— 否则带图的那一发会在分派这一步被截断。
|
|
59
|
+
*/
|
|
60
|
+
const BODY_MAX = 16 * 1024 * 1024
|
|
61
|
+
const HEARTBEAT_MS = 15_000
|
|
62
|
+
const MESSAGE_TEXT_MAX = 200
|
|
63
|
+
const SSE_PADDING_BYTES = 2048
|
|
64
|
+
|
|
65
|
+
function isCliSessionId(value) {
|
|
66
|
+
return typeof value === 'string' && value.startsWith(CLI_PREFIX)
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function textOf(value) {
|
|
70
|
+
return typeof value === 'string' ? value : ''
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function queryOf(req) {
|
|
74
|
+
return new URL(req.url || '/', 'http://x').searchParams
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** 会话状态访问器:每个服务进程一份,按会话对象挂状态(会话被收掉时它自然一起没)。 */
|
|
78
|
+
function createState() {
|
|
79
|
+
return {
|
|
80
|
+
/** 已经发出去的帧序号。App 的序门只认「比上次大」,所以每发一帧就抬一次。 */
|
|
81
|
+
seq: 0,
|
|
82
|
+
/** 轮号:每出现一个用户块 +1。 */
|
|
83
|
+
turn: 0,
|
|
84
|
+
/** 块 id → 轮号。 */
|
|
85
|
+
turnByBlock: new Map(),
|
|
86
|
+
/** 块 id → 块序号(`assistant/chunk` 的 index)。 */
|
|
87
|
+
blockIndex: new Map(),
|
|
88
|
+
/** 这一轮开着(`turn/start` 已发、`turn/end` 还没发)。 */
|
|
89
|
+
turnOpen: false,
|
|
90
|
+
/** 上一轮事实:侧栏的红点 / 黄点靠它,与宿主 `lastTurn` 同形。 */
|
|
91
|
+
lastTurn: null,
|
|
92
|
+
/**
|
|
93
|
+
* 这一份会话自己那本账:置顶。
|
|
94
|
+
*
|
|
95
|
+
* 宿主那本账里没有 CLI 会话(它本来就不是宿主会话),所以 `workspace/*-session` 这几条
|
|
96
|
+
* 动作在这一层自己记。**代价:账活在宿主进程内存里,DSH 重启就没了** —— 但那跟 CLI
|
|
97
|
+
* 会话本身(ACP 子进程、块表、审批挂起项)一起没,不是缺陷,别为它落盘。
|
|
98
|
+
*
|
|
99
|
+
* **没有归档这一格**:「归档」在这条链上等于关掉那条 CLI,会话对象直接没了,没有第二档
|
|
100
|
+
* 状态要记(见 `routeFlag`)。
|
|
101
|
+
*/
|
|
102
|
+
pinned: false,
|
|
103
|
+
/** 这台设备读过的那一轮(`/session/read` 记),`completed` 提醒按它判「新」。 */
|
|
104
|
+
readAt: 0,
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* 把块表扫一遍,补齐轮号 / 块序号 / 消息 seq。
|
|
110
|
+
*
|
|
111
|
+
* 消息 seq 在这里按**块表顺序**一次性分配(包括还在流式的那一段):历史与实时帧必须拿到
|
|
112
|
+
* 同一个值,否则同一条消息在两边算不出同一个落点,重连之后整段会挪位。后进来的块追加在
|
|
113
|
+
* 后面,已分配的值不动。
|
|
114
|
+
*/
|
|
115
|
+
function syncState(session, state) {
|
|
116
|
+
const blocks = session.blocks.list()
|
|
117
|
+
let turn = state.turn
|
|
118
|
+
blocks.forEach((block, index) => {
|
|
119
|
+
if (!state.blockIndex.has(block.id)) state.blockIndex.set(block.id, index)
|
|
120
|
+
if (block.kind === 'user' && !state.turnByBlock.has(block.id)) turn += 1
|
|
121
|
+
if (!state.turnByBlock.has(block.id)) state.turnByBlock.set(block.id, turn)
|
|
122
|
+
})
|
|
123
|
+
state.turn = turn
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
function nextSeq(state) {
|
|
127
|
+
state.seq += 1
|
|
128
|
+
return state.seq
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/** 一条块 → App 认的内容块(与 `history-derive.js` 给历史用的那一组同形)。 */
|
|
132
|
+
function toolBlockOf(block) {
|
|
133
|
+
return {
|
|
134
|
+
type: 'tool-call',
|
|
135
|
+
callId: block.callId,
|
|
136
|
+
name: block.name || undefined,
|
|
137
|
+
argsRaw: block.argsRaw || undefined,
|
|
138
|
+
state: block.state === 'ok' ? 'ok' : block.state === 'error' ? 'error' : 'running',
|
|
139
|
+
...(typeof block.output === 'string' && block.output ? { output: block.output } : {}),
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/** 一条块 → 历史消息(setttled 的块才出,正在流的那一段交给实时帧)。 */
|
|
144
|
+
function messageOf(block, state, force = false) {
|
|
145
|
+
const turn = state.turnByBlock.get(block.id) || 0
|
|
146
|
+
const base = {
|
|
147
|
+
id: block.id,
|
|
148
|
+
role: 'assistant',
|
|
149
|
+
text: '',
|
|
150
|
+
blocks: [],
|
|
151
|
+
// 历史里的落点用**块表顺序号**:块只增不改序,所以同一个块每次算出来的落点都一样,
|
|
152
|
+
// App 的 `bySeq` 才排得稳。实时帧另用单调的事件序号(见 onBlock)。
|
|
153
|
+
seq: (state.blockIndex.get(block.id) || 0) + 1,
|
|
154
|
+
time: block.at,
|
|
155
|
+
...(turn > 0 ? { turn } : {}),
|
|
156
|
+
}
|
|
157
|
+
if (block.kind === 'text') {
|
|
158
|
+
if ((block.streaming && !force) || !block.text) return null
|
|
159
|
+
return { ...base, text: block.text, blocks: [{ type: 'text', text: block.text }] }
|
|
160
|
+
}
|
|
161
|
+
if (block.kind === 'reasoning') {
|
|
162
|
+
if ((block.streaming && !force) || !block.text) return null
|
|
163
|
+
return { ...base, blocks: [{ type: 'reasoning', text: block.text }] }
|
|
164
|
+
}
|
|
165
|
+
if (block.kind === 'tool') return { ...base, blocks: [toolBlockOf(block)] }
|
|
166
|
+
if (block.kind === 'notice') {
|
|
167
|
+
const text = textOf(block.text)
|
|
168
|
+
if (!text) return null
|
|
169
|
+
return { ...base, text, blocks: [{ type: 'text', text }] }
|
|
170
|
+
}
|
|
171
|
+
// 计划整表替换在对话页没有对应呈现;它仍留在 CLI 自己的详情里(`cli/detail`)。
|
|
172
|
+
return null
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/**
|
|
176
|
+
* 用户消息:正文 + 这一发带的图片。
|
|
177
|
+
*
|
|
178
|
+
* 图片两种形状都要给:`images`(历史项直接用,气泡读它)与 `blocks` 里的 image 块(实时帧
|
|
179
|
+
* 走 App 的 `messageFromEvent`,它按内容块提图)。只给一种的话,同一张图会「重进会话看得见、
|
|
180
|
+
* 刚发出去那一下看不见」,或者反过来。
|
|
181
|
+
*/
|
|
182
|
+
function userMessageOf(block, state) {
|
|
183
|
+
const turn = state.turnByBlock.get(block.id) || 0
|
|
184
|
+
const text = textOf(block.text)
|
|
185
|
+
const imageBlocks = (Array.isArray(block.images) ? block.images : [])
|
|
186
|
+
.map((meta) => imageBlockOf(meta))
|
|
187
|
+
.filter(Boolean)
|
|
188
|
+
const images = imageBlocks.map((image) => ({
|
|
189
|
+
mediaType: image.mediaType,
|
|
190
|
+
attachmentId: image.attachment.attachmentId,
|
|
191
|
+
...(image.name ? { name: image.name } : {}),
|
|
192
|
+
...(image.width && image.height ? { width: image.width, height: image.height } : {}),
|
|
193
|
+
}))
|
|
194
|
+
return {
|
|
195
|
+
id: block.id,
|
|
196
|
+
role: 'user',
|
|
197
|
+
text,
|
|
198
|
+
blocks: [...imageBlocks, ...(text ? [{ type: 'text', text }] : [])],
|
|
199
|
+
...(images.length ? { images } : {}),
|
|
200
|
+
seq: (state.blockIndex.get(block.id) || 0) + 1,
|
|
201
|
+
time: block.at,
|
|
202
|
+
...(turn > 0 ? { turn } : {}),
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
/**
|
|
207
|
+
* 计划 → `stats` 的 `todos` 投影(TodoDock 的数据源)。
|
|
208
|
+
*
|
|
209
|
+
* ACP 的 `plan` 事件是**整表替换**,块表里因此永远只有一条 `kind === 'plan'` 的块,它的
|
|
210
|
+
* `steps` 就是当前这份计划。没有计划事件就回空对象 —— 不造一份空计划,那会让计划条以
|
|
211
|
+
* 「0 / 0」的形式挂出来。
|
|
212
|
+
*/
|
|
213
|
+
function planProjection(session) {
|
|
214
|
+
const plan = (session.blocks.list() || []).find((block) => block && block.kind === 'plan')
|
|
215
|
+
const steps = plan && Array.isArray(plan.steps) ? plan.steps : []
|
|
216
|
+
if (steps.length === 0) return {}
|
|
217
|
+
return {
|
|
218
|
+
todos: steps.map((step) => ({
|
|
219
|
+
content: textOf(step.text),
|
|
220
|
+
status: step.status === 'completed' ? 'completed' : step.status === 'in_progress' ? 'in_progress' : 'pending',
|
|
221
|
+
})).filter((item) => item.content),
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
/** 侧栏那一行要的「最后一条可见内容」。 */
|
|
226
|
+
function lastMessageOf(items) {
|
|
227
|
+
for (let i = items.length - 1; i >= 0; i -= 1) {
|
|
228
|
+
const item = items[i]
|
|
229
|
+
const text = textOf(item.text) || (item.blocks || []).map(blockText).filter(Boolean).join(' ')
|
|
230
|
+
if (!text) continue
|
|
231
|
+
return { role: item.role, text: text.slice(0, MESSAGE_TEXT_MAX), at: item.time || 0 }
|
|
232
|
+
}
|
|
233
|
+
return null
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
function blockText(block) {
|
|
237
|
+
if (!block || typeof block !== 'object') return ''
|
|
238
|
+
if (block.type === 'text' || block.type === 'reasoning') return textOf(block.text)
|
|
239
|
+
if (block.type === 'tool-call') return textOf(block.name) || textOf(block.title)
|
|
240
|
+
return ''
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
/**
|
|
244
|
+
* 模型目录:ACP 的 `configOptions` 里 `category === 'model'` 的那一项。
|
|
245
|
+
*
|
|
246
|
+
* 两个退化档必须分开,这一条是被真机坑出来的(2026-11-04 用户报「模型一直出不来」):
|
|
247
|
+
*
|
|
248
|
+
* - **进程还在 `starting`**:适配器没握手完,`configOptions` 必然是空数组,此刻**不知道**
|
|
249
|
+
* 有哪些模型。所以回 `routable:false` + 空目录,一枚占位都不给。给一份 `default` 会被
|
|
250
|
+
* App 当成真目录写进缓存,那个会话之后 20 秒都只会显示 `default` —— 而适配器其实一秒
|
|
251
|
+
* 就起来了(本机实测 opencode 约 0.9s,codex / claude 首次还要 npx 下载)。
|
|
252
|
+
* - **握手已经成功、这家 CLI 确实没声明模型项**:给一枚当前 CLI 名的占位。这说的是真话,
|
|
253
|
+
* 芯片打开时不该是一片红字。
|
|
254
|
+
*/
|
|
255
|
+
function modelsBodyOf(session) {
|
|
256
|
+
const options = Array.isArray(session.configOptions) ? session.configOptions : []
|
|
257
|
+
const modelOption = options.find((option) => option && option.category === 'model')
|
|
258
|
+
|| options.find((option) => option && typeof option.id === 'string' && /model/i.test(option.id))
|
|
259
|
+
const listed = modelOption && Array.isArray(modelOption.options) ? modelOption.options : []
|
|
260
|
+
const hidden = hiddenOf(session.agentId)
|
|
261
|
+
const hiddenModels = new Set(hidden.models)
|
|
262
|
+
const hiddenGroups = new Set(hidden.configs)
|
|
263
|
+
const vendors = {}
|
|
264
|
+
const models = listed
|
|
265
|
+
.filter((item) => item && item.value !== undefined)
|
|
266
|
+
.map((item) => {
|
|
267
|
+
const id = String(item.value)
|
|
268
|
+
const shown = modelLabelOf(id, textOf(item.name))
|
|
269
|
+
// 能不能读图:ACP 的选项里没有这一格,只有名字。公开参数表(`model-meta`)按名字查得到,
|
|
270
|
+
// 查不到就**不给这一格**(三态,别写成 false —— 那会让 App 拒掉一张其实是能看的图)。
|
|
271
|
+
const vision = visionOfModel(shown.name || id, id)
|
|
272
|
+
// 厂商:与宿主那套模型目录共用同一个识别器(`model-vendors.js`),所以这一页的徽标
|
|
273
|
+
// 与首页那台设备的模型列表长得一样。认不出就不给,App 退回首字母。
|
|
274
|
+
const vendor = identifyVendor({ modelName: shown.name, model: id, providerName: session.label, provider: session.agentId })
|
|
275
|
+
if (vendor) {
|
|
276
|
+
const logo = vendorLogoOf(vendor)
|
|
277
|
+
if (logo) vendors[vendor] = logo
|
|
278
|
+
}
|
|
279
|
+
// 这一条属于哪一组(模型 id 里斜杠前那一段),以及用户有没有把它关掉。
|
|
280
|
+
// `hidden` 只交给 App 的展示层过滤:管理页要能看见全部,选择器只画没关的。
|
|
281
|
+
const group = id.includes('/') ? id.slice(0, id.indexOf('/')) : ''
|
|
282
|
+
return {
|
|
283
|
+
id,
|
|
284
|
+
name: shown.name || id,
|
|
285
|
+
...(shown.tag ? { tag: shown.tag } : {}),
|
|
286
|
+
...(group ? { group } : {}),
|
|
287
|
+
...(vendor ? { vendor } : {}),
|
|
288
|
+
...(textOf(item.description) ? { description: textOf(item.description) } : {}),
|
|
289
|
+
...(vision === undefined ? {} : { vision }),
|
|
290
|
+
...(hiddenModels.has(id) || (group && hiddenGroups.has(group)) ? { hidden: true } : {}),
|
|
291
|
+
}
|
|
292
|
+
})
|
|
293
|
+
// 还没起来:老实说「现在还不知道」,别给一份会被当成真目录的占位(见上面那段)。
|
|
294
|
+
if (session.status === 'starting') {
|
|
295
|
+
return {
|
|
296
|
+
ok: true,
|
|
297
|
+
current: { provider: session.agentId, model: '' },
|
|
298
|
+
routable: false,
|
|
299
|
+
groups: [],
|
|
300
|
+
pending: true,
|
|
301
|
+
failures: [],
|
|
302
|
+
}
|
|
303
|
+
}
|
|
304
|
+
if (models.length === 0) models.push({ id: 'default', name: session.label || 'CLI' })
|
|
305
|
+
const current = modelOption && modelOption.currentValue !== undefined
|
|
306
|
+
? String(modelOption.currentValue)
|
|
307
|
+
: models[0].id
|
|
308
|
+
const chosen = models.find((item) => item.id === current)
|
|
309
|
+
// 档位与其它会话级下拉:ACP 把它们与模型并列给(`thought_level` / `mode` / `model_config`),
|
|
310
|
+
// App 那边认的是「挂在模型上的档位」+「一排附加选项行」,所以在这里换形状。
|
|
311
|
+
//
|
|
312
|
+
// 档位摊到**每一个**模型上:ACP 的档位是会话级的,换模型不会重置它,摊上去就与真实现状一致。
|
|
313
|
+
// 其它项(模式 / 协作模式 / 快速档)交给 `options` 那排 —— App 早已会画、也会写回。
|
|
314
|
+
const thought = thoughtOptionOf(options)
|
|
315
|
+
const reasoning = reasoningOf(thought)
|
|
316
|
+
if (reasoning) for (const model of models) model.reasoning = reasoning
|
|
317
|
+
const rows = []
|
|
318
|
+
for (const option of options) {
|
|
319
|
+
if (!option || option === modelOption) continue
|
|
320
|
+
if (reasoning && option === thought) continue
|
|
321
|
+
if (option.category === 'model') continue
|
|
322
|
+
const row = optionRowOf(option)
|
|
323
|
+
if (row) rows.push(row)
|
|
324
|
+
}
|
|
325
|
+
return {
|
|
326
|
+
ok: true,
|
|
327
|
+
current: {
|
|
328
|
+
provider: session.agentId,
|
|
329
|
+
model: current,
|
|
330
|
+
...(reasoning && thought && thought.currentValue !== undefined ? { reasoningEffort: String(thought.currentValue) } : {}),
|
|
331
|
+
},
|
|
332
|
+
routable: true,
|
|
333
|
+
groups: [{ id: session.agentId, name: session.label || 'CLI', models }],
|
|
334
|
+
...(rows.length ? { options: rows } : {}),
|
|
335
|
+
...(Object.keys(vendors).length ? { vendors } : {}),
|
|
336
|
+
// 当前这个模型认不认图:App 发图前靠它拦一句人话,而不是把图发过去让模型自己说「我看不到」。
|
|
337
|
+
...(chosen && typeof chosen.vision === 'boolean' ? { currentVision: chosen.vision } : {}),
|
|
338
|
+
failures: [],
|
|
339
|
+
}
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
/**
|
|
343
|
+
* 模型名去掉那层「渠道前缀」,剩下的才是模型自己。
|
|
344
|
+
*
|
|
345
|
+
* opencode 给的名字是 `OpenCode Go/DeepSeek V4.1 Flash`、`OpenCode/Big Pickle` —— 斜杠前
|
|
346
|
+
* 是它自己的渠道(带 `Go` 的是订阅那一档),后面才是模型。原样铺在列表里,每一行都以
|
|
347
|
+
* 同一个词开头,厂商徽标也就认不出来了(全都命中 `opencode` 那个词);把前缀去掉之后
|
|
348
|
+
* 名字直接就是模型名,识别照旧走 `model-vendors.js`(2026-11-04 用户要求)。
|
|
349
|
+
*
|
|
350
|
+
* 前缀不丢,转成后面那枚小标签(`tag`):**「Go 订阅」这一档还要能分出来**,只是不该占着
|
|
351
|
+
* 名字的位置。判定只看斜杠前那一段,取它最后一块(`OpenCode Go` → `Go`);与 CLI 自己的
|
|
352
|
+
* 名字完全相同的那一段(`OpenCode/`)是「默认渠道」,不挂标签 —— 那种前缀什么也没多说。
|
|
353
|
+
*
|
|
354
|
+
* 别家 CLI 的名字里带斜杠也会走这里:名字里本来就有斜杠、却不像 `渠道/模型` 的(两边有空格、
|
|
355
|
+
* 或者前缀里有多个词且不认识)时原样留着 —— 猜错前缀比留着前缀难看得多。
|
|
356
|
+
*/
|
|
357
|
+
function modelLabelOf(id, name) {
|
|
358
|
+
const raw = textOf(name) || id
|
|
359
|
+
const at = raw.indexOf('/')
|
|
360
|
+
if (at <= 0) return { name: raw, tag: '' }
|
|
361
|
+
const prefix = raw.slice(0, at).trim()
|
|
362
|
+
const rest = raw.slice(at + 1).trim()
|
|
363
|
+
if (!prefix || !rest) return { name: raw, tag: '' }
|
|
364
|
+
// 前缀的**第一段**是 CLI 自己的名字时才算渠道前缀(`OpenCode Go` / `OpenCode`);
|
|
365
|
+
// 其余一切(`Some Vendor/Model`、`A/B`)原样留着 —— 猜错前缀比留着前缀难看得多。
|
|
366
|
+
const words = prefix.split(/\s+/).filter(Boolean)
|
|
367
|
+
const head = words[0] || ''
|
|
368
|
+
const agentish = /^(opencode|codex|claude|gemini|cline|copilot|qwen|cursor|amp|kiro)$/i
|
|
369
|
+
if (!agentish.test(head)) return { name: raw, tag: '' }
|
|
370
|
+
// 剩下的那几段才是要留成标签的东西(`Go` = 订阅那一档);没有就什么都不挂。
|
|
371
|
+
return { name: rest, tag: words.slice(1).join(' ') }
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
/**
|
|
375
|
+
* 名字 → 能不能读图。三态:`true` / `false` / `undefined`(公开库里没有这条)。
|
|
376
|
+
*
|
|
377
|
+
* **查到但没有视觉标记 = 不能读图**(`false`),不是「不知道」。公开参数表里 `i:1` 才是
|
|
378
|
+
* 「能读图」,一行查到了却没这个标记,就是说得清清楚楚的「它不认图」—— 2026-11-04 现场
|
|
379
|
+
* 就是这一格弄反了:默认的 `Big Pickle` 在表里查得到、没有视觉标记,被当成「不知道」放行,
|
|
380
|
+
* 于是图发过去、模型回一句「我看不到」,用户看到的是「发了但没反应」。
|
|
381
|
+
*
|
|
382
|
+
* 只有**整条查不到**才是 `undefined`:那种情况放行,别替一个陌生模型拒掉它也许能看的图。
|
|
383
|
+
*/
|
|
384
|
+
function visionOfModel(name, id) {
|
|
385
|
+
const meta = lookupModelMeta(name) || lookupModelMeta(id)
|
|
386
|
+
if (!meta) return undefined
|
|
387
|
+
return meta.vision === true
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
/**
|
|
391
|
+
* 当前选中的模型能不能读图。三态,判据同 `visionOfModel`。
|
|
392
|
+
*
|
|
393
|
+
* 只用来在**发图之前**拦一句人话。`undefined`(公开库里没这条)放行 —— 替模型拒一张它其实
|
|
394
|
+
* 能看的图,比让它自己说一句「我看不到」更糟(后者用户至少能换个模型立刻重发)。
|
|
395
|
+
*/
|
|
396
|
+
function currentVisionOf(session) {
|
|
397
|
+
const options = Array.isArray(session.configOptions) ? session.configOptions : []
|
|
398
|
+
const modelOption = options.find((option) => option && option.category === 'model')
|
|
399
|
+
|| options.find((option) => option && typeof option.id === 'string' && /model/i.test(option.id))
|
|
400
|
+
const listed = modelOption && Array.isArray(modelOption.options) ? modelOption.options : []
|
|
401
|
+
const current = modelOption && modelOption.currentValue !== undefined
|
|
402
|
+
? String(modelOption.currentValue)
|
|
403
|
+
: (listed[0] && listed[0].value !== undefined ? String(listed[0].value) : '')
|
|
404
|
+
const entry = listed.find((item) => item && item.value !== undefined && String(item.value) === current)
|
|
405
|
+
if (!entry) return undefined
|
|
406
|
+
// 与 `modelsBodyOf` 同一条口径:查表用的是去掉渠道前缀之后的模型名。
|
|
407
|
+
return visionOfModel(modelLabelOf(current, textOf(entry.name)).name || current, current)
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
/**
|
|
411
|
+
* ACP 的 `thought_level` 那一项 → App 认的 `reasoning`。
|
|
412
|
+
*
|
|
413
|
+
* ACP 把「思考档」定成会话级的**一个下拉**(codex 的 `reasoning_effort`: low / medium / high /
|
|
414
|
+
* xhigh / max),而宿主那套目录把它挂在**模型**上(`model.reasoning.efforts`)。App 的档位滑轨
|
|
415
|
+
* 只认后者,所以这一层把它摊到每个模型上:ACP 那边本来就是「这条会话用哪一档」,与选哪个模型
|
|
416
|
+
* 无关,摊上去不失真。
|
|
417
|
+
*
|
|
418
|
+
* 没有这一项就返回 undefined —— 那正是「这家 CLI 不提供档位」,App 不画滑轨。
|
|
419
|
+
*/
|
|
420
|
+
function reasoningOf(option) {
|
|
421
|
+
if (!option || !Array.isArray(option.options)) return undefined
|
|
422
|
+
const efforts = option.options
|
|
423
|
+
.filter((item) => item && item.value !== undefined)
|
|
424
|
+
.map((item) => ({
|
|
425
|
+
id: String(item.value),
|
|
426
|
+
name: textOf(item.name) || String(item.value),
|
|
427
|
+
...(textOf(item.description) ? { description: textOf(item.description) } : {}),
|
|
428
|
+
}))
|
|
429
|
+
if (efforts.length === 0) return undefined
|
|
430
|
+
return { efforts }
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
/** 会话级配置项里「档位」那一项的 id(codex 叫 `reasoning_effort`,别家可能是别的名字)。 */
|
|
434
|
+
function thoughtOptionOf(options) {
|
|
435
|
+
return options.find((option) => option && option.category === 'thought_level')
|
|
436
|
+
|| options.find((option) => option && typeof option.id === 'string' && /thought|reasoning|effort/i.test(`${option.id} ${textOf(option.name)}`))
|
|
437
|
+
|| null
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
/**
|
|
441
|
+
* 一条 ACP 配置项 → App 的「附加选项行」。
|
|
442
|
+
*
|
|
443
|
+
* 除了模型与档位,ACP 还可能给模式(`mode`:codex 的 Read-only / Full access)、协作模式
|
|
444
|
+
* (`collaboration_mode`:Default / Plan)、模型附加项(`model_config`:codex 的 Fast mode)。
|
|
445
|
+
* 这些在宿主那套里就是「速度 / 输出详略」那一排下拉(`ModelOptionRow`),App 早就会画、也会
|
|
446
|
+
* 写回同一套接口 —— 所以这里照那个形状交出去,一行 UI 都不用新写。
|
|
447
|
+
*/
|
|
448
|
+
function optionRowOf(option) {
|
|
449
|
+
if (!option || typeof option.id !== 'string' || !Array.isArray(option.options)) return null
|
|
450
|
+
const choices = option.options
|
|
451
|
+
.filter((item) => item && item.value !== undefined)
|
|
452
|
+
.map((item) => ({
|
|
453
|
+
id: String(item.value),
|
|
454
|
+
name: textOf(item.name) || String(item.value),
|
|
455
|
+
...(textOf(item.description) ? { description: textOf(item.description) } : {}),
|
|
456
|
+
}))
|
|
457
|
+
if (choices.length === 0) return null
|
|
458
|
+
const value = option.currentValue === undefined ? '' : String(option.currentValue)
|
|
459
|
+
return { id: option.id, title: textOf(option.name) || option.id, value, choices }
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
/**
|
|
463
|
+
* @param {object} deps 由 `cli/service.js` 交进来的受控门面(见那边的返回值)
|
|
464
|
+
*/
|
|
465
|
+
function createCliMobile(deps) {
|
|
466
|
+
const { sessions, runPrompt, answerPermission, cancelTurn, setTitle, watch, forget } = deps
|
|
467
|
+
/**
|
|
468
|
+
* 告诉手机「列表变了」。
|
|
469
|
+
*
|
|
470
|
+
* CLI 会话不在宿主那本账里,镜像推送调度器每 500ms 比对的也是**宿主**那份清单,所以它
|
|
471
|
+
* 变了之后没人吭声 —— 手机只能等兜底轮询(推送活着时是 3 分钟)。真机现象就是「对话页
|
|
472
|
+
* 标题都出来了,侧栏那一行还写着 CLI 的名字」(2026-11-04 用户报)。
|
|
473
|
+
*
|
|
474
|
+
* 只报「变了」不报内容:清单仍然由 App 自己来拉,与宿主那条信号同一个口径。指纹按行上
|
|
475
|
+
* 会变的那几项算,用同一个值重复报会被 `bump` 幂等掉。
|
|
476
|
+
*/
|
|
477
|
+
function bumpList() {
|
|
478
|
+
const rows = []
|
|
479
|
+
for (const session of sessions.values()) {
|
|
480
|
+
const state = stateOf(session)
|
|
481
|
+
rows.push([session.id, session.title, session.status, state.pinned ? 1 : 0, state.lastTurn ? state.lastTurn.turn : 0])
|
|
482
|
+
}
|
|
483
|
+
rows.sort((a, b) => (a[0] < b[0] ? -1 : a[0] > b[0] ? 1 : 0))
|
|
484
|
+
listSignal.bump(listRevision(rows))
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
/** 会话对象 → 适配状态。 */
|
|
488
|
+
const states = new WeakMap()
|
|
489
|
+
/** rpcId → { session, requestId }:`/approval/respond` 靠它找回 ACP 的挂起项。 */
|
|
490
|
+
const approvals = new Map()
|
|
491
|
+
|
|
492
|
+
function stateOf(session) {
|
|
493
|
+
let state = states.get(session)
|
|
494
|
+
if (!state) {
|
|
495
|
+
state = createState()
|
|
496
|
+
states.set(session, state)
|
|
497
|
+
observe(session, state)
|
|
498
|
+
}
|
|
499
|
+
return state
|
|
500
|
+
}
|
|
501
|
+
|
|
502
|
+
/**
|
|
503
|
+
* 会话级观察者:**不开流也要记的账**。
|
|
504
|
+
*
|
|
505
|
+
* 上一轮什么时候结束、成没成,是 `/monitor`(安卓后台提醒)与侧栏红点的唯一依据,
|
|
506
|
+
* 而手机退到后台时根本没有 SSE 连接。所以这一份挂在会话自己身上:收口帧一到就落账,
|
|
507
|
+
* 与谁在看无关。每帧该发什么仍然由各条连接自己的 `conn` 决定。
|
|
508
|
+
*/
|
|
509
|
+
function observe(session, state) {
|
|
510
|
+
watch(session, (frame) => {
|
|
511
|
+
if (frame.t === 'status') {
|
|
512
|
+
if (frame.status === 'running') state.turnOpen = true
|
|
513
|
+
else if (frame.status === 'idle' || frame.status === 'error') closeTurn(session, state, frame.status)
|
|
514
|
+
bumpList()
|
|
515
|
+
} else if (frame.t === 'exit') {
|
|
516
|
+
closeTurn(session, state, 'error')
|
|
517
|
+
bumpList()
|
|
518
|
+
} else if (frame.t === 'title') {
|
|
519
|
+
bumpList()
|
|
520
|
+
}
|
|
521
|
+
})
|
|
522
|
+
}
|
|
523
|
+
|
|
524
|
+
/** 这一轮收口(记账,幂等):轮号取「这一轮实际是第几轮」,块表可能有攒批没同步的。 */
|
|
525
|
+
function closeTurn(session, state, status) {
|
|
526
|
+
if (!state.turnOpen) return
|
|
527
|
+
state.turnOpen = false
|
|
528
|
+
syncState(session, state)
|
|
529
|
+
const turn = Math.max(1, state.turn)
|
|
530
|
+
const failed = status === 'error'
|
|
531
|
+
state.lastTurn = {
|
|
532
|
+
turn,
|
|
533
|
+
endedAt: Date.now(),
|
|
534
|
+
status: failed ? 'failed' : 'completed',
|
|
535
|
+
...(failed ? { message: 'CLI 报错了' } : {}),
|
|
536
|
+
}
|
|
537
|
+
}
|
|
538
|
+
|
|
539
|
+
function sessionOf(sessionId) {
|
|
540
|
+
return isCliSessionId(sessionId) ? sessions.get(sessionId) || null : null
|
|
541
|
+
}
|
|
542
|
+
|
|
543
|
+
// ── 摘要(列表 / 详情共用一份投影) ────────────────────────────────────────
|
|
544
|
+
|
|
545
|
+
function summaryOf(session) {
|
|
546
|
+
const state = stateOf(session)
|
|
547
|
+
syncState(session, state)
|
|
548
|
+
const items = itemsOf(session, state)
|
|
549
|
+
const blocks = session.blocks.list()
|
|
550
|
+
return {
|
|
551
|
+
sessionId: session.id,
|
|
552
|
+
title: session.title,
|
|
553
|
+
running: session.status === 'running',
|
|
554
|
+
...(session.permissionFrames.size > 0 ? { pendingInteraction: 'approval' } : {}),
|
|
555
|
+
blank: !blocks.some((block) => block.kind === 'user'),
|
|
556
|
+
cwd: session.cwd,
|
|
557
|
+
// 侧栏按 workspaceId 分组:给不准就会把 CLI 会话扔进「未分组」。
|
|
558
|
+
workspaceId: session.workspaceId,
|
|
559
|
+
// 对话页「⋯ → 终端」靠这两格:哪个 CLI、在哪个目录。
|
|
560
|
+
cliAgent: session.agentId,
|
|
561
|
+
updatedAt: session.updatedAt || 0,
|
|
562
|
+
// 置顶与宿主同一个字段:App 的行尾图钉读它(排序见 mergeList)。
|
|
563
|
+
...(state.pinned ? { pinned: true } : {}),
|
|
564
|
+
lastMessage: lastMessageOf(items),
|
|
565
|
+
...(state.lastTurn ? { lastTurn: state.lastTurn } : {}),
|
|
566
|
+
...(session.status === 'error' ? { lastTurnError: { turn: state.lastTurn ? state.lastTurn.turn : 0, message: 'CLI 退出了' } } : {}),
|
|
567
|
+
}
|
|
568
|
+
}
|
|
569
|
+
|
|
570
|
+
function itemsOf(session, state = stateOf(session)) {
|
|
571
|
+
syncState(session, state)
|
|
572
|
+
const out = []
|
|
573
|
+
for (const block of session.blocks.list()) {
|
|
574
|
+
if (block.kind === 'user') {
|
|
575
|
+
out.push(userMessageOf(block, state))
|
|
576
|
+
continue
|
|
577
|
+
}
|
|
578
|
+
const message = messageOf(block, state)
|
|
579
|
+
if (message) out.push(message)
|
|
580
|
+
}
|
|
581
|
+
return out
|
|
582
|
+
}
|
|
583
|
+
|
|
584
|
+
// ── 帧:块表 / 状态 / 审批 → App 认的会话事件 ──────────────────────────────
|
|
585
|
+
|
|
586
|
+
/**
|
|
587
|
+
* 一条块变化 → 实时帧。
|
|
588
|
+
*
|
|
589
|
+
* 只有用户消息是「消息事件」;正文与工具走草稿(`assistant/chunk`),落定的版本在
|
|
590
|
+
* `turn/end` 之后按稳定 id 补发(见 `settleTurn`)。
|
|
591
|
+
*/
|
|
592
|
+
function onBlock(session, conn, block) {
|
|
593
|
+
const state = stateOf(session)
|
|
594
|
+
syncState(session, state)
|
|
595
|
+
const turn = state.turnByBlock.get(block.id) || 0
|
|
596
|
+
const index = state.blockIndex.get(block.id) || 0
|
|
597
|
+
|
|
598
|
+
if (block.kind === 'user') {
|
|
599
|
+
if (conn.emitted.has(block.id)) return
|
|
600
|
+
conn.emitted.add(block.id)
|
|
601
|
+
state.turnOpen = true
|
|
602
|
+
conn.turnOpen = true
|
|
603
|
+
conn.write({ type: 'turn/start', sessionId: session.id, seq: nextSeq(state), data: { turn } })
|
|
604
|
+
conn.write({ type: 'user/message', sessionId: session.id, seq: nextSeq(state), data: userMessageOf(block, state) })
|
|
605
|
+
return
|
|
606
|
+
}
|
|
607
|
+
|
|
608
|
+
if (block.kind === 'text' || block.kind === 'reasoning') {
|
|
609
|
+
const full = textOf(block.text)
|
|
610
|
+
const sent = conn.sent.get(block.id) || 0
|
|
611
|
+
if (full.length <= sent) return
|
|
612
|
+
conn.sent.set(block.id, full.length)
|
|
613
|
+
/**
|
|
614
|
+
* 这一轮**已经收口**之后到的增量,只补消息、不再发草稿。
|
|
615
|
+
*
|
|
616
|
+
* 现场(2026-11-04 用户报「AI 已经回复了,这边还在深度求索中」):状态帧是立即广播的、
|
|
617
|
+
* 块帧走 60ms 攒批,于是 `status: idle` 会先到、`turn/end` 当场发出去;紧接着攒批里
|
|
618
|
+
* 最后那一段正文的块帧才到。它先被当成 `assistant/chunk` 发出去 —— App 收到带回合号的
|
|
619
|
+
* 增量会把这一轮重新认成「在跑」(`adoptTurn`),而这一轮再也不会有第二次 `turn/end`,
|
|
620
|
+
* 状态行就永久挂着。所以收口之后只发 `assistant/message`。
|
|
621
|
+
*/
|
|
622
|
+
if (!state.turnOpen) {
|
|
623
|
+
emitSettled(session, conn, block, state, true)
|
|
624
|
+
return
|
|
625
|
+
}
|
|
626
|
+
conn.write({
|
|
627
|
+
type: 'assistant/chunk',
|
|
628
|
+
sessionId: session.id,
|
|
629
|
+
data: {
|
|
630
|
+
turn,
|
|
631
|
+
step: 0,
|
|
632
|
+
chunk: {
|
|
633
|
+
index,
|
|
634
|
+
type: block.kind === 'text' ? 'text-delta' : 'reasoning-delta',
|
|
635
|
+
text: full.slice(sent),
|
|
636
|
+
},
|
|
637
|
+
},
|
|
638
|
+
})
|
|
639
|
+
return
|
|
640
|
+
}
|
|
641
|
+
|
|
642
|
+
if (block.kind === 'tool') {
|
|
643
|
+
const key = `${block.state}|${textOf(block.output)}|${textOf(block.argsRaw)}`
|
|
644
|
+
const prev = conn.tool.get(block.id)
|
|
645
|
+
if (prev === key) return
|
|
646
|
+
conn.tool.set(block.id, key)
|
|
647
|
+
const first = prev === undefined
|
|
648
|
+
// 收口之后到的工具块同理(见正文那一段):草稿帧会把这一轮重新认成「在跑」,
|
|
649
|
+
// 所以只补 `assistant/message` 与结果,不再发 `assistant/chunk`。
|
|
650
|
+
if (state.turnOpen) {
|
|
651
|
+
if (first) {
|
|
652
|
+
conn.write({
|
|
653
|
+
type: 'assistant/chunk',
|
|
654
|
+
sessionId: session.id,
|
|
655
|
+
data: {
|
|
656
|
+
turn,
|
|
657
|
+
step: 0,
|
|
658
|
+
chunk: { index, type: 'tool-call-delta', id: block.callId, name: block.name || '', argumentsDelta: textOf(block.argsRaw) },
|
|
659
|
+
},
|
|
660
|
+
})
|
|
661
|
+
}
|
|
662
|
+
if (block.state === 'running' && !first) return
|
|
663
|
+
conn.write({ type: 'assistant/chunk', sessionId: session.id, data: { turn, step: 0, chunk: { index, type: 'block-end' } } })
|
|
664
|
+
}
|
|
665
|
+
if (block.state !== 'running') {
|
|
666
|
+
conn.write({
|
|
667
|
+
type: 'tool/result',
|
|
668
|
+
sessionId: session.id,
|
|
669
|
+
seq: nextSeq(state),
|
|
670
|
+
data: {
|
|
671
|
+
message: {
|
|
672
|
+
source: { callId: block.callId },
|
|
673
|
+
content: textOf(block.output) ? [{ type: 'text', text: block.output }] : [],
|
|
674
|
+
isError: block.state === 'error',
|
|
675
|
+
},
|
|
676
|
+
},
|
|
677
|
+
})
|
|
678
|
+
}
|
|
679
|
+
if (!state.turnOpen) emitSettled(session, conn, block, state, true)
|
|
680
|
+
return
|
|
681
|
+
}
|
|
682
|
+
|
|
683
|
+
if (block.kind === 'notice') {
|
|
684
|
+
// 提示块只当正文草稿流露一手;落定的那一份走 `assistant/message`。
|
|
685
|
+
if (conn.emitted.has(block.id)) return
|
|
686
|
+
conn.emitted.add(block.id)
|
|
687
|
+
const text = textOf(block.text)
|
|
688
|
+
if (!text) return
|
|
689
|
+
if (!state.turnOpen) {
|
|
690
|
+
emitSettled(session, conn, block, state, true)
|
|
691
|
+
return
|
|
692
|
+
}
|
|
693
|
+
conn.write({
|
|
694
|
+
type: 'assistant/chunk',
|
|
695
|
+
sessionId: session.id,
|
|
696
|
+
data: { turn, step: 0, chunk: { index, type: 'text-delta', text: `${text}\n` } },
|
|
697
|
+
})
|
|
698
|
+
}
|
|
699
|
+
}
|
|
700
|
+
|
|
701
|
+
/**
|
|
702
|
+
* 历史块 → 实时帧的块形状。
|
|
703
|
+
*
|
|
704
|
+
* 历史那一条是 App 的 `ChatMessage.blocks`(`callId` / `argsRaw`),而 SSE 的
|
|
705
|
+
* `assistant/message` 要过 App 的 `parseChatBlocks`(宿主形状:`id` / `arguments`)。
|
|
706
|
+
* 两种形状是同一件事的两套写法,发之前翻译一次;不转的话实时那条工具卡没有 callId,
|
|
707
|
+
* 后面到的 `tool/result` 补丁就配不上它。
|
|
708
|
+
*/
|
|
709
|
+
function wireBlocksOf(blocks) {
|
|
710
|
+
return (blocks || []).map((block) => {
|
|
711
|
+
if (block.type !== 'tool-call') return block
|
|
712
|
+
return {
|
|
713
|
+
type: 'tool-call',
|
|
714
|
+
...(block.callId ? { id: block.callId } : {}),
|
|
715
|
+
...(block.name ? { name: block.name } : {}),
|
|
716
|
+
...(block.argsRaw ? { arguments: block.argsRaw } : {}),
|
|
717
|
+
...(block.state ? { state: block.state } : {}),
|
|
718
|
+
...(block.output ? { output: block.output } : {}),
|
|
719
|
+
}
|
|
720
|
+
})
|
|
721
|
+
}
|
|
722
|
+
|
|
723
|
+
/** 一条块落定后补发 `assistant/message`(同一块只发一次;历史里同 id 的行与它合并)。 */
|
|
724
|
+
function emitSettled(session, conn, block, state, force = false) {
|
|
725
|
+
if (block.kind === 'user' || block.kind === 'plan') return
|
|
726
|
+
if (conn.messaged.has(block.id)) return
|
|
727
|
+
const message = messageOf(block, state, force)
|
|
728
|
+
if (!message) return
|
|
729
|
+
conn.messaged.add(block.id)
|
|
730
|
+
conn.write({
|
|
731
|
+
type: 'assistant/message',
|
|
732
|
+
sessionId: session.id,
|
|
733
|
+
seq: nextSeq(state),
|
|
734
|
+
data: { message: { id: message.id, role: 'assistant', content: wireBlocksOf(message.blocks) } },
|
|
735
|
+
})
|
|
736
|
+
}
|
|
737
|
+
|
|
738
|
+
/**
|
|
739
|
+
* 一轮收口:先 `turn/end`,再把这一轮每个落定的块按稳定 id 发一条 `assistant/message`。
|
|
740
|
+
*
|
|
741
|
+
* 顺序是刻意的:App 在「当前回合」里收到 assistant/message 会就地再收一次尾,先发
|
|
742
|
+
* turn/end 就只是「把这一轮的终局交出去」,不触发重复收尾。
|
|
743
|
+
*/
|
|
744
|
+
/**
|
|
745
|
+
* 一轮收口:先 `turn/end`,再把这一轮每个落定的块按稳定 id 发一条 `assistant/message`。
|
|
746
|
+
*
|
|
747
|
+
* 顺序是刻意的:App 在「当前回合」里收到 assistant/message 会就地再收一次尾,先发
|
|
748
|
+
* turn/end 就只是「把这一轮的终局交出去」,不触发重复收尾。
|
|
749
|
+
*
|
|
750
|
+
* 判据是**会话这一轮开着**,不是「这条连接上开着」。2026-11-04 现场:手机在发送之后才接上
|
|
751
|
+
* 流(进页面晚、断线重连、屏幕熄灭后回来),这条连接的 `turnOpen` 一直是假,于是收尾时
|
|
752
|
+
* 一句 `turn/end` 都不发 —— 页面上的「深度求索中」永久挂着,而正文早就到了。
|
|
753
|
+
*/
|
|
754
|
+
function settleTurn(session, conn, reason) {
|
|
755
|
+
const state = stateOf(session)
|
|
756
|
+
if (!state.turnOpen && !conn.turnOpen) return
|
|
757
|
+
state.turnOpen = false
|
|
758
|
+
conn.turnOpen = false
|
|
759
|
+
syncState(session, state)
|
|
760
|
+
closeTurn(session, state, reason === 'error' ? 'error' : 'idle')
|
|
761
|
+
const turn = Math.max(1, state.turn)
|
|
762
|
+
conn.write({
|
|
763
|
+
type: 'turn/end',
|
|
764
|
+
sessionId: session.id,
|
|
765
|
+
seq: nextSeq(state),
|
|
766
|
+
data: { turn, reason: { kind: reason === 'error' ? 'error' : 'completed' } },
|
|
767
|
+
})
|
|
768
|
+
syncState(session, state)
|
|
769
|
+
// `force`:service 里「块落定」与「状态变 idle」是两条广播,快照里的 streaming 标记
|
|
770
|
+
// 可能还没跟上。这一轮已经结束了,正文按当前内容落定,别再等下一次块帧。
|
|
771
|
+
for (const block of session.blocks.list()) emitSettled(session, conn, block, state, true)
|
|
772
|
+
}
|
|
773
|
+
|
|
774
|
+
function onStatus(session, conn, status) {
|
|
775
|
+
if (status === 'running') {
|
|
776
|
+
// 两处都要记:会话那一份是「这一轮还没收口」的事实,连接那一份只影响这一条流上
|
|
777
|
+
// 补不补 `turn/start`。早先这里只写会话、收尾却只认连接,晚接上的那条流永远收不了尾。
|
|
778
|
+
stateOf(session).turnOpen = true
|
|
779
|
+
conn.turnOpen = true
|
|
780
|
+
return
|
|
781
|
+
}
|
|
782
|
+
if (status === 'idle' || status === 'error') settleTurn(session, conn, status === 'error' ? 'error' : 'done')
|
|
783
|
+
}
|
|
784
|
+
|
|
785
|
+
function approvalFrameOf(session, request) {
|
|
786
|
+
return {
|
|
787
|
+
type: 'approval/requested',
|
|
788
|
+
sessionId: session.id,
|
|
789
|
+
rpcId: rpcIdOf(request),
|
|
790
|
+
approvalId: request.id,
|
|
791
|
+
toolName: request.title,
|
|
792
|
+
...(request.detail ? { reason: request.detail } : {}),
|
|
793
|
+
}
|
|
794
|
+
}
|
|
795
|
+
|
|
796
|
+
function rpcIdOf(request) {
|
|
797
|
+
return `cli:${request.id}`
|
|
798
|
+
}
|
|
799
|
+
|
|
800
|
+
/** 一条会话产生的帧 → 这个连接的写入。返回解绑函数。 */
|
|
801
|
+
function attach(session, write) {
|
|
802
|
+
const conn = {
|
|
803
|
+
write,
|
|
804
|
+
sent: new Map(),
|
|
805
|
+
tool: new Map(),
|
|
806
|
+
emitted: new Set(),
|
|
807
|
+
/** 已经按 id 发过楼定消息的块:历史里已有它们,别重发。 */
|
|
808
|
+
messaged: new Set(),
|
|
809
|
+
/** 这条连接上「这一轮」开着没有(帧该不该发只看它,记账在会话级观察者里)。 */
|
|
810
|
+
turnOpen: false,
|
|
811
|
+
}
|
|
812
|
+
const state = stateOf(session)
|
|
813
|
+
syncState(session, state)
|
|
814
|
+
// 接上时补「正在发生的那一轮」:还在流的正文按**当前全量**当一段增量发出去(历史里
|
|
815
|
+
// 没有它),这样页面重进 / 断线重连回来能接着看,而落定的旧内容靠历史铺。
|
|
816
|
+
if (state.turnOpen) {
|
|
817
|
+
conn.turnOpen = true
|
|
818
|
+
write({ type: 'turn/start', sessionId: session.id, seq: nextSeq(state), data: { turn: Math.max(1, state.turn) } })
|
|
819
|
+
}
|
|
820
|
+
for (const block of session.blocks.list()) {
|
|
821
|
+
if ((block.kind === 'text' || block.kind === 'reasoning') && block.streaming && textOf(block.text)) {
|
|
822
|
+
const index = state.blockIndex.get(block.id) || 0
|
|
823
|
+
conn.sent.set(block.id, block.text.length)
|
|
824
|
+
write({
|
|
825
|
+
type: 'assistant/chunk',
|
|
826
|
+
sessionId: session.id,
|
|
827
|
+
data: {
|
|
828
|
+
turn: state.turnByBlock.get(block.id) || 0,
|
|
829
|
+
step: 0,
|
|
830
|
+
chunk: { index, type: block.kind === 'text' ? 'text-delta' : 'reasoning-delta', text: block.text },
|
|
831
|
+
},
|
|
832
|
+
})
|
|
833
|
+
}
|
|
834
|
+
if (block.kind === 'tool') conn.tool.set(block.id, `${block.state}|${textOf(block.output)}|${textOf(block.argsRaw)}`)
|
|
835
|
+
if (block.kind === 'user' || block.kind === 'notice') conn.emitted.add(block.id)
|
|
836
|
+
// 落定的块历史里已经有了:接上时不再按消息补一遍,避免同一段话画两遍。
|
|
837
|
+
if (block.kind !== 'user' && block.kind !== 'plan' && !block.streaming) conn.messaged.add(block.id)
|
|
838
|
+
}
|
|
839
|
+
for (const request of session.permissionFrames.values()) {
|
|
840
|
+
approvals.set(rpcIdOf(request), { sessionId: session.id, requestId: request.id })
|
|
841
|
+
write(approvalFrameOf(session, request))
|
|
842
|
+
}
|
|
843
|
+
const unwatch = watch(session, (frame) => {
|
|
844
|
+
if (frame.t === 'block') onBlock(session, conn, frame.block)
|
|
845
|
+
else if (frame.t === 'status') onStatus(session, conn, frame.status)
|
|
846
|
+
else if (frame.t === 'title') write({ type: 'session/title', sessionId: session.id, seq: nextSeq(state), data: { title: frame.title } })
|
|
847
|
+
else if (frame.t === 'permission') {
|
|
848
|
+
approvals.set(rpcIdOf(frame.request), { sessionId: session.id, requestId: frame.request.id })
|
|
849
|
+
write(approvalFrameOf(session, frame.request))
|
|
850
|
+
} else if (frame.t === 'permission-resolved') {
|
|
851
|
+
approvals.delete(`cli:${frame.id}`)
|
|
852
|
+
write({ type: 'approval/resolved', sessionId: session.id, approvalId: frame.id })
|
|
853
|
+
} else if (frame.t === 'exit') {
|
|
854
|
+
settleTurn(session, conn, 'error')
|
|
855
|
+
}
|
|
856
|
+
})
|
|
857
|
+
// 会话已经退出过的:把结局当场说清楚,页面不会一直转圈。
|
|
858
|
+
if (session.status === 'error' && !state.turnOpen) {
|
|
859
|
+
write({ type: 'session/heartbeat', sessionId: session.id, at: Date.now() })
|
|
860
|
+
}
|
|
861
|
+
return () => {
|
|
862
|
+
unwatch()
|
|
863
|
+
conn.sent.clear()
|
|
864
|
+
}
|
|
865
|
+
}
|
|
866
|
+
|
|
867
|
+
/**
|
|
868
|
+
* CLI 会话的「导出日志」:没有宿主的会话日志可导(那本账是宿主记的),但这条入口
|
|
869
|
+
* 挂在对话页的「⋯」里,报错就会变成一张错误卡。回一份合法的空 ZIP,App 正常落盘。
|
|
870
|
+
*/
|
|
871
|
+
function handleExport(session, req, res) {
|
|
872
|
+
// 空 ZIP 的 EOCD:`PK\x05\x06` + 18 个 0。只有 22 字节,不需要任何压缩库。
|
|
873
|
+
const empty = Buffer.concat([Buffer.from([0x50, 0x4b, 0x05, 0x06]), Buffer.alloc(18)])
|
|
874
|
+
const filename = `dsh-session-${String(session.id).replace(/[^A-Za-z0-9_-]/g, '_')}.zip`
|
|
875
|
+
const opened = req && req.__dshE2ee
|
|
876
|
+
if (opened && opened.keys && opened.req) {
|
|
877
|
+
res.writeHead(200, chunksHeaders({
|
|
878
|
+
'content-disposition': `attachment; filename="${filename}"`,
|
|
879
|
+
'x-dsh-content-type': 'application/zip',
|
|
880
|
+
}))
|
|
881
|
+
void pipeEncrypted(req, res, Readable.from([empty]))
|
|
882
|
+
return
|
|
883
|
+
}
|
|
884
|
+
res.writeHead(200, {
|
|
885
|
+
'content-type': 'application/zip',
|
|
886
|
+
'content-disposition': `attachment; filename="${filename}"`,
|
|
887
|
+
'content-length': String(empty.length),
|
|
888
|
+
'cache-control': 'no-store',
|
|
889
|
+
'content-encoding': 'identity',
|
|
890
|
+
})
|
|
891
|
+
res.end(empty)
|
|
892
|
+
}
|
|
893
|
+
|
|
894
|
+
// ── 各端点 ──────────────────────────────────────────────────────────────
|
|
895
|
+
|
|
896
|
+
function sendJsonQuiet(res, body) {
|
|
897
|
+
sendJson(res, 200, { ok: true, ...body })
|
|
898
|
+
}
|
|
899
|
+
|
|
900
|
+
async function handlersOf(session, path, req, res, body) {
|
|
901
|
+
const state = stateOf(session)
|
|
902
|
+
switch (path) {
|
|
903
|
+
case '/session':
|
|
904
|
+
sendJsonQuiet(res, {
|
|
905
|
+
session: {
|
|
906
|
+
sessionId: session.id,
|
|
907
|
+
cwd: session.cwd,
|
|
908
|
+
title: session.title,
|
|
909
|
+
blank: !session.blocks.list().some((block) => block.kind === 'user'),
|
|
910
|
+
lastSeq: stateOf(session).seq,
|
|
911
|
+
lastMessage: lastMessageOf(itemsOf(session)),
|
|
912
|
+
},
|
|
913
|
+
})
|
|
914
|
+
return
|
|
915
|
+
case '/session/head':
|
|
916
|
+
// 不知道就老实说不知道:App 会退回整份拉取,比给一个假水位便宜。
|
|
917
|
+
sendJsonQuiet(res, { seq: 0, known: false })
|
|
918
|
+
return
|
|
919
|
+
case '/session/messages': {
|
|
920
|
+
// 游标一律不认:App 手上的 `afterSeq` 是**实时事件序号**,而这里的 `seq` 是块表
|
|
921
|
+
// 顺序号(两个空间)。CLI 会话本来就短,整份给回去由 App 按 id 合并,比按一个
|
|
922
|
+
// 对不上的游标过滤安全。
|
|
923
|
+
const items = itemsOf(session)
|
|
924
|
+
sendJsonQuiet(res, {
|
|
925
|
+
items,
|
|
926
|
+
hasMore: false,
|
|
927
|
+
lastSeq: stateOf(session).seq,
|
|
928
|
+
...(items.length ? { oldestSeq: items[0].seq } : {}),
|
|
929
|
+
})
|
|
930
|
+
return
|
|
931
|
+
}
|
|
932
|
+
case '/session/stats':
|
|
933
|
+
// 两块真数据并在一起:
|
|
934
|
+
// - 计划:ACP 的 `plan` 事件整表替换落在块表里(`kind === 'plan'`),计划条读 `todos`;
|
|
935
|
+
// - 用量:`usage_update` 与 prompt 回包记下的上下文 / token / 耗时(见 `cli/usage.js`),
|
|
936
|
+
// 底部那行「tok/s · 缓存命中 · 上下文」读它。
|
|
937
|
+
// 没报上来的项照样缺席 —— 缺席就是「没有」,别编成 0。
|
|
938
|
+
sendJsonQuiet(res, { projections: { ...statsOf(session.usage), ...planProjection(session) } })
|
|
939
|
+
return
|
|
940
|
+
case '/session-log':
|
|
941
|
+
handleExport(session, req, res)
|
|
942
|
+
return
|
|
943
|
+
case '/session/models':
|
|
944
|
+
sendJson(res, 200, modelsBodyOf(session))
|
|
945
|
+
return
|
|
946
|
+
/**
|
|
947
|
+
* 选模型 / 换思考档:**等 ACP 真的改完再回包**。
|
|
948
|
+
*
|
|
949
|
+
* 早先是 `void applyModelChoice(...)` 立刻回 200(跟宿主那条学,宿主那边 select 是排进
|
|
950
|
+
* 队列的写)。但对 CLI 这条定时不安全:App 收到 200 就会刷新目录,而 ACP 的
|
|
951
|
+
* `set_config_option` 可能还没跑到,那一刷拿回的是旧值 —— 界面上就是「选了,没生效」;
|
|
952
|
+
* 更糟的是紧接着「新建一条对话」,`preferredModel` 那时候还没写进去,于是新会话套不上。
|
|
953
|
+
* 现在改成真实的往返:改完(并记下账)再回,App 那一次刷新读到的就是新值。
|
|
954
|
+
*/
|
|
955
|
+
case '/session/select-model': {
|
|
956
|
+
await applyModelChoice(session, body)
|
|
957
|
+
sendJsonQuiet(res, { models: modelsBodyOf(session) })
|
|
958
|
+
return
|
|
959
|
+
}
|
|
960
|
+
case '/session/model-option':
|
|
961
|
+
await applyModelOption(session, body)
|
|
962
|
+
sendJsonQuiet(res, {})
|
|
963
|
+
return
|
|
964
|
+
/**
|
|
965
|
+
* 模型管理页的开关:`{ sessionId, kind: 'model'|'config', id, visible }`。
|
|
966
|
+
*
|
|
967
|
+
* 真值写在这台电脑上(`cli/model-visibility.js`,按 CLI 记账),所以换一台手机看到的是
|
|
968
|
+
* 同一份 —— ACP 那边根本不知道有这回事,它给的目录原样不动,我们只决定「画不画」。
|
|
969
|
+
* 回包里带上改完的整份目录,管理页与选择器都能就地刷新。
|
|
970
|
+
*/
|
|
971
|
+
case '/session/model-visibility': {
|
|
972
|
+
setVisible(session.agentId, textOf(body && body.kind) === 'config' ? 'config' : 'model', textOf(body && body.id), body && body.visible !== false)
|
|
973
|
+
sendJson(res, 200, modelsBodyOf(session))
|
|
974
|
+
return
|
|
975
|
+
}
|
|
976
|
+
case '/commands':
|
|
977
|
+
sendJsonQuiet(res, { items: (session.commands || []).map((command) => ({
|
|
978
|
+
name: command.name,
|
|
979
|
+
description: command.description,
|
|
980
|
+
...(command.hint ? { input: { hint: command.hint } } : {}),
|
|
981
|
+
})) })
|
|
982
|
+
return
|
|
983
|
+
case '/skills':
|
|
984
|
+
sendJsonQuiet(res, { skills: [] })
|
|
985
|
+
return
|
|
986
|
+
case '/session/search':
|
|
987
|
+
sendJsonQuiet(res, { items: [], hasMore: false })
|
|
988
|
+
return
|
|
989
|
+
case '/session/references':
|
|
990
|
+
case '/file-references':
|
|
991
|
+
case '/session-references':
|
|
992
|
+
sendJsonQuiet(res, { items: [] })
|
|
993
|
+
return
|
|
994
|
+
case '/session/read':
|
|
995
|
+
// 与宿主那条同口径:读过之后这一轮就不再算「刚完成」,后台不该再为它响。
|
|
996
|
+
state.readAt = Date.now()
|
|
997
|
+
sendJsonQuiet(res, {})
|
|
998
|
+
return
|
|
999
|
+
case '/session/queue/update':
|
|
1000
|
+
sendJsonQuiet(res, {})
|
|
1001
|
+
return
|
|
1002
|
+
case '/session/attachment':
|
|
1003
|
+
await handleAttachment(req, res, body)
|
|
1004
|
+
return
|
|
1005
|
+
case '/session/goal':
|
|
1006
|
+
sendJsonQuiet(res, {})
|
|
1007
|
+
return
|
|
1008
|
+
case '/session/delivery':
|
|
1009
|
+
sendJsonQuiet(res, { delivered: false })
|
|
1010
|
+
return
|
|
1011
|
+
case '/session/rename': {
|
|
1012
|
+
const title = textOf(body && body.title).trim()
|
|
1013
|
+
if (title) setTitle(session, title)
|
|
1014
|
+
sendJson(res, 200, { ok: true, title: title || session.title, seq: 0 })
|
|
1015
|
+
return
|
|
1016
|
+
}
|
|
1017
|
+
case '/session/fork':
|
|
1018
|
+
// CLI 会话没有可分叉的宿主日志:回一个「还在这条」的成功,页面不会被错误卡挡住。
|
|
1019
|
+
sendJson(res, 200, { ok: true, sessionId: session.id })
|
|
1020
|
+
return
|
|
1021
|
+
case '/session/stop':
|
|
1022
|
+
cancelTurn(session)
|
|
1023
|
+
sendJson(res, 200, { ok: true, accepted: true })
|
|
1024
|
+
return
|
|
1025
|
+
case '/session/send': {
|
|
1026
|
+
const content = Array.isArray(body && body.content) ? body.content : []
|
|
1027
|
+
const message = textOf(body && body.text) || textOfContent(content)
|
|
1028
|
+
// 这一发带的图:先认下字节(手机发上来时已经落在收件箱里),块表里只记元数据。
|
|
1029
|
+
const sent = await saveImages(content)
|
|
1030
|
+
// 图的毛病先说清楚(上传过期 / 读不出来),比「这一发没有正文」有用得多。
|
|
1031
|
+
if (sent.problem) {
|
|
1032
|
+
return sendJson(res, 400, { ok: false, error: { code: 'bad-request', message: sent.problem } })
|
|
1033
|
+
}
|
|
1034
|
+
/**
|
|
1035
|
+
* 这个模型明确不收图时,**当场说清楚**,别把图塞过去让模型自己回一句「我看不到」。
|
|
1036
|
+
*
|
|
1037
|
+
* 2026-11-04 用户现场「发图片它还是看不到」:链路上图片确实到了(换一个能读图的模型
|
|
1038
|
+
* 立刻答对了颜色),但默认那个模型 `vision: false`,适配器照样收下、模型再回一句
|
|
1039
|
+
* 「当前模型不支持图片输入」。用户看到的是「发了但没反应」,而实际问题一句话就能说清。
|
|
1040
|
+
* 用与宿主同一组 reason(`MODEL_DOES_NOT_SUPPORT_IMAGES`),App 那边有现成文案。
|
|
1041
|
+
*/
|
|
1042
|
+
if (sent.images.length > 0 && currentVisionOf(session) === false) {
|
|
1043
|
+
return sendJson(res, 400, {
|
|
1044
|
+
ok: false,
|
|
1045
|
+
error: {
|
|
1046
|
+
code: 'attachment-error',
|
|
1047
|
+
message: '当前模型不支持图片,请切换支持图片的模型',
|
|
1048
|
+
details: { reason: 'MODEL_DOES_NOT_SUPPORT_IMAGES' },
|
|
1049
|
+
},
|
|
1050
|
+
})
|
|
1051
|
+
}
|
|
1052
|
+
if (!message && sent.images.length === 0) {
|
|
1053
|
+
return sendJson(res, 400, { ok: false, error: { code: 'bad-request', message: '这一发没有正文' } })
|
|
1054
|
+
}
|
|
1055
|
+
const result = runPrompt(session, message, sent.images)
|
|
1056
|
+
if (!result.ok) return sendJson(res, 409, { ok: false, error: { code: 'cli-busy', message: result.error || '这一轮还没结束' } })
|
|
1057
|
+
sendJson(res, 200, { ok: true, accepted: true })
|
|
1058
|
+
return
|
|
1059
|
+
}
|
|
1060
|
+
case '/session/step':
|
|
1061
|
+
sendJsonQuiet(res, { step: { kind: textOf(queryOf(req).get('kind')) || 'tool', text: '' } })
|
|
1062
|
+
return
|
|
1063
|
+
case '/subagents':
|
|
1064
|
+
sendJsonQuiet(res, { entries: [], parentAvailable: true })
|
|
1065
|
+
return
|
|
1066
|
+
case '/extension-cards':
|
|
1067
|
+
sendJsonQuiet(res, { cards: [] })
|
|
1068
|
+
return
|
|
1069
|
+
default:
|
|
1070
|
+
sendJsonQuiet(res, {})
|
|
1071
|
+
}
|
|
1072
|
+
}
|
|
1073
|
+
|
|
1074
|
+
/**
|
|
1075
|
+
* 这一发带的图 → 存好的元数据。
|
|
1076
|
+
*
|
|
1077
|
+
* App 的图片块有两种写法:`uploadId`(分片上传,字节已经落在收件箱)与内联 `data`
|
|
1078
|
+
* (老客户端、子会话那种)。两种都要认 —— 只认一种的话,换一个客户端发图就静默丢掉。
|
|
1079
|
+
*
|
|
1080
|
+
* 存的时候按内容哈希去重(同一张图发两次只有一份),块表里只留 `attachmentId`:历史与实时
|
|
1081
|
+
* 帧都只下发引用,字节等手机真的要看时再按 id 来取。
|
|
1082
|
+
*/
|
|
1083
|
+
async function saveImages(content) {
|
|
1084
|
+
const out = []
|
|
1085
|
+
for (const block of Array.isArray(content) ? content : []) {
|
|
1086
|
+
if (!block || block.type !== 'image') continue
|
|
1087
|
+
let bytes = null
|
|
1088
|
+
let name = textOf(block.name) || ''
|
|
1089
|
+
if (typeof block.data === 'string' && block.data) {
|
|
1090
|
+
try { bytes = Buffer.from(block.data, 'base64') } catch { bytes = null }
|
|
1091
|
+
} else if (typeof block.uploadId === 'string' && block.uploadId) {
|
|
1092
|
+
const record = resolveFinishedUpload(block.uploadId)
|
|
1093
|
+
// 画布素材那份只带哈希没有路径,不能拿来内联(与宿主那条同一个判定)。
|
|
1094
|
+
if (!record || !record.path) return { images: out, problem: '这张图片的上传已过期,请重新发送' }
|
|
1095
|
+
try { bytes = await fsp.readFile(record.path) } catch { bytes = null }
|
|
1096
|
+
if (!name) name = textOf(record.name)
|
|
1097
|
+
}
|
|
1098
|
+
if (!bytes || bytes.length === 0) return { images: out, problem: '这张图片读不出来,请重新选择' }
|
|
1099
|
+
const mediaType = textOf(block.mediaType) || 'image/png'
|
|
1100
|
+
if (!MEDIA_EXT[mediaType]) return { images: out, problem: '这个格式的图片发不了' }
|
|
1101
|
+
if (bytes.length > MAX_IMAGE_BYTES) return { images: out, problem: '这张图片太大了' }
|
|
1102
|
+
const saved = await saveImage({ bytes, mediaType, name })
|
|
1103
|
+
if (!saved) return { images: out, problem: '这张图片存不下来' }
|
|
1104
|
+
out.push(saved)
|
|
1105
|
+
}
|
|
1106
|
+
return { images: out, problem: '' }
|
|
1107
|
+
}
|
|
1108
|
+
|
|
1109
|
+
/**
|
|
1110
|
+
* `/session/attachment`:手机上按 `attachmentId` 来取一张图的字节。
|
|
1111
|
+
*
|
|
1112
|
+
* 三种回法与宿主那条逐字一致(App 的 `useAttachmentUri` 与原生通道只认这几种):
|
|
1113
|
+
* - `dims:true` 只要宽高,不搬字节;
|
|
1114
|
+
* - `raw:true` 原始字节走分块信封(不解包不许走:那是明文传输);
|
|
1115
|
+
* - 默认 整份 base64。
|
|
1116
|
+
*/
|
|
1117
|
+
async function handleAttachment(req, res, body) {
|
|
1118
|
+
const attachmentId = textOf(body && body.attachmentId)
|
|
1119
|
+
const meta = metaOf(attachmentId)
|
|
1120
|
+
const opened = openImage(attachmentId)
|
|
1121
|
+
if (!meta || !opened) {
|
|
1122
|
+
return sendJson(res, 404, { ok: false, error: { code: 'not-found', message: '这张图片已经不在了' } })
|
|
1123
|
+
}
|
|
1124
|
+
if (body && body.dims === true) {
|
|
1125
|
+
if (!meta.width || !meta.height) {
|
|
1126
|
+
return sendJson(res, 404, { ok: false, error: { code: 'no-dims', message: '这张图的文件头里量不出宽高' } })
|
|
1127
|
+
}
|
|
1128
|
+
return sendJson(res, 200, { ok: true, mediaType: meta.mediaType, width: meta.width, height: meta.height })
|
|
1129
|
+
}
|
|
1130
|
+
if (body && body.raw === true) {
|
|
1131
|
+
const e2ee = req && req.__dshE2ee
|
|
1132
|
+
if (!e2ee || !e2ee.keys || !e2ee.req) {
|
|
1133
|
+
return sendJson(res, 400, { ok: false, error: { code: 'e2ee-required', message: '这条会话是加密的,请用信封重发' } })
|
|
1134
|
+
}
|
|
1135
|
+
res.writeHead(200, chunksHeaders({ 'x-dsh-content-type': meta.mediaType, 'x-dsh-file-size': String(opened.size) }))
|
|
1136
|
+
const stream = fs.createReadStream(opened.path)
|
|
1137
|
+
res.on('close', () => stream.destroy())
|
|
1138
|
+
await pipeEncrypted(req, res, stream)
|
|
1139
|
+
return
|
|
1140
|
+
}
|
|
1141
|
+
let data = ''
|
|
1142
|
+
try { data = (await fsp.readFile(opened.path)).toString('base64') } catch { data = '' }
|
|
1143
|
+
if (!data) return sendJson(res, 404, { ok: false, error: { code: 'not-found', message: '这张图片已经不在了' } })
|
|
1144
|
+
sendJson(res, 200, {
|
|
1145
|
+
ok: true,
|
|
1146
|
+
mediaType: meta.mediaType,
|
|
1147
|
+
data,
|
|
1148
|
+
...(meta.name ? { name: meta.name } : {}),
|
|
1149
|
+
})
|
|
1150
|
+
}
|
|
1151
|
+
|
|
1152
|
+
async function applyModelChoice(session, body) {
|
|
1153
|
+
if (!session.handle || !body) return
|
|
1154
|
+
const model = textOf(body.model)
|
|
1155
|
+
const options = Array.isArray(session.configOptions) ? session.configOptions : []
|
|
1156
|
+
const modelOption = options.find((option) => option && option.category === 'model')
|
|
1157
|
+
|| options.find((option) => option && typeof option.id === 'string' && /model/i.test(option.id))
|
|
1158
|
+
try {
|
|
1159
|
+
if (model && modelOption) {
|
|
1160
|
+
await session.handle.setConfigOption(modelOption.id, model)
|
|
1161
|
+
// 记住它:下次在这个 CLI 上开新会话时自动套上(见 service 的 applyPreferredModel)。
|
|
1162
|
+
rememberModel(session.agentId, modelOption.id, model)
|
|
1163
|
+
}
|
|
1164
|
+
// 档位:ACP 把它放在一条会话级配置项里(见 `thoughtOptionOf`),与模型是两个独立的下拉。
|
|
1165
|
+
const effort = textOf(body.reasoningEffort)
|
|
1166
|
+
if (effort) {
|
|
1167
|
+
const effortOption = thoughtOptionOf(options)
|
|
1168
|
+
if (effortOption) await session.handle.setConfigOption(effortOption.id, effort)
|
|
1169
|
+
}
|
|
1170
|
+
} catch { /* 选不动就维持原样:下一次读 models 会回真实值 */ }
|
|
1171
|
+
}
|
|
1172
|
+
|
|
1173
|
+
async function applyModelOption(session, body) {
|
|
1174
|
+
if (!session.handle || !body) return
|
|
1175
|
+
try {
|
|
1176
|
+
const optionId = textOf(body.optionId) || textOf(body.id)
|
|
1177
|
+
if (!optionId) return
|
|
1178
|
+
// 档位在这一层也是「一条附加选项」,只是 App 的滑轨走的是上面那条 `select-model`。
|
|
1179
|
+
// 两条都认同一项,写进去的是同一个 ACP 配置。
|
|
1180
|
+
const updated = await session.handle.setConfigOption(optionId, body.value)
|
|
1181
|
+
if (updated && Array.isArray(updated.configOptions)) session.configOptions = updated.configOptions
|
|
1182
|
+
const option = (session.configOptions || []).find((item) => item && item.id === optionId)
|
|
1183
|
+
if (option && option.category === 'model') rememberModel(session.agentId, optionId, textOf(body.value))
|
|
1184
|
+
} catch { /* 同上 */ }
|
|
1185
|
+
}
|
|
1186
|
+
|
|
1187
|
+
function textOfContent(content) {
|
|
1188
|
+
if (!Array.isArray(content)) return ''
|
|
1189
|
+
return content.filter((block) => block && block.type === 'text' && typeof block.text === 'string').map((block) => block.text).join('\n').trim()
|
|
1190
|
+
}
|
|
1191
|
+
|
|
1192
|
+
// ── 路由层要的两个入口 ────────────────────────────────────────────────────
|
|
1193
|
+
|
|
1194
|
+
/**
|
|
1195
|
+
* 会话域端点的一次分派:`sessionId` 是 `cli-*` 就交给这里,否则原样交回原 handler。
|
|
1196
|
+
*
|
|
1197
|
+
* `sessionId` 在 GET 的查询串、POST 的请求体里。POST 要先读一次体 —— 读到的对象挂回
|
|
1198
|
+
* `req.body`(`readJsonBody` 认它),原 handler 再读是零成本的,请求流不会被读空两次。
|
|
1199
|
+
*/
|
|
1200
|
+
function route(path, fallback) {
|
|
1201
|
+
return async (ctx, req, res) => {
|
|
1202
|
+
const method = String((req && req.method) || 'GET').toUpperCase()
|
|
1203
|
+
let sessionId = null
|
|
1204
|
+
let body = null
|
|
1205
|
+
if (method === 'GET' || method === 'HEAD') {
|
|
1206
|
+
sessionId = queryOf(req).get('sessionId')
|
|
1207
|
+
} else {
|
|
1208
|
+
body = await readJsonBody(req, BODY_MAX)
|
|
1209
|
+
if (!body || typeof body !== 'object') {
|
|
1210
|
+
// 请求体读不出来(不是 JSON / 超限):与各 handler 自己的判定同口径回一句人话。
|
|
1211
|
+
return sendJson(res, 400, { ok: false, error: { code: 'bad-request', message: '请求体不是合法的 JSON' } })
|
|
1212
|
+
}
|
|
1213
|
+
req.body = body
|
|
1214
|
+
sessionId = body.sessionId
|
|
1215
|
+
}
|
|
1216
|
+
if (!isCliSessionId(sessionId)) return fallback(ctx, req, res)
|
|
1217
|
+
const session = sessionOf(sessionId)
|
|
1218
|
+
if (!session) {
|
|
1219
|
+
// 会话已经不在了(宿主重启 / 用户收掉):正文类接口回空,动作类接口回人话,
|
|
1220
|
+
// 别把 App 的对话页整屏变成错误卡。
|
|
1221
|
+
if (method === 'GET') return sendJsonQuiet(res, { items: [], hasMore: false, lastSeq: 0, skills: [], steps: [] })
|
|
1222
|
+
return sendJson(res, 404, { ok: false, error: { code: 'cli-session-not-found', message: '这条 CLI 会话已经不在了' } })
|
|
1223
|
+
}
|
|
1224
|
+
return await handlersOf(session, path.replace(/^\/api\/mobile\/v1/, ''), req, res, body)
|
|
1225
|
+
}
|
|
1226
|
+
}
|
|
1227
|
+
|
|
1228
|
+
/**
|
|
1229
|
+
* `/sessions`:宿主列表照原样构建,把还活着的 CLI 会话并进去。
|
|
1230
|
+
*
|
|
1231
|
+
* **CLI 会话不参与归档视图**:这条链上「归档」等于关掉那条 CLI(见 `routeFlag`),关掉之后
|
|
1232
|
+
* 进程、块表、会话一起没了,没有「已归档」这一档可列。所以 `?archived=1` 那一发原样交给
|
|
1233
|
+
* 宿主,一条 CLI 行都不并 —— 不在这里造一个点不回去的假归档。
|
|
1234
|
+
*/
|
|
1235
|
+
function mergeList(body, archivedOnly = false) {
|
|
1236
|
+
if (!body || body.ok !== true) return body
|
|
1237
|
+
if (archivedOnly) return body
|
|
1238
|
+
const items = Array.isArray(body.items) ? body.items : null
|
|
1239
|
+
// `unchanged` 那一份没有 items:CLI 会话是活的(运行态会变),不能回「没变」。
|
|
1240
|
+
if (!items) return body
|
|
1241
|
+
const mine = []
|
|
1242
|
+
for (const session of sessions.values()) {
|
|
1243
|
+
mine.push(summaryOf(session))
|
|
1244
|
+
}
|
|
1245
|
+
// 置顶的排最前(宿主那边置顶就是排在各工作区最前);其余按最近更新,排在宿主那批后面
|
|
1246
|
+
// —— 宿主行有自己的工作区顺序,不该被 CLI 行整批顶到后面。
|
|
1247
|
+
mine.sort((a, b) => (Number(b.pinned === true) - Number(a.pinned === true)) || (b.updatedAt - a.updatedAt))
|
|
1248
|
+
const pinned = mine.filter((item) => item.pinned === true)
|
|
1249
|
+
const rest = mine.filter((item) => item.pinned !== true)
|
|
1250
|
+
const merged = [...pinned, ...items, ...rest]
|
|
1251
|
+
return {
|
|
1252
|
+
...body,
|
|
1253
|
+
items: merged,
|
|
1254
|
+
revision: listRevision(merged),
|
|
1255
|
+
emptyConfirmed: merged.length === 0,
|
|
1256
|
+
}
|
|
1257
|
+
}
|
|
1258
|
+
|
|
1259
|
+
/** `/sessions` 的处理器包装:接住原 handler 的 JSON,合并后自己写出去。 */
|
|
1260
|
+
function wrapSessions(handler) {
|
|
1261
|
+
return async (ctx, req, res) => {
|
|
1262
|
+
// 一条 CLI 会话都没有时(绝大多数手机、绝大多数时间):原样交给宿主那条,不接响应、
|
|
1263
|
+
// 不重算 revision —— 合并只在真有行要并进来时才值得付那一次反序列化。
|
|
1264
|
+
if (sessions.size === 0) return handler(ctx, req, res)
|
|
1265
|
+
const url = new URL(req.url || '/', 'http://x')
|
|
1266
|
+
const archivedOnly = url.searchParams.get('archived') === '1'
|
|
1267
|
+
// 条件刷新那一发只带 revision、不带 items,合并就没法做:去掉 rev 让原 handler
|
|
1268
|
+
// 构建整份(10 秒缓存照样在),CLI 行才不会被一句 unchanged 吞掉。
|
|
1269
|
+
if (url.searchParams.has('rev')) {
|
|
1270
|
+
url.searchParams.delete('rev')
|
|
1271
|
+
req.url = `${url.pathname}${url.search}`
|
|
1272
|
+
}
|
|
1273
|
+
const captured = capture(res)
|
|
1274
|
+
await handler(ctx, req, captured.res)
|
|
1275
|
+
if (!captured.done) return
|
|
1276
|
+
sendJson(res, captured.status || 200, mergeList(captured.body, archivedOnly))
|
|
1277
|
+
}
|
|
1278
|
+
}
|
|
1279
|
+
|
|
1280
|
+
function capture(res) {
|
|
1281
|
+
const box = { done: false, status: 200, body: null, headers: null }
|
|
1282
|
+
const proxy = Object.create(res)
|
|
1283
|
+
proxy.writeHead = (status, headers) => { box.status = status; box.headers = headers || null; return proxy }
|
|
1284
|
+
proxy.end = (chunk) => {
|
|
1285
|
+
box.done = true
|
|
1286
|
+
box.body = decodeBody(chunk, box.headers)
|
|
1287
|
+
return proxy
|
|
1288
|
+
}
|
|
1289
|
+
proxy.write = () => true
|
|
1290
|
+
box.res = proxy
|
|
1291
|
+
return box
|
|
1292
|
+
}
|
|
1293
|
+
|
|
1294
|
+
/**
|
|
1295
|
+
* 接住原 handler 写出来的 JSON。
|
|
1296
|
+
*
|
|
1297
|
+
* 客户端声明了 `accept-encoding: gzip` 时 `sendJson` 会真的压:列表是几十 KB 的那一档,
|
|
1298
|
+
* 明文解析不出来 —— 那不是「没有 items」,而会在合并时被当成坏响应丢掉。
|
|
1299
|
+
*/
|
|
1300
|
+
function decodeBody(chunk, headers) {
|
|
1301
|
+
try {
|
|
1302
|
+
if (chunk === undefined || chunk === null) return null
|
|
1303
|
+
let text = Buffer.isBuffer(chunk) ? chunk : Buffer.from(String(chunk), 'utf8')
|
|
1304
|
+
const encoding = headers && typeof headers['content-encoding'] === 'string' ? headers['content-encoding'] : ''
|
|
1305
|
+
if (/gzip/i.test(encoding)) text = require('node:zlib').gunzipSync(text)
|
|
1306
|
+
return JSON.parse(text.toString('utf8'))
|
|
1307
|
+
} catch {
|
|
1308
|
+
return null
|
|
1309
|
+
}
|
|
1310
|
+
}
|
|
1311
|
+
|
|
1312
|
+
/**
|
|
1313
|
+
* `/approval/respond`:rpcId 是这里铸的(`cli:<requestId>`)就答 ACP,否则交回宿主那条。
|
|
1314
|
+
*/
|
|
1315
|
+
function wrapApprovalRespond(fallback) {
|
|
1316
|
+
return async (ctx, req, res) => {
|
|
1317
|
+
const body = await readJsonBody(req, BODY_MAX)
|
|
1318
|
+
if (body && typeof body === 'object') req.body = body
|
|
1319
|
+
const rpcId = textOf(body && body.rpcId)
|
|
1320
|
+
const pending = approvals.get(rpcId)
|
|
1321
|
+
if (!pending) return fallback(ctx, req, res)
|
|
1322
|
+
const session = sessionOf(pending.sessionId)
|
|
1323
|
+
const request = session && session.permissionFrames.get(pending.requestId)
|
|
1324
|
+
if (!session || !request) {
|
|
1325
|
+
approvals.delete(rpcId)
|
|
1326
|
+
return sendJson(res, 409, { ok: false, error: { code: 'approval-not-pending', message: '这条审批已经不在了' } })
|
|
1327
|
+
}
|
|
1328
|
+
const optionId = chooseOption(request, textOf(body.outcome))
|
|
1329
|
+
if (!optionId) return sendJson(res, 409, { ok: false, error: { code: 'approval-not-pending', message: '这张卡没有可选答案' } })
|
|
1330
|
+
answerPermission(session, request.id, optionId)
|
|
1331
|
+
approvals.delete(rpcId)
|
|
1332
|
+
sendJson(res, 200, { ok: true, accepted: true })
|
|
1333
|
+
}
|
|
1334
|
+
}
|
|
1335
|
+
|
|
1336
|
+
// ── 归档 / 置顶 / 清空归档:CLI 会话自己记账 ──────────────────────────────
|
|
1337
|
+
|
|
1338
|
+
/**
|
|
1339
|
+
* 四条 `workspace/*-session` 的分派。
|
|
1340
|
+
*
|
|
1341
|
+
* 宿主那本账(`workspace.list` 的 archivedSessionIds / pinnedSessionIds)里没有 CLI 会话,
|
|
1342
|
+
* 原样透过去只会换回一张错误卡。这里只改会话自己那份状态,`/sessions` 的投影跟着走:
|
|
1343
|
+
* 归档的只在 `?archived=1` 那一路出现,置顶的排最前(见 `mergeList`)。
|
|
1344
|
+
*/
|
|
1345
|
+
function routeFlag(kind, fallback) {
|
|
1346
|
+
return async (ctx, req, res) => {
|
|
1347
|
+
const body = await readJsonBody(req, BODY_MAX)
|
|
1348
|
+
if (!body || typeof body !== 'object') {
|
|
1349
|
+
return sendJson(res, 400, { ok: false, error: { code: 'bad-request', message: '请求体不是合法的 JSON' } })
|
|
1350
|
+
}
|
|
1351
|
+
req.body = body
|
|
1352
|
+
if (!isCliSessionId(body.sessionId)) return fallback(ctx, req, res)
|
|
1353
|
+
const session = sessionOf(body.sessionId)
|
|
1354
|
+
// 会话已经被收掉了:这个动作本身是幂等的,按成功回,App 不必弹错。
|
|
1355
|
+
if (!session) return sendJson(res, 200, { ok: true })
|
|
1356
|
+
const state = stateOf(session)
|
|
1357
|
+
// `archive` 在这条链上等于**关掉这条 CLI**(`forget` 收进程、从列表里消失)。
|
|
1358
|
+
//
|
|
1359
|
+
// 为什么不照宿主的语义「挪进已归档视图」:宿主那边归档的是一条会话文件,进程早就不在
|
|
1360
|
+
// 了,撤销归档就是把它从账本里拿回来;CLI 这边恰恰相反 —— 会话背后挂着一个真进程,
|
|
1361
|
+
// 「收起来」唯一说得通的意思就是把它停掉。留一个还能从「已归档」点回来的假归档,点
|
|
1362
|
+
// 回来的是一个已经死掉的子进程壳,比关掉更糟(2026-11-04 用户定的)。
|
|
1363
|
+
//
|
|
1364
|
+
// `unarchive` 仍收:老版本的 App 上点过「取消归档」的历史记录还会打过来,这个动作对
|
|
1365
|
+
// 不存在的会话本来就是幂等成功。
|
|
1366
|
+
if (kind === 'archive') {
|
|
1367
|
+
const closed = forget(session.id)
|
|
1368
|
+
bumpList()
|
|
1369
|
+
return sendJson(res, 200, closed ? { ok: true, closed: true } : { ok: true, closed: false })
|
|
1370
|
+
}
|
|
1371
|
+
if (kind === 'pin') state.pinned = true
|
|
1372
|
+
else if (kind === 'unpin') state.pinned = false
|
|
1373
|
+
bumpList()
|
|
1374
|
+
sendJson(res, 200, { ok: true })
|
|
1375
|
+
}
|
|
1376
|
+
}
|
|
1377
|
+
|
|
1378
|
+
/**
|
|
1379
|
+
* `POST /workspace/clear-archived` 原样交给宿主。
|
|
1380
|
+
*
|
|
1381
|
+
* CLI 会话不进归档视图(见 `mergeList`),所以这边没有可清的:这条链上「归档」就是关掉,
|
|
1382
|
+
* 在按下那一刻进程已经收了。曾经这里会把归档着的 CLI 会话一起 `forget`,现在那一档不
|
|
1383
|
+
* 存在了,掺和进来只会把 `deleted` 数改得跟用户看到的对不上。
|
|
1384
|
+
*/
|
|
1385
|
+
function wrapClearArchived(handler) {
|
|
1386
|
+
return handler
|
|
1387
|
+
}
|
|
1388
|
+
|
|
1389
|
+
/**
|
|
1390
|
+
* `/monitor` 里要并进去的那几条(安卓后台提醒服务读的那份视图)。
|
|
1391
|
+
*
|
|
1392
|
+
* 字段与宿主那套逐字一致(sessionId / title / running / completed / pendingInteraction /
|
|
1393
|
+
* pendingCard / lastTurn / lastTurnError),不新增接口、不改出口形状。活着的 CLI 会话都在
|
|
1394
|
+
* 这里 —— 关掉的那些已经不在这张表上了,没有第二档要滤。
|
|
1395
|
+
*/
|
|
1396
|
+
function monitorItems() {
|
|
1397
|
+
const out = []
|
|
1398
|
+
for (const session of sessions.values()) {
|
|
1399
|
+
const state = stateOf(session)
|
|
1400
|
+
syncState(session, state)
|
|
1401
|
+
const items = itemsOf(session, state)
|
|
1402
|
+
const running = session.status === 'running'
|
|
1403
|
+
const last = state.lastTurn
|
|
1404
|
+
// 「刚完成」按设备算:这一轮结束在这台手机读过之后才算新。
|
|
1405
|
+
const settled = last != null && last.endedAt > (state.readAt || 0)
|
|
1406
|
+
const item = {
|
|
1407
|
+
sessionId: session.id,
|
|
1408
|
+
title: session.title,
|
|
1409
|
+
running,
|
|
1410
|
+
completed: Boolean(last && !running && last.status === 'completed' && settled),
|
|
1411
|
+
}
|
|
1412
|
+
if (session.permissionFrames.size > 0) {
|
|
1413
|
+
item.pendingInteraction = 'approval'
|
|
1414
|
+
const request = [...session.permissionFrames.values()][0]
|
|
1415
|
+
if (request) item.pendingCard = approvalFrameOf(session, request)
|
|
1416
|
+
} else {
|
|
1417
|
+
// 显式 null 是「这台宿主认识这一格」的记号,原生靠它撤掉已经答掉的旧卡。
|
|
1418
|
+
item.pendingCard = null
|
|
1419
|
+
}
|
|
1420
|
+
if (last) {
|
|
1421
|
+
const failed = last.status === 'failed' && settled
|
|
1422
|
+
item.lastTurn = {
|
|
1423
|
+
turn: last.turn,
|
|
1424
|
+
endedAt: last.endedAt,
|
|
1425
|
+
status: last.status,
|
|
1426
|
+
...(last.status === 'failed' ? { code: null, message: last.message ?? null } : {}),
|
|
1427
|
+
}
|
|
1428
|
+
if (failed) item.lastTurnError = { turn: last.turn, code: null, message: last.message ?? null }
|
|
1429
|
+
}
|
|
1430
|
+
// 横幅正文只给「可能要提醒」的那几条补一句,与宿主那份同一个判据。
|
|
1431
|
+
if (item.completed || item.lastTurnError || item.pendingInteraction) {
|
|
1432
|
+
const preview = lastMessageOf(items)
|
|
1433
|
+
if (preview && preview.role === 'assistant' && preview.text) {
|
|
1434
|
+
item.lastMessage = { role: 'assistant', text: preview.text.slice(0, 200) }
|
|
1435
|
+
}
|
|
1436
|
+
}
|
|
1437
|
+
out.push(item)
|
|
1438
|
+
}
|
|
1439
|
+
return out
|
|
1440
|
+
}
|
|
1441
|
+
|
|
1442
|
+
// ── SSE:给 CLI 会话发一份与宿主同形状的流 ────────────────────────────────
|
|
1443
|
+
|
|
1444
|
+
function handleStream(ctx, req, res) {
|
|
1445
|
+
const sessionId = queryOf(req).get('sessionId')
|
|
1446
|
+
const session = sessionOf(sessionId)
|
|
1447
|
+
if (!session) {
|
|
1448
|
+
return sendJson(res, 404, { ok: false, error: { code: 'cli-session-not-found', message: '这条 CLI 会话已经不在了' } })
|
|
1449
|
+
}
|
|
1450
|
+
const sealFrame = createFrameSealer(req)
|
|
1451
|
+
const head = {
|
|
1452
|
+
'content-type': 'text/event-stream; charset=utf-8',
|
|
1453
|
+
'cache-control': 'no-cache',
|
|
1454
|
+
connection: 'keep-alive',
|
|
1455
|
+
'x-accel-buffering': 'no',
|
|
1456
|
+
'content-encoding': 'identity',
|
|
1457
|
+
...(sealFrame ? { 'x-dsh-proto': '2' } : {}),
|
|
1458
|
+
}
|
|
1459
|
+
try {
|
|
1460
|
+
res.writeHead(200, head)
|
|
1461
|
+
} catch {
|
|
1462
|
+
return
|
|
1463
|
+
}
|
|
1464
|
+
res.write(': connected\n\n')
|
|
1465
|
+
const untrack = streamRegistry.track(req.__dshRecord && req.__dshRecord.deviceId, res)
|
|
1466
|
+
let closed = false
|
|
1467
|
+
let detach = () => {}
|
|
1468
|
+
const teardown = () => {
|
|
1469
|
+
if (closed) return
|
|
1470
|
+
closed = true
|
|
1471
|
+
clearInterval(keepAlive)
|
|
1472
|
+
detach()
|
|
1473
|
+
untrack()
|
|
1474
|
+
try { res.end() } catch { /* 已经走了 */ }
|
|
1475
|
+
}
|
|
1476
|
+
const write = (payload) => {
|
|
1477
|
+
if (closed) return null
|
|
1478
|
+
const sent = writeSse(res, payload, { sealFrame, padding: ssePadding(SSE_PADDING_BYTES) })
|
|
1479
|
+
if (!sent.ok) { teardown(); return null }
|
|
1480
|
+
if (sent.needDrain) waitForDrain(res, new AbortController().signal).catch(() => {})
|
|
1481
|
+
return payload
|
|
1482
|
+
}
|
|
1483
|
+
const keepAlive = setInterval(() => {
|
|
1484
|
+
const sent = writeSse(res, { type: 'session/heartbeat', sessionId: session.id, at: Date.now() }, { sealFrame })
|
|
1485
|
+
if (!sent.ok) teardown()
|
|
1486
|
+
}, HEARTBEAT_MS)
|
|
1487
|
+
if (keepAlive && typeof keepAlive.unref === 'function') keepAlive.unref()
|
|
1488
|
+
res.on('close', teardown)
|
|
1489
|
+
res.on('error', teardown)
|
|
1490
|
+
detach = attach(session, write)
|
|
1491
|
+
}
|
|
1492
|
+
|
|
1493
|
+
function chooseOption(request, outcome) {
|
|
1494
|
+
const options = Array.isArray(request.options) ? request.options : []
|
|
1495
|
+
if (options.length === 0) return ''
|
|
1496
|
+
const wantAllow = outcome !== 'rejected'
|
|
1497
|
+
const matched = options.find((option) => {
|
|
1498
|
+
const kind = textOf(option.kind)
|
|
1499
|
+
return wantAllow ? kind.startsWith('allow') : kind.startsWith('reject')
|
|
1500
|
+
})
|
|
1501
|
+
return (matched || options[0]).optionId
|
|
1502
|
+
}
|
|
1503
|
+
|
|
1504
|
+
return {
|
|
1505
|
+
route,
|
|
1506
|
+
routeFlag,
|
|
1507
|
+
bumpList,
|
|
1508
|
+
wrapSessions,
|
|
1509
|
+
wrapClearArchived,
|
|
1510
|
+
wrapApprovalRespond,
|
|
1511
|
+
handleStream,
|
|
1512
|
+
monitorItems,
|
|
1513
|
+
summaryOf,
|
|
1514
|
+
itemsOf,
|
|
1515
|
+
modelsBodyOf,
|
|
1516
|
+
isCliSessionId,
|
|
1517
|
+
}
|
|
1518
|
+
}
|
|
1519
|
+
|
|
1520
|
+
module.exports = { createCliMobile, isCliSessionId, CLI_PREFIX, modelLabelOf }
|