dsh-mobile-plugin 0.1.96 → 0.1.98
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 +73 -50
- package/config/agent-presets/standard/agent.cordis.yml +13 -0
- package/lib/git/exec.js +129 -0
- package/lib/git/package.json +3 -0
- package/lib/git/parse.js +247 -0
- package/lib/git/read.js +617 -0
- package/lib/index.js +23 -29
- package/lib/mobile/archive-clean.js +31 -1
- package/lib/mobile/channels.js +583 -0
- package/lib/mobile/events.js +10 -8
- package/lib/mobile/git-repos.js +210 -0
- package/lib/mobile/git.js +603 -0
- package/lib/mobile/history-derive.js +79 -1
- package/lib/mobile/host-bridge/endpoints.js +3 -0
- package/lib/mobile/host-bridge/event-synthesis.js +109 -1
- package/lib/mobile/host-bridge/shims.js +9 -2
- package/lib/mobile/host-settings.js +41 -0
- package/lib/mobile/inbox-clean.js +337 -0
- package/lib/mobile/pairing.js +18 -1
- package/lib/mobile/questions.js +31 -9
- package/lib/mobile/schedule.js +440 -0
- package/lib/mobile/session-cache.js +18 -0
- package/lib/mobile/session-file.js +192 -0
- package/lib/mobile/session-model-options.js +184 -0
- package/lib/mobile/session-models.js +24 -2
- package/lib/mobile/session-schedule-mark.js +70 -0
- package/lib/mobile/session-snapshot.js +12 -1
- package/lib/mobile/sessions.js +8 -0
- package/lib/mobile/stats-collect.cjs +257 -0
- package/lib/mobile/stats-ledger.cjs +407 -0
- package/lib/mobile/stats.js +38 -0
- package/lib/mobile/stream.js +18 -10
- package/lib/mobile/uploads.js +24 -6
- package/lib/mobile/workspaces.js +30 -6
- package/lib/panel/index.html +4 -4
- package/lib/relay/lan-proxy.js +10 -1
- package/lib/routes/control-plane.js +38 -0
- package/lib/routes/mobile-api.js +70 -1
- package/lib/session-cleanup.cjs +8 -0
- package/lib/shared/sse-write.js +17 -6
- package/lib/shell/device-ota.js +52 -2
- package/lib/shell/manager.js +15 -1
- package/lib/shell/updater.js +153 -6
- package/lib/update-install.cjs +11 -0
- package/package.json +3 -2
|
@@ -268,6 +268,26 @@ function lastMessageOf(messages) {
|
|
|
268
268
|
return null;
|
|
269
269
|
}
|
|
270
270
|
|
|
271
|
+
/**
|
|
272
|
+
* 一份历史事件里所有**交付声明**过的路径(`deliverables/presented` 的 `files[].path`)。
|
|
273
|
+
*
|
|
274
|
+
* 与下面的按轮归并同一批事件、同一个形状兼容(`entry.event || entry`):交付文件取字节
|
|
275
|
+
* 那条路由(`session-file.js`)用它做白名单 —— 只有模型显式交出来的文件才允许手机上取。
|
|
276
|
+
* 单独一个函数而不是从消息里翻 `presentedFiles`:声明事件可能落在消息窗口之外,按轮挂在
|
|
277
|
+
* 消息上的那份不一定在。
|
|
278
|
+
*/
|
|
279
|
+
function presentedPathsOf(events) {
|
|
280
|
+
const paths = new Set();
|
|
281
|
+
for (const entry of events) {
|
|
282
|
+
const e = entry && (entry.event || entry);
|
|
283
|
+
if (!e || e.type !== 'deliverables/presented' || !e.data || !Array.isArray(e.data.files)) continue;
|
|
284
|
+
for (const raw of e.data.files) {
|
|
285
|
+
if (raw && typeof raw.path === 'string' && raw.path.trim() !== '') paths.add(raw.path);
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
return paths;
|
|
289
|
+
}
|
|
290
|
+
|
|
271
291
|
/**
|
|
272
292
|
* 从 history 事件折叠出消息与「结构化行」(wire v1 扩展,向后兼容):
|
|
273
293
|
* - 普通消息维持现状:{ id, seq, time, role, text, blocks?, images?, clientMessageId? }(user/assistant);
|
|
@@ -296,6 +316,31 @@ function deriveMessages(events) {
|
|
|
296
316
|
if (e.type !== 'compaction/summary' || !e.data || typeof e.data.compactionId !== 'string') continue;
|
|
297
317
|
summaries.set(e.data.compactionId, e.data);
|
|
298
318
|
}
|
|
319
|
+
// 交付声明预扫描(`deliverables/presented`):模型调 `present` 声明「这几份请你收下」时,
|
|
320
|
+
// 宿主的 dsh-tool-present 会往会话日志追一条这个事件。桌面那张「交付文件」卡只认它 ——
|
|
321
|
+
// **不是**「这一轮改过哪些文件」。手机第一版从 write/edit 反推,于是「每改一次都冒卡、
|
|
322
|
+
// 命令行改的却一张都没有」(2026-10-22 用户报的正是这个)。这里按 **turn** 归并,
|
|
323
|
+
// 同一个路径后声明的覆盖先声明的(桌面 `presentedForClosing` 的 `files.set` 同口径)。
|
|
324
|
+
const presentedByTurn = new Map();
|
|
325
|
+
for (const entry of events) {
|
|
326
|
+
const e = entry.event || entry;
|
|
327
|
+
if (e.type !== 'deliverables/presented') continue;
|
|
328
|
+
const data = e.data;
|
|
329
|
+
if (!data || typeof data !== 'object' || typeof data.turn !== 'number' || !Array.isArray(data.files)) continue;
|
|
330
|
+
const bucket = presentedByTurn.get(data.turn) || [];
|
|
331
|
+
for (const raw of data.files) {
|
|
332
|
+
if (!raw || typeof raw !== 'object' || typeof raw.path !== 'string' || raw.path.trim() === '') continue;
|
|
333
|
+
const file = {
|
|
334
|
+
path: raw.path,
|
|
335
|
+
...(typeof raw.description === 'string' && raw.description.trim() !== '' ? { description: raw.description } : {}),
|
|
336
|
+
};
|
|
337
|
+
const at = bucket.findIndex((item) => item.path === file.path);
|
|
338
|
+
if (at >= 0) bucket[at] = file;
|
|
339
|
+
else bucket.push(file);
|
|
340
|
+
}
|
|
341
|
+
if (bucket.length > 0) presentedByTurn.set(data.turn, bucket);
|
|
342
|
+
}
|
|
343
|
+
|
|
299
344
|
// 单步计时预扫描:为每条 assistant 消息派生「本轮」的 ttft / decode / 输出 token
|
|
300
345
|
// (对齐桌面 turn-metrics/assistant-timing:ttftMs = 首个非空 delta − step/start;
|
|
301
346
|
// decodeMs = 落定 − 首个 delta;outputTokens = assistant/message.usage.outputTokens)。
|
|
@@ -420,6 +465,13 @@ function deriveMessages(events) {
|
|
|
420
465
|
} catch { return null; }
|
|
421
466
|
}).filter((path) => typeof path === 'string');
|
|
422
467
|
if (producedFiles.length > 0) item.producedFiles = [...new Set(producedFiles)];
|
|
468
|
+
// 这一轮模型显式声明的交付文件:轮尾那张交付卡的唯一依据(与 producedFiles 分开,
|
|
469
|
+
// 后者只给收尾正文的行内引用解析用)。事件没有 turn 号的(老日志)不挂。
|
|
470
|
+
const turn = e.data && typeof e.data.turn === 'number' ? e.data.turn : undefined;
|
|
471
|
+
if (turn !== undefined) {
|
|
472
|
+
const presented = presentedByTurn.get(turn);
|
|
473
|
+
if (presented && presented.length > 0) item.presentedFiles = presented.map((file) => ({ ...file }));
|
|
474
|
+
}
|
|
423
475
|
// 单轮计时只挂 assistant 消息(user/message 无 step 计时)。
|
|
424
476
|
if (item.role !== 'user') {
|
|
425
477
|
const timing = timingOf(e);
|
|
@@ -483,6 +535,25 @@ function deriveMessages(events) {
|
|
|
483
535
|
});
|
|
484
536
|
continue;
|
|
485
537
|
}
|
|
538
|
+
if (src && typeof src === 'object' && src.kind === 'schedule') {
|
|
539
|
+
// 定时任务投递的提醒:桌面画成 `turn-trigger` 节点,手机这里落成一行 kind:'wake'
|
|
540
|
+
// (与 App 的 userMessageOrigin 同口径,见 app/src/utils/structuralItems.ts)。
|
|
541
|
+
// 以前把它并进下面那条「非 user 来源一律不下发」里丢掉,于是每一轮自动执行在重进
|
|
542
|
+
// 会话后一点痕迹都没有。正文是宿主拼的提醒框架,原样收下、不解析。
|
|
543
|
+
const wakeBlocks = parseBlocks(msg.content, toolResults);
|
|
544
|
+
const wakeText = blocksToText(wakeBlocks);
|
|
545
|
+
if (wakeText) {
|
|
546
|
+
out.push({
|
|
547
|
+
id: String(msg.id),
|
|
548
|
+
kind: 'wake',
|
|
549
|
+
wakeSource: 'schedule',
|
|
550
|
+
text: wakeText,
|
|
551
|
+
seq: e.seq,
|
|
552
|
+
time: e.time,
|
|
553
|
+
});
|
|
554
|
+
}
|
|
555
|
+
continue;
|
|
556
|
+
}
|
|
486
557
|
if (src && typeof src === 'object' && src.kind !== 'user') {
|
|
487
558
|
// 上下文注入/召回(session-reference→召回,其余 kind→注入):桌面 `isVisibleChatNode`
|
|
488
559
|
// 只放行携带 tool-addition/tool-removal 的上下文节点,这类提示一律不显示 —— 不下发。
|
|
@@ -634,7 +705,8 @@ function deriveMessages(events) {
|
|
|
634
705
|
out.push(done);
|
|
635
706
|
}
|
|
636
707
|
} else if (e.type === 'turn/end' && e.data && e.data.reason && e.data.turn != null) {
|
|
637
|
-
// turn/end 每轮一次,一条消息只对应一条行:error → turn-error;max-tokens → max-tokens
|
|
708
|
+
// turn/end 每轮一次,一条消息只对应一条行:error → turn-error;max-tokens → max-tokens;
|
|
709
|
+
// aborted → turn-aborted;其余 kind 忽略。
|
|
638
710
|
if (e.data.reason.kind === 'error') {
|
|
639
711
|
const err = e.data.reason.error;
|
|
640
712
|
if (!err || typeof err.message !== 'string') continue;
|
|
@@ -643,6 +715,11 @@ function deriveMessages(events) {
|
|
|
643
715
|
out.push(item);
|
|
644
716
|
} else if (e.data.reason.kind === 'max-tokens') {
|
|
645
717
|
out.push({ id: `turn-${e.data.turn}`, kind: 'max-tokens', seq, time });
|
|
718
|
+
} else if (e.data.reason.kind === 'aborted') {
|
|
719
|
+
// 用户点了停止(或被宿主打断)。这一行不带正文,只让 App 把那一轮标成「已停止」并摊开:
|
|
720
|
+
// 桌面口径是 aborted / error 不折叠,App 收不到这个信号就只能当成正常结束,把整轮收进
|
|
721
|
+
// 一行「已完成,用时 …」里 —— 跑了几十步、最后没有收尾答复的那一轮会整轮从屏幕上消失。
|
|
722
|
+
out.push({ id: `turn-${e.data.turn}`, kind: 'turn-aborted', seq, time });
|
|
646
723
|
}
|
|
647
724
|
}
|
|
648
725
|
// tool/result 等其余类型照旧忽略(工具结果在预扫描里配对回填;compaction 行由 start/end 发出)。
|
|
@@ -719,6 +796,7 @@ function clientMessageIdFromSource(source, message) {
|
|
|
719
796
|
|
|
720
797
|
module.exports = {
|
|
721
798
|
listTitle,
|
|
799
|
+
presentedPathsOf,
|
|
722
800
|
deriveMessages,
|
|
723
801
|
unwrap,
|
|
724
802
|
errorDetails,
|
|
@@ -66,6 +66,9 @@ const ENDPOINTS = {
|
|
|
66
66
|
// 宿主的模型目录是全局的(不再按 session 查),这里翻译成插件/移动端要的形状;
|
|
67
67
|
// payload 带 sessionId,供 modelCatalog 叠加会话自己的 modelSelection 投影。
|
|
68
68
|
'sessions.models': (ctx, payload, signal) => modelCatalog(ctx, payload, signal),
|
|
69
|
+
// 宿主级的模型目录(与 session 无关):同一个 modelCatalog,不带 sessionId 就不去读会话选择,
|
|
70
|
+
// 「AI commit 用哪个模型」这类选择器要的就是这一份(`llm/models` 那条移动路由)。
|
|
71
|
+
'llm.models': (ctx, payload, signal) => modelCatalog(ctx, {}, signal),
|
|
69
72
|
|
|
70
73
|
// agentPresets(namespace 是复数 agentPresets)
|
|
71
74
|
'agentPresets.list': inv('agentPresets', 'list', () => ({})),
|
|
@@ -52,6 +52,89 @@ async function* tolerate(frames, what, ctx) {
|
|
|
52
52
|
}
|
|
53
53
|
}
|
|
54
54
|
|
|
55
|
+
/** 正文 / 推理 / 工具参数的增量:同一块的连续 delta 在基线里可以合并成一帧。 */
|
|
56
|
+
const BASELINE_DELTA_TYPES = new Set(['text-delta', 'reasoning-delta', 'tool-call-delta']);
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* 展开 follow 首帧里那条「正在跑的那一步已生成的内容」。
|
|
60
|
+
*
|
|
61
|
+
* 宿主的 follow 第一帧是 `{type:'snapshot', cursor, assistantStream:{ revision, activeAttempt }}`,
|
|
62
|
+
* `activeAttempt.stream` 是一份**压缩过的**增量记录(连续同块 delta 被压成
|
|
63
|
+
* `{type:'text-chunks', index, time0, dt, texts}` 这类一条记录多个成员),
|
|
64
|
+
* `nextIndex` 是「下一帧实时增量应有的密排位置」,也就是这条前缀展开后应有的成员数。
|
|
65
|
+
* 中途进入会话的人只有这一份,宿主不会把已发过的实时帧补给他。
|
|
66
|
+
*
|
|
67
|
+
* 返回 `[{ time, chunk }]`;形状认不出就跳过那一条记录,绝不让整条流挂掉。
|
|
68
|
+
*/
|
|
69
|
+
function expandBaselineStream(stream, limit) {
|
|
70
|
+
const out = [];
|
|
71
|
+
if (!Array.isArray(stream)) return out;
|
|
72
|
+
const cap = Number.isSafeInteger(limit) && limit >= 0 ? limit : Number.POSITIVE_INFINITY;
|
|
73
|
+
for (const record of stream) {
|
|
74
|
+
if (out.length >= cap) break;
|
|
75
|
+
if (!record || typeof record !== 'object') continue;
|
|
76
|
+
if (record.type === 'chunk') {
|
|
77
|
+
if (record.chunk && typeof record.chunk === 'object' && typeof record.chunk.index === 'number') {
|
|
78
|
+
out.push({ time: Number.isSafeInteger(record.time) ? record.time : 0, chunk: record.chunk });
|
|
79
|
+
}
|
|
80
|
+
continue;
|
|
81
|
+
}
|
|
82
|
+
const members = record.type === 'tool-call-chunks' ? record.args : record.texts;
|
|
83
|
+
if (!Array.isArray(members) || typeof record.index !== 'number') continue;
|
|
84
|
+
const dt = Array.isArray(record.dt) ? record.dt : [];
|
|
85
|
+
let time = Number.isSafeInteger(record.time0) ? record.time0 : 0;
|
|
86
|
+
for (let i = 0; i < members.length && out.length < cap; i += 1) {
|
|
87
|
+
if (i > 0 && Number.isSafeInteger(dt[i - 1])) time += dt[i - 1];
|
|
88
|
+
if (record.type === 'text-chunks' && typeof members[i] === 'string') {
|
|
89
|
+
out.push({ time, chunk: { type: 'text-delta', index: record.index, text: members[i] } });
|
|
90
|
+
} else if (record.type === 'reasoning-chunks' && typeof members[i] === 'string') {
|
|
91
|
+
out.push({ time, chunk: { type: 'reasoning-delta', index: record.index, text: members[i] } });
|
|
92
|
+
} else if (record.type === 'tool-call-chunks') {
|
|
93
|
+
out.push({
|
|
94
|
+
time,
|
|
95
|
+
chunk: {
|
|
96
|
+
type: 'tool-call-delta',
|
|
97
|
+
index: record.index,
|
|
98
|
+
...(typeof record.id === 'string' ? { id: record.id } : {}),
|
|
99
|
+
...(typeof record.name === 'string' ? { name: record.name } : {}),
|
|
100
|
+
argumentsDelta: members[i],
|
|
101
|
+
},
|
|
102
|
+
});
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
return out;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* 同一块的连续增量合成一帧:一条 3k 字的回复展开后是几千帧,逐帧发给手机既占带宽
|
|
111
|
+
* 又会在 App 的守门缓冲里被挤掉。App 的草稿本来就是按块累积文本,合并后观感一致。
|
|
112
|
+
*/
|
|
113
|
+
function coalesceBaselineStream(members) {
|
|
114
|
+
const out = [];
|
|
115
|
+
for (const member of members) {
|
|
116
|
+
const chunk = member && member.chunk;
|
|
117
|
+
if (!chunk || typeof chunk !== 'object') continue;
|
|
118
|
+
const last = out[out.length - 1];
|
|
119
|
+
const previous = last && last.chunk;
|
|
120
|
+
if (
|
|
121
|
+
previous && BASELINE_DELTA_TYPES.has(chunk.type) &&
|
|
122
|
+
previous.type === chunk.type && previous.index === chunk.index
|
|
123
|
+
) {
|
|
124
|
+
if (typeof chunk.text === 'string' && typeof previous.text === 'string') {
|
|
125
|
+
last.chunk = { ...previous, text: previous.text + chunk.text };
|
|
126
|
+
continue;
|
|
127
|
+
}
|
|
128
|
+
if (typeof chunk.argumentsDelta === 'string' && typeof previous.argumentsDelta === 'string') {
|
|
129
|
+
last.chunk = { ...previous, argumentsDelta: previous.argumentsDelta + chunk.argumentsDelta };
|
|
130
|
+
continue;
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
out.push({ time: member.time, chunk });
|
|
134
|
+
}
|
|
135
|
+
return out;
|
|
136
|
+
}
|
|
137
|
+
|
|
55
138
|
/**
|
|
56
139
|
* follow 流的一帧 → 0..n 个 mux 帧(事件原样转发;实时增量通知合成 assistant/chunk)。
|
|
57
140
|
*
|
|
@@ -66,10 +149,35 @@ async function* tolerate(frames, what, ctx) {
|
|
|
66
149
|
* (`lib/mobile/events.js` 的 LIST_EVENT_INNER),合成增量到不了那条通道,不会形成推送风暴。
|
|
67
150
|
*
|
|
68
151
|
* `state` 是这条流的记账({ turn, step, streaming }),由调用方持有 —— 一帧的处理要看
|
|
69
|
-
*
|
|
152
|
+
* 「之前收到过什么」。历史正文走 session.history,但首帧 snapshot 里那份
|
|
153
|
+
* `assistantStream.activeAttempt` 不能丢:中途进入会话的人拿不到开跑之前的实时增量,
|
|
154
|
+
* 那一段只存在于这份基线里(见 expandBaselineStream)。
|
|
70
155
|
*/
|
|
71
156
|
function* followFramePayloads(frame, sessionId, state) {
|
|
72
157
|
if (!frame) return;
|
|
158
|
+
if (frame.type === 'snapshot') {
|
|
159
|
+
const opening = frame.assistantStream && frame.assistantStream.activeAttempt;
|
|
160
|
+
if (!opening || !Number.isSafeInteger(opening.turn) || !Number.isSafeInteger(opening.step)) return;
|
|
161
|
+
// 这条流后面收到的实时增量要靠这两个值带上作用域(App 按「本轮·本步」分家草稿),
|
|
162
|
+
// 中途进来的客户端收不到 start 通知,基线是唯一来源。
|
|
163
|
+
state.turn = opening.turn;
|
|
164
|
+
state.step = opening.step;
|
|
165
|
+
state.streaming = true;
|
|
166
|
+
for (const member of coalesceBaselineStream(expandBaselineStream(opening.stream, opening.nextIndex))) {
|
|
167
|
+
yield {
|
|
168
|
+
payload: {
|
|
169
|
+
type: 'session/event',
|
|
170
|
+
sessionId,
|
|
171
|
+
event: {
|
|
172
|
+
type: 'assistant/chunk',
|
|
173
|
+
time: Number.isSafeInteger(member.time) ? member.time : Date.now(),
|
|
174
|
+
data: { turn: state.turn, step: state.step, chunk: member.chunk },
|
|
175
|
+
},
|
|
176
|
+
},
|
|
177
|
+
};
|
|
178
|
+
}
|
|
179
|
+
return;
|
|
180
|
+
}
|
|
73
181
|
if (frame.type === 'event') {
|
|
74
182
|
if (!frame.event) return;
|
|
75
183
|
// 增量只交付一次:宿主若同时把 chunk 也当成事件发,以通知为准(通知先到)。
|
|
@@ -85,6 +85,11 @@ async function historyViaPage(ctx, payload, signal) {
|
|
|
85
85
|
*
|
|
86
86
|
* 读不到投影(会话不存在 / 宿主没挂投影能力)时不升级成错误:退回全局默认即可,
|
|
87
87
|
* 让模型目录照常下发,宁可少一次会话级精修也不让整个模型面板消失。
|
|
88
|
+
*
|
|
89
|
+
* `defaultModel` 单独回一份**全局默认**,与 `current` 分开:宿主的新会话读的就是
|
|
90
|
+
* 「最近一次成功保存的默认值」(`session.create` 不带模型时落这一条),所以 App 要
|
|
91
|
+
* 显示「新对话会用哪个模型」只能看它 —— 拿任何一个会话的 `current` 去顶都是猜,
|
|
92
|
+
* 会话各自选过的模型未必是默认。旧 App 忽略这个字段,行为不变。
|
|
88
93
|
*/
|
|
89
94
|
async function modelCatalog(ctx, payload, signal) {
|
|
90
95
|
const sessionId = payload && typeof payload.sessionId === 'string' ? payload.sessionId : '';
|
|
@@ -94,7 +99,7 @@ async function modelCatalog(ctx, payload, signal) {
|
|
|
94
99
|
]);
|
|
95
100
|
if (result.error) return fail(result.error);
|
|
96
101
|
const catalog = result.value && typeof result.value === 'object' ? result.value : {};
|
|
97
|
-
const
|
|
102
|
+
const globalDefault = catalog.default && typeof catalog.default === 'object'
|
|
98
103
|
? pick(catalog.default, ['provider', 'model', 'reasoningEffort'])
|
|
99
104
|
: null;
|
|
100
105
|
// 会话自己选过模型时,用它覆盖全局默认;读不到或没选过才保留全局默认。
|
|
@@ -108,10 +113,12 @@ async function modelCatalog(ctx, payload, signal) {
|
|
|
108
113
|
? pick(sessionModel.lastUsed, ['provider', 'model', 'reasoningEffort'])
|
|
109
114
|
: null)
|
|
110
115
|
: null;
|
|
111
|
-
const effective = sessionSelection && sessionSelection.provider && sessionSelection.model ? sessionSelection :
|
|
116
|
+
const effective = sessionSelection && sessionSelection.provider && sessionSelection.model ? sessionSelection : globalDefault;
|
|
112
117
|
const routableProviders = Array.isArray(catalog.routableProviders) ? catalog.routableProviders : [];
|
|
118
|
+
const defaultModel = globalDefault && globalDefault.provider && globalDefault.model ? globalDefault : null;
|
|
113
119
|
return ok({
|
|
114
120
|
...(effective ? { current: effective } : {}),
|
|
121
|
+
...(defaultModel ? { defaultModel } : {}),
|
|
115
122
|
routable: Boolean(effective && routableProviders.includes(effective.provider)),
|
|
116
123
|
routableProviders,
|
|
117
124
|
groups: Array.isArray(catalog.groups) ? catalog.groups : [],
|
|
@@ -22,6 +22,7 @@ const { sendJson, readJsonBody } = require('../shared/http-util');
|
|
|
22
22
|
const { unwrap } = require('./sessions.js');
|
|
23
23
|
|
|
24
24
|
const HOST_SETTINGS_MAX_NAMESPACES = 60; // 命名空间数量上限(避免异常宿主把响应撑爆)
|
|
25
|
+
const HOST_SETTINGS_MAX_MODELS_PER_GROUP = 200; // 单个提供方下的模型条数上限
|
|
25
26
|
const HOST_SETTINGS_VALUE_DEPTH = 4; // 值递归深度上限
|
|
26
27
|
const HOST_SETTINGS_VALUE_KEYS = 40; // 每层键/元素数量上限
|
|
27
28
|
const HOST_SETTINGS_VALUE_CHARS = 4000; // 单个字符串长度上限
|
|
@@ -190,6 +191,45 @@ async function handleLlmProviders(ctx, req, res) {
|
|
|
190
191
|
sendJson(res, 200, { ok: true, providers });
|
|
191
192
|
}
|
|
192
193
|
|
|
194
|
+
/** 把 llm.models 的一组渠道压成手机选择器要的形状;没有模型的组丢掉。 */
|
|
195
|
+
function normalizeModelGroup(group) {
|
|
196
|
+
if (!group || typeof group !== 'object') return null;
|
|
197
|
+
const provider = typeof group.id === 'string' ? group.id.trim() : '';
|
|
198
|
+
if (!provider) return null;
|
|
199
|
+
const displayName = typeof group.name === 'string' && group.name.trim() ? group.name.trim() : provider;
|
|
200
|
+
const models = (Array.isArray(group.models) ? group.models : [])
|
|
201
|
+
.map((item) => {
|
|
202
|
+
if (!item || typeof item !== 'object') return null;
|
|
203
|
+
const id = typeof item.id === 'string' ? item.id.trim() : '';
|
|
204
|
+
if (!id) return null;
|
|
205
|
+
const name = typeof item.name === 'string' && item.name.trim() ? item.name.trim() : id;
|
|
206
|
+
return { id, name };
|
|
207
|
+
})
|
|
208
|
+
.filter(Boolean)
|
|
209
|
+
.slice(0, HOST_SETTINGS_MAX_MODELS_PER_GROUP);
|
|
210
|
+
if (models.length === 0) return null;
|
|
211
|
+
return { provider, displayName, models };
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
/**
|
|
215
|
+
* GET /api/mobile/v1/llm/models —— 宿主的模型目录(`llm.models`,与 session 无关)。
|
|
216
|
+
*
|
|
217
|
+
* 与 `/llm/providers` 的区别:那条只说「有哪些提供方 / 哪个在生效」,这条给出每个提供方
|
|
218
|
+
* 下面真正可请求的模型 id。手机上没有活动会话时(比如从 Git 页打开选择器)也能拿到。
|
|
219
|
+
*/
|
|
220
|
+
async function handleLlmModels(ctx, req, res) {
|
|
221
|
+
const { value, error } = unwrap(await ctx.apiProxy.llm.models({
|
|
222
|
+
rpcId: 'mobile',
|
|
223
|
+
method: 'llm.models',
|
|
224
|
+
payload: {},
|
|
225
|
+
}));
|
|
226
|
+
if (error) return sendJson(res, 400, { ok: false, error });
|
|
227
|
+
|
|
228
|
+
const list = Array.isArray(value && value.groups) ? value.groups : [];
|
|
229
|
+
const groups = list.map(normalizeModelGroup).filter(Boolean).slice(0, HOST_SETTINGS_MAX_NAMESPACES);
|
|
230
|
+
sendJson(res, 200, { ok: true, groups });
|
|
231
|
+
}
|
|
232
|
+
|
|
193
233
|
const HOST_SETTINGS_MAX_OPS = 40; // 一次写入的 op 数量上限
|
|
194
234
|
const HOST_SETTINGS_MAX_PATH = 8; // 单个路径的层数上限
|
|
195
235
|
|
|
@@ -274,6 +314,7 @@ module.exports = {
|
|
|
274
314
|
handleHostSettings,
|
|
275
315
|
handleHostSettingsMutate,
|
|
276
316
|
handleLlmProviders,
|
|
317
|
+
handleLlmModels,
|
|
277
318
|
boundValue,
|
|
278
319
|
credentialRefOf,
|
|
279
320
|
normalizeNamespace,
|