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
|
@@ -0,0 +1,583 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* 移动端「渠道管理」API:列出这台宿主上的渠道,并增删「本机自建」那一类。
|
|
5
|
+
*
|
|
6
|
+
* ## 两类渠道,只有一类能改
|
|
7
|
+
*
|
|
8
|
+
* 手机上的模型目录(`/session/models`、`/llm/models`)把两种来源混在一起:
|
|
9
|
+
* - **插件 / 宿主注册的提供方**:账号登录、渠道开关都由它自己的插件说了算
|
|
10
|
+
* (免费模型插件那十几家、官方 DeepSeek 等)。移动端**一个写操作都不做**——
|
|
11
|
+
* 那不是我们的渠道,改它就是越界。App 那边只保留「本机显示 / 不显示」的开关。
|
|
12
|
+
* - **本机自建渠道**:`llm-pi-ai` 设置命名空间里 `providers` 下的每一条路由
|
|
13
|
+
* (协议 + 地址 + 一把 Key + 模型清单)。桌面端免费模型面板的「添加渠道」写的
|
|
14
|
+
* 就是这一层,所以手机照同一份口径做:`settings.mutate` 按路径 set / unset,
|
|
15
|
+
* 凭据走 `credentials.set` / `unset`。
|
|
16
|
+
*
|
|
17
|
+
* 判据不是「谁建的」,而是**这条 provider 在不在 `llm-pi-ai` 的 providers 里**:
|
|
18
|
+
* 在 = 本机配置的、可增删;不在 = 别的插件提供的、只读。所以用户手写在
|
|
19
|
+
* `cordis.patch.yml` 里的 pi-ai 路由也会被列成可删——那是他自己机器上的配置,
|
|
20
|
+
* App 在删除前会二次确认并写出渠道名与地址。
|
|
21
|
+
*
|
|
22
|
+
* ## 为什么不用「免费模型插件的元数据」
|
|
23
|
+
*
|
|
24
|
+
* 桌面面板另存了一份 `$DSH_HOME/our-free-model/custom.json`(描述、介绍、关掉的模型)。
|
|
25
|
+
* 手机不读也不写:那是另一个插件的私有状态,两个进程抢同一个文件只会写出半截。
|
|
26
|
+
* 代价是手机上删掉的渠道,桌面面板的「自建渠道」列表里会显示成「路由缺失」;反过来
|
|
27
|
+
* 手机上建的渠道,桌面面板不会把它当成自己建的。两边都不影响模型选择器——真源是
|
|
28
|
+
* `llm-pi-ai` 的路由表。
|
|
29
|
+
*
|
|
30
|
+
* ## 获取模型为什么在宿主侧发
|
|
31
|
+
*
|
|
32
|
+
* 渠道端点常常在内网 / 只有这台电脑能直连(本机网关、局域网模型服务)。手机直接发
|
|
33
|
+
* 请求既可能不通,也绕过了宿主自己的协议适配。这里借宿主 `llm.discoverModels`
|
|
34
|
+
* (桌面面板同一条路)在电脑上问一次,只回模型 id / 名字,不落盘。
|
|
35
|
+
*/
|
|
36
|
+
|
|
37
|
+
const { sendJson, readJsonBody } = require('../shared/http-util');
|
|
38
|
+
const { unwrap } = require('./envelope.js');
|
|
39
|
+
const { dropSessionModelsForDevice } = require('./session-cache.js');
|
|
40
|
+
|
|
41
|
+
/** 自建渠道写在这个命名空间里;桌面端 `src/custom-channels.js` 的 PI_AI_NS 同值。 */
|
|
42
|
+
const PI_AI_NS = 'llm-pi-ai';
|
|
43
|
+
|
|
44
|
+
const PROTOCOLS = ['openai-completions', 'openai-responses', 'anthropic-messages'];
|
|
45
|
+
|
|
46
|
+
/** 与 pi-ai 的缺省值同值:上游没报的时候,落到这里就是协议层本来会给的那个数。 */
|
|
47
|
+
const DEFAULT_CONTEXT_WINDOW = 262144;
|
|
48
|
+
const DEFAULT_MAX_TOKENS = 32768;
|
|
49
|
+
|
|
50
|
+
/** 新模型默认四档全开:空清单在 pi-ai 那边是「明确声明不思考」。 */
|
|
51
|
+
const DEFAULT_REASONING = ['low', 'medium', 'high', 'max'];
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* 内置渠道的 provider id(与免费模型插件的 `CHANNEL_PROVIDER_IDS` 同一份名单)。
|
|
55
|
+
*
|
|
56
|
+
* 只用来**占位**:新渠道的 id 撞上已注册的 provider 就会把那条静默顶掉。名单少一个
|
|
57
|
+
* 不报错,只是那个 id 有被占的风险;插件没装时这些 id 本来也不存在,仍一并绕开,
|
|
58
|
+
* 免得日后装上插件才撞。
|
|
59
|
+
*/
|
|
60
|
+
const RESERVED_PROVIDER_IDS = new Set([
|
|
61
|
+
'buddy', 'workbuddy', 'lobsterai', 'qoder', 'qodercn', 'trae',
|
|
62
|
+
'cline', 'loomy', 'raccoon', 'minimax', 'zcode', 'gemini', 'antigravity',
|
|
63
|
+
]);
|
|
64
|
+
|
|
65
|
+
const MAX_CHANNEL_NAME_CHARS = 60;
|
|
66
|
+
/** 渠道 id 的形状:小写字母数字起头、只收连字符(与 `providerIdOf` 的产出同一口径)。 */
|
|
67
|
+
const CHANNEL_ID_PATTERN = /^[a-z0-9][a-z0-9-]{0,63}$/;
|
|
68
|
+
const MAX_MODEL_ID_CHARS = 200;
|
|
69
|
+
const MAX_MODELS = 200;
|
|
70
|
+
const MAX_BASE_URL_CHARS = 500;
|
|
71
|
+
const MAX_KEY_CHARS = 8192;
|
|
72
|
+
|
|
73
|
+
/** `apiKeyEnv` 的引用名,与桌面端 `credentialRefOf` 逐字一致(同一把 Key 两边都认)。 */
|
|
74
|
+
function credentialRefOf(providerId) {
|
|
75
|
+
return `OFM_${providerId.toUpperCase().replace(/[^A-Z0-9]+/g, '_')}_KEY`;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** 渠道名 → 路由 id:小写、连字符、只留 ASCII 字母数字;撞上已占用的就加序号。 */
|
|
79
|
+
function providerIdOf(name, taken) {
|
|
80
|
+
const ascii = String(name == null ? '' : name)
|
|
81
|
+
.toLowerCase()
|
|
82
|
+
.replace(/[^a-z0-9]+/g, '-')
|
|
83
|
+
.replace(/^-+|-+$/g, '');
|
|
84
|
+
const base = ascii === '' || /^[0-9]/.test(ascii) ? `route-${ascii}`.replace(/-+$/, '') : ascii;
|
|
85
|
+
const seed = base === '' || base === 'route-' ? 'route' : base;
|
|
86
|
+
if (!taken.has(seed)) return seed;
|
|
87
|
+
for (let index = 2; index < 1000; index += 1) {
|
|
88
|
+
const candidate = `${seed}-${index}`;
|
|
89
|
+
if (!taken.has(candidate)) return candidate;
|
|
90
|
+
}
|
|
91
|
+
return `${seed}-${Date.now()}`;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* 只收「协议 + 域名(+ 端口,可带路径)」,末尾斜杠去掉。
|
|
96
|
+
*
|
|
97
|
+
* 与桌面端同一口径:各家 baseURL 约定不统一(有的要把 `/v1` 算进地址),路径留给
|
|
98
|
+
* 协议层或下面的 `/v1` 回退去拼,不在这里猜。
|
|
99
|
+
*/
|
|
100
|
+
function normalizeBaseUrl(raw) {
|
|
101
|
+
const text = typeof raw === 'string' ? raw.trim() : '';
|
|
102
|
+
if (text === '') return { error: '渠道地址不能为空' };
|
|
103
|
+
if (text.length > MAX_BASE_URL_CHARS) return { error: '渠道地址太长' };
|
|
104
|
+
let parsed;
|
|
105
|
+
try {
|
|
106
|
+
parsed = new URL(text);
|
|
107
|
+
} catch {
|
|
108
|
+
return { error: '地址格式不对,需要 http:// 或 https:// 开头' };
|
|
109
|
+
}
|
|
110
|
+
if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') {
|
|
111
|
+
return { error: '只支持 http / https 地址' };
|
|
112
|
+
}
|
|
113
|
+
if (!parsed.hostname) return { error: '地址里没有主机名' };
|
|
114
|
+
const pathname = parsed.pathname.replace(/\/+$/, '');
|
|
115
|
+
return { url: `${parsed.origin}${pathname === '/' ? '' : pathname}` };
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/** 模型清单归一:必须至少一条,id 非空且不重复;能力字段缺省按上面那两个常量。 */
|
|
119
|
+
function normalizeModels(raw) {
|
|
120
|
+
if (!Array.isArray(raw) || raw.length === 0) return { error: '至少要有 1 个模型' };
|
|
121
|
+
if (raw.length > MAX_MODELS) return { error: `模型最多 ${MAX_MODELS} 个` };
|
|
122
|
+
const seen = new Set();
|
|
123
|
+
const models = [];
|
|
124
|
+
for (const row of raw) {
|
|
125
|
+
if (!row || typeof row !== 'object') return { error: '模型清单里有畸形条目' };
|
|
126
|
+
const id = typeof row.id === 'string' ? row.id.trim() : '';
|
|
127
|
+
if (id === '') return { error: '有模型没有填 id' };
|
|
128
|
+
if (id.length > MAX_MODEL_ID_CHARS) return { error: `模型 id 太长:${id.slice(0, 40)}…` };
|
|
129
|
+
if (seen.has(id)) return { error: `模型 id 重复:${id}` };
|
|
130
|
+
seen.add(id);
|
|
131
|
+
const contextWindow = Number(row.contextWindow);
|
|
132
|
+
const maxTokens = Number(row.maxTokens);
|
|
133
|
+
models.push({
|
|
134
|
+
id,
|
|
135
|
+
name: typeof row.name === 'string' && row.name.trim() ? row.name.trim().slice(0, MAX_MODEL_ID_CHARS) : id,
|
|
136
|
+
// 这里**不落缺省值**:留空才分得出「表单没报」与「表单报了一个值」,
|
|
137
|
+
// 编辑时前者要沿用原路由的配置,后者才覆盖。缺省由 profileOf 最后补。
|
|
138
|
+
...(Number.isFinite(contextWindow) && contextWindow > 0 ? { contextWindow: Math.trunc(contextWindow) } : {}),
|
|
139
|
+
...(Number.isFinite(maxTokens) && maxTokens > 0 ? { maxTokens: Math.trunc(maxTokens) } : {}),
|
|
140
|
+
...(row.vision === true ? { vision: true } : {}),
|
|
141
|
+
});
|
|
142
|
+
}
|
|
143
|
+
return { models };
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* 一条渠道 → pi-ai 的 provider profile。
|
|
148
|
+
*
|
|
149
|
+
* 只写「必须显式说」的字段,与桌面端 `profileOf` 一致:自建路由没有内置目录,协议必须
|
|
150
|
+
* 点名;思考档位不写就是「不思考」,所以这里给默认四档而不是省略。
|
|
151
|
+
*
|
|
152
|
+
* **编辑时按已有那一条做基底**(`existing`):手机上的表单只有名称 / 协议 / 地址 /
|
|
153
|
+
* 模型 id,能力字段(上下文、输出上限、图片、思考档位)根本不在界面上。直接按缺省值
|
|
154
|
+
* 重写,等于用户改一次名字就把桌面面板里精细调过的参数全冲掉。所以:
|
|
155
|
+
* - 同一模型仍在清单里 → 沿用它的能力字段;
|
|
156
|
+
* - 新加的模型 → 表单报了就用表单的,没报才落缺省;
|
|
157
|
+
* - 其它不认识的字段(用户手写的)原样带回去。
|
|
158
|
+
*/
|
|
159
|
+
function profileOf(channel, existing) {
|
|
160
|
+
const previous = new Map();
|
|
161
|
+
for (const model of Array.isArray(existing && existing.models) ? existing.models : []) {
|
|
162
|
+
if (model && typeof model === 'object' && typeof model.id === 'string' && model.id) previous.set(model.id, model);
|
|
163
|
+
}
|
|
164
|
+
const base = existing && typeof existing === 'object' && !Array.isArray(existing) ? existing : {};
|
|
165
|
+
return {
|
|
166
|
+
...base,
|
|
167
|
+
displayName: channel.name,
|
|
168
|
+
api: channel.protocol,
|
|
169
|
+
baseURL: channel.baseUrl,
|
|
170
|
+
apiKeyEnv: credentialRefOf(channel.id),
|
|
171
|
+
models: channel.models.map((model) => {
|
|
172
|
+
const before = previous.get(model.id);
|
|
173
|
+
const contextWindow = Number(model.contextWindow) > 0
|
|
174
|
+
? Math.trunc(model.contextWindow)
|
|
175
|
+
: (Number(before && before.contextWindow) > 0 ? Math.trunc(before.contextWindow) : DEFAULT_CONTEXT_WINDOW);
|
|
176
|
+
const maxTokens = Number(model.maxTokens) > 0
|
|
177
|
+
? Math.trunc(model.maxTokens)
|
|
178
|
+
: (Number(before && before.maxTokens) > 0 ? Math.trunc(before.maxTokens) : DEFAULT_MAX_TOKENS);
|
|
179
|
+
return {
|
|
180
|
+
id: model.id,
|
|
181
|
+
// 表单没带名字时 normalizeModels 会拿 id 顶上;那条路由原来有显示名就沿用,
|
|
182
|
+
// 别因为一次「只改了地址」把桌面那边起好的名字冲成 id。
|
|
183
|
+
name: model.name && model.name !== model.id
|
|
184
|
+
? model.name
|
|
185
|
+
: (before && typeof before.name === 'string' && before.name.trim() ? before.name : model.name),
|
|
186
|
+
contextWindow,
|
|
187
|
+
maxTokens,
|
|
188
|
+
input: model.vision === true
|
|
189
|
+
? ['text', 'image']
|
|
190
|
+
: (Array.isArray(before && before.input) ? before.input : ['text']),
|
|
191
|
+
// 保留 `false`(「明确不思考」)也是有效值,不能当缺省吞掉。
|
|
192
|
+
reasoningEfforts: before && before.reasoningEfforts !== undefined
|
|
193
|
+
? before.reasoningEfforts
|
|
194
|
+
: Object.fromEntries(DEFAULT_REASONING.map((level) => [level, level])),
|
|
195
|
+
};
|
|
196
|
+
}),
|
|
197
|
+
};
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
/** 从 `settings.describe` 的 `llm-pi-ai` 视图里取 providers 字典;形状不对就当空表。 */
|
|
201
|
+
function piAiProvidersOf(value) {
|
|
202
|
+
const providers = value && typeof value === 'object' && !Array.isArray(value) ? value.providers : null;
|
|
203
|
+
return providers && typeof providers === 'object' && !Array.isArray(providers) ? providers : {};
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
/** 一条 pi-ai 路由 → 手机要的形状;畸形条目丢掉。 */
|
|
207
|
+
function customChannelOf(id, profile) {
|
|
208
|
+
if (typeof id !== 'string' || id === '') return null;
|
|
209
|
+
if (!profile || typeof profile !== 'object' || Array.isArray(profile)) return null;
|
|
210
|
+
const models = (Array.isArray(profile.models) ? profile.models : [])
|
|
211
|
+
.map((model) => {
|
|
212
|
+
if (!model || typeof model !== 'object') return null;
|
|
213
|
+
const modelId = typeof model.id === 'string' ? model.id.trim() : '';
|
|
214
|
+
if (!modelId) return null;
|
|
215
|
+
return { id: modelId, name: typeof model.name === 'string' && model.name.trim() ? model.name.trim() : modelId };
|
|
216
|
+
})
|
|
217
|
+
.filter(Boolean);
|
|
218
|
+
return {
|
|
219
|
+
id,
|
|
220
|
+
name: typeof profile.displayName === 'string' && profile.displayName.trim() ? profile.displayName.trim() : id,
|
|
221
|
+
protocol: typeof profile.api === 'string' ? profile.api : '',
|
|
222
|
+
baseUrl: typeof profile.baseURL === 'string' ? profile.baseURL : '',
|
|
223
|
+
models,
|
|
224
|
+
};
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
/**
|
|
228
|
+
* GET /api/mobile/v1/channels —— 这台宿主的渠道清单(自建 + 插件提供)。
|
|
229
|
+
*
|
|
230
|
+
* 三处宿主读数各管一段:`settings.describe` 给自建路由,`llm.providers` 给「哪些 provider
|
|
231
|
+
* 真的存在」,`llm.models` 给每个 provider 下面的模型。合并顺序是「先外部、后自建」——
|
|
232
|
+
* 自建的是用户刚动手的那几条,放后面不容易被一长串插件渠道挤到视口之外。
|
|
233
|
+
*/
|
|
234
|
+
async function handleChannels(ctx, req, res) {
|
|
235
|
+
const settings = unwrap(await ctx.apiProxy.settings.describe({
|
|
236
|
+
rpcId: 'mobile',
|
|
237
|
+
method: 'settings.describe',
|
|
238
|
+
payload: {},
|
|
239
|
+
}));
|
|
240
|
+
if (settings.error) return sendJson(res, 400, { ok: false, error: settings.error });
|
|
241
|
+
const namespaces = Array.isArray(settings.value && settings.value.namespaces) ? settings.value.namespaces : [];
|
|
242
|
+
const piAi = namespaces.find((entry) => entry && entry.ns === PI_AI_NS);
|
|
243
|
+
const providers = piAiProvidersOf(piAi && piAi.value);
|
|
244
|
+
|
|
245
|
+
const listed = unwrap(await ctx.apiProxy.llm.providers({
|
|
246
|
+
rpcId: 'mobile',
|
|
247
|
+
method: 'llm.providers',
|
|
248
|
+
payload: {},
|
|
249
|
+
}));
|
|
250
|
+
const catalog = unwrap(await ctx.apiProxy.llm.models({
|
|
251
|
+
rpcId: 'mobile',
|
|
252
|
+
method: 'llm.models',
|
|
253
|
+
payload: {},
|
|
254
|
+
}));
|
|
255
|
+
const registered = listed.error ? [] : (Array.isArray(listed.value && listed.value.providers) ? listed.value.providers : []);
|
|
256
|
+
const groups = catalog.error ? [] : (Array.isArray(catalog.value && catalog.value.groups) ? catalog.value.groups : []);
|
|
257
|
+
|
|
258
|
+
const modelsOf = new Map();
|
|
259
|
+
for (const group of groups) {
|
|
260
|
+
if (!group || typeof group.id !== 'string') continue;
|
|
261
|
+
const models = (Array.isArray(group.models) ? group.models : [])
|
|
262
|
+
.map((model) => (model && typeof model.id === 'string' && model.id !== ''
|
|
263
|
+
? { id: model.id, name: typeof model.name === 'string' && model.name.trim() ? model.name.trim() : model.id }
|
|
264
|
+
: null))
|
|
265
|
+
.filter(Boolean);
|
|
266
|
+
modelsOf.set(group.id, models);
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
const customIds = Object.keys(providers);
|
|
270
|
+
const refs = customIds.map(credentialRefOf);
|
|
271
|
+
let keyState = {};
|
|
272
|
+
if (refs.length > 0) {
|
|
273
|
+
const described = unwrap(await ctx.apiProxy.credentials.describe({
|
|
274
|
+
rpcId: 'mobile',
|
|
275
|
+
method: 'credentials.describe',
|
|
276
|
+
payload: { refs },
|
|
277
|
+
}));
|
|
278
|
+
if (!described.error && described.value && typeof described.value === 'object') keyState = described.value;
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
const channels = [];
|
|
282
|
+
for (const item of registered) {
|
|
283
|
+
if (!item || typeof item.provider !== 'string' || item.provider === '') continue;
|
|
284
|
+
// 自建路由同样会出现在 llm.providers 里,跳过——下面会以「自建」身份列一次。
|
|
285
|
+
if (Object.prototype.hasOwnProperty.call(providers, item.provider)) continue;
|
|
286
|
+
channels.push({
|
|
287
|
+
id: item.provider,
|
|
288
|
+
source: 'external',
|
|
289
|
+
name: typeof item.displayName === 'string' && item.displayName.trim() ? item.displayName.trim() : item.provider,
|
|
290
|
+
active: item.active === true,
|
|
291
|
+
models: modelsOf.get(item.provider) || [],
|
|
292
|
+
});
|
|
293
|
+
}
|
|
294
|
+
for (const id of customIds) {
|
|
295
|
+
const channel = customChannelOf(id, providers[id]);
|
|
296
|
+
if (!channel) continue;
|
|
297
|
+
const view = keyState[credentialRefOf(id)];
|
|
298
|
+
channels.push({
|
|
299
|
+
...channel,
|
|
300
|
+
source: 'custom',
|
|
301
|
+
// 凭据那一发失败时不给 keySet:界面按「未知」处理,比谎报「未设置」好。
|
|
302
|
+
...(view ? { keySet: view.configured === true, writable: view.writable !== false } : {}),
|
|
303
|
+
});
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
sendJson(res, 200, { ok: true, channels });
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
/**
|
|
310
|
+
* 「获取模型」要依次试哪几个 baseURL。
|
|
311
|
+
*
|
|
312
|
+
* OpenAI 系把 `/v1` 算进 baseURL,而表单只收「协议 + 域名」:先照填的问一次,不成就补
|
|
313
|
+
* `/v1`。用户自己写了路径就只问那一个;Anthropic 协议自己会拼 `/v1/models`,不参与回退。
|
|
314
|
+
* 与桌面端 `discoverCandidates` 同一份规则。
|
|
315
|
+
*/
|
|
316
|
+
function discoverCandidates(baseUrl, protocol) {
|
|
317
|
+
if (protocol === 'anthropic-messages') return [baseUrl];
|
|
318
|
+
let pathname = '/';
|
|
319
|
+
try {
|
|
320
|
+
pathname = new URL(baseUrl).pathname;
|
|
321
|
+
} catch {
|
|
322
|
+
return [baseUrl];
|
|
323
|
+
}
|
|
324
|
+
return pathname === '/' ? [baseUrl, `${baseUrl}/v1`] : [baseUrl];
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
/** 宿主 `llm` 服务可能没装(老运行时):取不到就走 503,别让整条路由挂掉。 */
|
|
328
|
+
function lookupService(ctx, name) {
|
|
329
|
+
try {
|
|
330
|
+
return typeof ctx.get === 'function' ? ctx.get(name) : undefined;
|
|
331
|
+
} catch {
|
|
332
|
+
return undefined;
|
|
333
|
+
}
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
/**
|
|
337
|
+
* POST /api/mobile/v1/channels/discover(token){ protocol, baseUrl, apiKey? }
|
|
338
|
+
* → { ok, models: [{id,name,contextWindow?,maxTokens?,vision?}], baseUrl }
|
|
339
|
+
*
|
|
340
|
+
* `baseUrl` 回的是**问通的那个**:`/v1` 回退成功时保存要写它,否则聊天请求还会打到
|
|
341
|
+
* 刚才失败的那个地址上。只读不落盘,用户确认清单之后再调 `/channels/save`。
|
|
342
|
+
*/
|
|
343
|
+
async function handleChannelDiscover(ctx, req, res) {
|
|
344
|
+
const body = await readJsonBody(req, 64 * 1024);
|
|
345
|
+
const protocol = body && typeof body.protocol === 'string' ? body.protocol.trim() : '';
|
|
346
|
+
if (!PROTOCOLS.includes(protocol)) {
|
|
347
|
+
return sendJson(res, 400, { ok: false, error: { code: 'bad-request', message: '端点类型不认识' } });
|
|
348
|
+
}
|
|
349
|
+
const base = normalizeBaseUrl(body ? body.baseUrl : undefined);
|
|
350
|
+
if (base.error) return sendJson(res, 400, { ok: false, error: { code: 'bad-request', message: base.error } });
|
|
351
|
+
const apiKey = body && typeof body.apiKey === 'string' ? body.apiKey.trim() : '';
|
|
352
|
+
if (apiKey.length > MAX_KEY_CHARS) {
|
|
353
|
+
return sendJson(res, 400, { ok: false, error: { code: 'bad-request', message: 'API Key 太长' } });
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
const llm = lookupService(ctx, 'llm');
|
|
357
|
+
if (!llm || typeof llm.discoverModels !== 'function') {
|
|
358
|
+
return sendJson(res, 503, {
|
|
359
|
+
ok: false,
|
|
360
|
+
error: { code: 'unsupported', message: '这台宿主不支持代取模型清单,请升级电脑上的 DSH' },
|
|
361
|
+
});
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
let lastError;
|
|
365
|
+
for (const baseURL of discoverCandidates(base.url, protocol)) {
|
|
366
|
+
try {
|
|
367
|
+
const found = await llm.discoverModels(PI_AI_NS, {
|
|
368
|
+
baseURL,
|
|
369
|
+
api: protocol,
|
|
370
|
+
...(apiKey === '' ? {} : { apiKey }),
|
|
371
|
+
});
|
|
372
|
+
const models = (Array.isArray(found) ? found : [])
|
|
373
|
+
.map((row) => {
|
|
374
|
+
if (!row || typeof row !== 'object') return null;
|
|
375
|
+
const id = typeof row.id === 'string' ? row.id.trim() : '';
|
|
376
|
+
if (!id) return null;
|
|
377
|
+
const contextWindow = Number(row.contextWindow);
|
|
378
|
+
const maxTokens = Number(row.maxTokens);
|
|
379
|
+
return {
|
|
380
|
+
id,
|
|
381
|
+
name: typeof row.name === 'string' && row.name.trim() ? row.name.trim() : id,
|
|
382
|
+
...(Number.isFinite(contextWindow) && contextWindow > 0 ? { contextWindow: Math.trunc(contextWindow) } : {}),
|
|
383
|
+
...(Number.isFinite(maxTokens) && maxTokens > 0 ? { maxTokens: Math.trunc(maxTokens) } : {}),
|
|
384
|
+
...(row.vision === true ? { vision: true } : {}),
|
|
385
|
+
};
|
|
386
|
+
})
|
|
387
|
+
.filter(Boolean)
|
|
388
|
+
.slice(0, MAX_MODELS);
|
|
389
|
+
return sendJson(res, 200, { ok: true, baseUrl: baseURL, models });
|
|
390
|
+
} catch (caught) {
|
|
391
|
+
lastError = caught;
|
|
392
|
+
}
|
|
393
|
+
}
|
|
394
|
+
// 协议层 / 渠道包的话本身就是给用户看的(「answered 401; check the API key」),原样透出。
|
|
395
|
+
sendJson(res, 422, {
|
|
396
|
+
ok: false,
|
|
397
|
+
error: { code: 'discover-failed', message: String((lastError && lastError.message) || lastError).slice(0, 300) },
|
|
398
|
+
});
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
/** 新建渠道时 id 的占用集合:已注册的 provider + 内置名单 + pi-ai 里已有的每条路由。 */
|
|
402
|
+
function takenIdsOf(registered, providers) {
|
|
403
|
+
const taken = new Set(RESERVED_PROVIDER_IDS);
|
|
404
|
+
for (const item of registered) {
|
|
405
|
+
if (item && typeof item.provider === 'string' && item.provider !== '') taken.add(item.provider);
|
|
406
|
+
}
|
|
407
|
+
for (const id of Object.keys(providers)) taken.add(id);
|
|
408
|
+
return taken;
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
/**
|
|
412
|
+
* POST /api/mobile/v1/channels/save(token)
|
|
413
|
+
* { id?, name, protocol, baseUrl, apiKey?, models: [{id, name?, contextWindow?, maxTokens?, vision?}] }
|
|
414
|
+
* → { ok, channel }
|
|
415
|
+
*
|
|
416
|
+
* 带 `id` = 编辑那一条(id 不变、Key 可以留空表示沿用原来的);不带 = 新建(Key 必填)。
|
|
417
|
+
* 写 Key → 写路由,两步都成了才回成功;路由写失败就把**这一次新写的** Key 清掉(回滚),
|
|
418
|
+
* 不留一条「有 Key 没路由」的孤儿凭据。编辑时用户没重填 Key,就没有可回滚的东西。
|
|
419
|
+
* **路由只能按路径写**:`settings.mutate` 的 `set path:['providers', id]` 一次只动这一段——
|
|
420
|
+
* 整段覆盖会把用户手写的其它路由抹掉(桌面端 2026-10-19 踩过:保存一次自建渠道,
|
|
421
|
+
* 另一条路由整段蒸发)。
|
|
422
|
+
*/
|
|
423
|
+
async function handleChannelSave(ctx, req, res) {
|
|
424
|
+
const body = await readJsonBody(req, 64 * 1024);
|
|
425
|
+
if (!body || typeof body !== 'object') {
|
|
426
|
+
return sendJson(res, 400, { ok: false, error: { code: 'bad-request', message: '需要渠道表单' } });
|
|
427
|
+
}
|
|
428
|
+
const name = typeof body.name === 'string' ? body.name.trim() : '';
|
|
429
|
+
if (name === '') return sendJson(res, 400, { ok: false, error: { code: 'bad-request', message: '渠道名称不能为空' } });
|
|
430
|
+
if (name.length > MAX_CHANNEL_NAME_CHARS) {
|
|
431
|
+
return sendJson(res, 400, { ok: false, error: { code: 'bad-request', message: `渠道名称最多 ${MAX_CHANNEL_NAME_CHARS} 字` } });
|
|
432
|
+
}
|
|
433
|
+
const protocol = typeof body.protocol === 'string' ? body.protocol.trim() : '';
|
|
434
|
+
if (!PROTOCOLS.includes(protocol)) {
|
|
435
|
+
return sendJson(res, 400, { ok: false, error: { code: 'bad-request', message: '端点类型不认识' } });
|
|
436
|
+
}
|
|
437
|
+
const base = normalizeBaseUrl(body.baseUrl);
|
|
438
|
+
if (base.error) return sendJson(res, 400, { ok: false, error: { code: 'bad-request', message: base.error } });
|
|
439
|
+
const givenId = typeof body.id === 'string' ? body.id.trim() : '';
|
|
440
|
+
if (givenId !== '' && !CHANNEL_ID_PATTERN.test(givenId)) {
|
|
441
|
+
return sendJson(res, 400, { ok: false, error: { code: 'bad-request', message: '渠道 id 不合法' } });
|
|
442
|
+
}
|
|
443
|
+
const apiKey = typeof body.apiKey === 'string' ? body.apiKey.trim() : '';
|
|
444
|
+
// 新建必须带 Key;编辑留空 = 沿用凭据库里那把(凭据只写不读,所以「原样保留」只能是这个语义)。
|
|
445
|
+
if (apiKey === '' && givenId === '') {
|
|
446
|
+
return sendJson(res, 400, { ok: false, error: { code: 'bad-request', message: 'API Key 不能为空' } });
|
|
447
|
+
}
|
|
448
|
+
if (apiKey.length > MAX_KEY_CHARS) {
|
|
449
|
+
return sendJson(res, 400, { ok: false, error: { code: 'bad-request', message: 'API Key 太长' } });
|
|
450
|
+
}
|
|
451
|
+
const normalized = normalizeModels(body.models);
|
|
452
|
+
if (normalized.error) {
|
|
453
|
+
return sendJson(res, 400, { ok: false, error: { code: 'bad-request', message: normalized.error } });
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
const registeredRead = unwrap(await ctx.apiProxy.llm.providers({
|
|
457
|
+
rpcId: 'mobile',
|
|
458
|
+
method: 'llm.providers',
|
|
459
|
+
payload: {},
|
|
460
|
+
}));
|
|
461
|
+
const registered = registeredRead.error ? [] : (Array.isArray(registeredRead.value && registeredRead.value.providers) ? registeredRead.value.providers : []);
|
|
462
|
+
|
|
463
|
+
const describe = unwrap(await ctx.apiProxy.settings.describe({
|
|
464
|
+
rpcId: 'mobile',
|
|
465
|
+
method: 'settings.describe',
|
|
466
|
+
payload: {},
|
|
467
|
+
}));
|
|
468
|
+
if (describe.error) return sendJson(res, 400, { ok: false, error: describe.error });
|
|
469
|
+
const namespaces = Array.isArray(describe.value && describe.value.namespaces) ? describe.value.namespaces : [];
|
|
470
|
+
const piAi = namespaces.find((entry) => entry && entry.ns === PI_AI_NS);
|
|
471
|
+
const providers = piAiProvidersOf(piAi && piAi.value);
|
|
472
|
+
|
|
473
|
+
// 编辑只认「确实在自建清单里」的那一条:id 是给外部(默认模型、缓存、别的插件)认的,
|
|
474
|
+
// 不能凭一个不存在的 id 在这里凭空建路由。
|
|
475
|
+
if (givenId !== '' && !Object.prototype.hasOwnProperty.call(providers, givenId)) {
|
|
476
|
+
return sendJson(res, 404, { ok: false, error: { code: 'not-found', message: '这条渠道不在宿主的自建清单里' } });
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
const id = givenId || providerIdOf(name, takenIdsOf(registered, providers));
|
|
480
|
+
const ref = credentialRefOf(id);
|
|
481
|
+
const channel = {
|
|
482
|
+
id,
|
|
483
|
+
name,
|
|
484
|
+
protocol,
|
|
485
|
+
baseUrl: base.url,
|
|
486
|
+
models: normalized.models,
|
|
487
|
+
};
|
|
488
|
+
const previous = givenId === '' ? null : providers[givenId];
|
|
489
|
+
|
|
490
|
+
// Key 留空(编辑)就完全不动凭据库:写一个空串会把原来那把覆盖掉。
|
|
491
|
+
if (apiKey !== '') {
|
|
492
|
+
const keyWrite = unwrap(await ctx.apiProxy.credentials.set({
|
|
493
|
+
rpcId: 'mobile',
|
|
494
|
+
method: 'credentials.set',
|
|
495
|
+
payload: { ref, value: apiKey },
|
|
496
|
+
}));
|
|
497
|
+
if (keyWrite.error) return sendJson(res, 400, { ok: false, error: keyWrite.error });
|
|
498
|
+
}
|
|
499
|
+
|
|
500
|
+
const settingsWrite = unwrap(await ctx.apiProxy.settings.mutate({
|
|
501
|
+
rpcId: 'mobile',
|
|
502
|
+
method: 'settings.mutate',
|
|
503
|
+
payload: { ns: PI_AI_NS, ops: [{ op: 'set', path: ['providers', id], value: profileOf(channel, previous) }] },
|
|
504
|
+
}));
|
|
505
|
+
if (settingsWrite.error) {
|
|
506
|
+
// 只回滚这一次新写的 Key;编辑时没写 Key 就没有可回滚的东西。
|
|
507
|
+
if (apiKey !== '') {
|
|
508
|
+
try {
|
|
509
|
+
await ctx.apiProxy.credentials.unset({ rpcId: 'mobile', method: 'credentials.unset', payload: { ref } });
|
|
510
|
+
} catch { /* 回滚失败不掩盖原始错误 */ }
|
|
511
|
+
}
|
|
512
|
+
return sendJson(res, 400, { ok: false, error: settingsWrite.error });
|
|
513
|
+
}
|
|
514
|
+
|
|
515
|
+
// 模型目录是设备级的,缓存却按会话存:这台设备上每个会话的旧目录都得作废。
|
|
516
|
+
dropSessionModelsForDevice(ctx, req);
|
|
517
|
+
sendJson(res, 200, {
|
|
518
|
+
ok: true,
|
|
519
|
+
channel: {
|
|
520
|
+
...channel,
|
|
521
|
+
source: 'custom',
|
|
522
|
+
// 编辑时没重填 Key:这里不猜它有没有设过,App 保存后会重新拉一次清单拿准的。
|
|
523
|
+
...(apiKey !== '' ? { keySet: true } : {}),
|
|
524
|
+
writable: true,
|
|
525
|
+
},
|
|
526
|
+
});
|
|
527
|
+
}
|
|
528
|
+
|
|
529
|
+
/**
|
|
530
|
+
* POST /api/mobile/v1/channels/delete(token){ id } → { ok }
|
|
531
|
+
*
|
|
532
|
+
* 只删 `llm-pi-ai` 里真的存在的那一条:路径写到 `providers.<id>`,不碰别的路由。
|
|
533
|
+
* 先删路由再清 Key——反过来的话路由还在、凭据先没了,中间那一小段时间模型会以
|
|
534
|
+
* 「鉴权失败」的形式出现在选择器里。
|
|
535
|
+
*/
|
|
536
|
+
async function handleChannelDelete(ctx, req, res) {
|
|
537
|
+
const body = await readJsonBody(req, 64 * 1024);
|
|
538
|
+
const id = body && typeof body.id === 'string' ? body.id.trim() : '';
|
|
539
|
+
if (!CHANNEL_ID_PATTERN.test(id)) {
|
|
540
|
+
return sendJson(res, 400, { ok: false, error: { code: 'bad-request', message: '渠道 id 不合法' } });
|
|
541
|
+
}
|
|
542
|
+
|
|
543
|
+
const describe = unwrap(await ctx.apiProxy.settings.describe({
|
|
544
|
+
rpcId: 'mobile',
|
|
545
|
+
method: 'settings.describe',
|
|
546
|
+
payload: {},
|
|
547
|
+
}));
|
|
548
|
+
if (describe.error) return sendJson(res, 400, { ok: false, error: describe.error });
|
|
549
|
+
const namespaces = Array.isArray(describe.value && describe.value.namespaces) ? describe.value.namespaces : [];
|
|
550
|
+
const piAi = namespaces.find((entry) => entry && entry.ns === PI_AI_NS);
|
|
551
|
+
const providers = piAiProvidersOf(piAi && piAi.value);
|
|
552
|
+
if (!Object.prototype.hasOwnProperty.call(providers, id)) {
|
|
553
|
+
return sendJson(res, 404, { ok: false, error: { code: 'not-found', message: '这条渠道不在宿主的自建清单里' } });
|
|
554
|
+
}
|
|
555
|
+
|
|
556
|
+
const removed = unwrap(await ctx.apiProxy.settings.mutate({
|
|
557
|
+
rpcId: 'mobile',
|
|
558
|
+
method: 'settings.mutate',
|
|
559
|
+
payload: { ns: PI_AI_NS, ops: [{ op: 'unset', path: ['providers', id] }] },
|
|
560
|
+
}));
|
|
561
|
+
if (removed.error) return sendJson(res, 400, { ok: false, error: removed.error });
|
|
562
|
+
|
|
563
|
+
try {
|
|
564
|
+
await ctx.apiProxy.credentials.unset({ rpcId: 'mobile', method: 'credentials.unset', payload: { ref: credentialRefOf(id) } });
|
|
565
|
+
} catch { /* 凭据可能早就不在了;路由已经删掉,别因此把成功报成失败 */ }
|
|
566
|
+
|
|
567
|
+
dropSessionModelsForDevice(ctx, req);
|
|
568
|
+
sendJson(res, 200, { ok: true });
|
|
569
|
+
}
|
|
570
|
+
|
|
571
|
+
module.exports = {
|
|
572
|
+
handleChannels,
|
|
573
|
+
handleChannelDiscover,
|
|
574
|
+
handleChannelSave,
|
|
575
|
+
handleChannelDelete,
|
|
576
|
+
credentialRefOf,
|
|
577
|
+
providerIdOf,
|
|
578
|
+
normalizeBaseUrl,
|
|
579
|
+
normalizeModels,
|
|
580
|
+
profileOf,
|
|
581
|
+
discoverCandidates,
|
|
582
|
+
PI_AI_NS,
|
|
583
|
+
};
|
package/lib/mobile/events.js
CHANGED
|
@@ -44,13 +44,14 @@ function logDeviceStream(message, error) {
|
|
|
44
44
|
/**
|
|
45
45
|
* 写一条 SSE 记录(策略声明,实现见 `lib/shared/sse-write.js`)。
|
|
46
46
|
*
|
|
47
|
-
* 这条流的策略:软水位**一律** shed
|
|
48
|
-
*
|
|
49
|
-
*
|
|
50
|
-
*
|
|
47
|
+
* 这条流的策略:软水位**一律** shed —— 但 shed 不能当成功:设备流没有 Last-Event-ID
|
|
48
|
+
* 续传,丢了帧又继续读,App 就会停在缺少那一次变更的状态。所以调用方看到 `shed` 必须
|
|
49
|
+
* 立即 `abort` 断流,让客户端重连后走 `replayKnownSignals()` 全量补水位。
|
|
50
|
+
* 这比把帧堆在写缓冲里好(2026-10-17 现场:466MB 的 SSE 帧挂在 socket 写队列上,宿主撞
|
|
51
|
+
* 4GB 上限被杀)。不补填充:这里的帧少、单帧也小,不需要撑 RN 的缓冲阈值。
|
|
51
52
|
*
|
|
52
|
-
* 返回值与会话流一致:`ok:false` 该收尾,`shed:true`
|
|
53
|
-
* 写进去了但底下已经写不动。
|
|
53
|
+
* 返回值与会话流一致:`ok:false` 该收尾,`shed:true` 这条被丢掉(调用方必须断流),
|
|
54
|
+
* `needDrain:true` 写进去了但底下已经写不动。
|
|
54
55
|
*/
|
|
55
56
|
function writeSse(res, payload, frame) {
|
|
56
57
|
return sharedWriteSse(res, payload, {
|
|
@@ -155,7 +156,8 @@ async function handleDeviceStream(ctx, req, res) {
|
|
|
155
156
|
payload: devicePayload,
|
|
156
157
|
}, null);
|
|
157
158
|
if (!result.ok) return false;
|
|
158
|
-
|
|
159
|
+
// shed 不是成功:不能继续读,否则这一帧代表的变更永远补不回来。
|
|
160
|
+
if (result.shed) return false;
|
|
159
161
|
framesSent += 1;
|
|
160
162
|
// 所有调用点都是同步回调(信号总线、心跳),没法在这里 await;写不动就如实回报,
|
|
161
163
|
// 由调用方 abort —— 这条流本来就是「断了重连、靠补发收敛」的语义。
|
|
@@ -290,7 +292,7 @@ async function handleDeviceStream(ctx, req, res) {
|
|
|
290
292
|
};
|
|
291
293
|
// Keep the existing device/event JSON envelope unchanged; SSE metadata is additive.
|
|
292
294
|
const sent = writeSse(res, envelope, frame);
|
|
293
|
-
if (sent.shed)
|
|
295
|
+
if (sent.shed) { clientGone = true; break; }
|
|
294
296
|
if (!sent.ok || sent.needDrain) { clientGone = true; break; }
|
|
295
297
|
framesSent += 1;
|
|
296
298
|
}
|