dsh-dock 0.9.5 → 0.9.7

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.
Files changed (35) hide show
  1. package/LICENSE +18 -18
  2. package/README.md +325 -325
  3. package/client.js +42 -8
  4. package/cordis.patch.yml +2 -2
  5. package/features/animation/host.js +660 -660
  6. package/features/animation/view.jsx +2247 -2247
  7. package/features/balance/host.js +494 -494
  8. package/features/balance/view.js +351 -351
  9. package/features/games/games/breakout.jsx +227 -227
  10. package/features/games/games/gomoku.jsx +244 -244
  11. package/features/games/games/racer.jsx +169 -169
  12. package/features/games/games/shared.jsx +118 -118
  13. package/features/games/games/snake.jsx +144 -144
  14. package/features/games/games/sokoban.jsx +180 -180
  15. package/features/games/games/tank.jsx +128 -128
  16. package/features/games/games/tetris.jsx +308 -308
  17. package/features/games/games/xiangqi.jsx +277 -277
  18. package/features/games/tank-core.js +152 -152
  19. package/features/games/view.jsx +639 -639
  20. package/features/games/xiangqi-core.js +299 -299
  21. package/features/heartbeat/view.js +35 -35
  22. package/features/mobile-relay/gateway.js +472 -415
  23. package/features/mobile-relay/host.js +570 -546
  24. package/features/mobile-relay/view.jsx +300 -300
  25. package/features/modelconfig/host.js +403 -403
  26. package/features/modelconfig/view.js +490 -490
  27. package/features/theme/view.js +61 -61
  28. package/features/tokenlog/host.js +678 -678
  29. package/features/tokenlog/view.jsx +561 -561
  30. package/features/visionproxy/host.js +315 -315
  31. package/index.js +175 -118
  32. package/package.json +59 -59
  33. package/src/client.jsx +668 -668
  34. package/src/host-core.js +98 -95
  35. package/src/shared.js +167 -130
@@ -1,403 +1,403 @@
1
- // dsh-dock · 功能模块【模型设置】· 宿主半部(v0.3.0 迁自 index.js,行为不变)
2
- //
3
- // GET /dsh-dock/models —— 枚举全部已配置 Provider 的模型目录(含输入类型/思考强度)
4
- // POST /dsh-dock/models —— 把面板编辑写回官方 settings(settings.mutate,热生效)
5
- // 官方链路(集成不改内核):
6
- // - 会话模型选择器按 model.reasoning.efforts 展示强度档;档位源自 Provider 配置:
7
- // · pi-ai 路由:每模型 reasoningEfforts(档位 → wire 值;off:null 表示"支持关闭、不发参数";false = 不支持思考)
8
- // · deepseek 官方:连接级 thinking(enabled/disabled)+ reasoningEffort 默认档,全模型共享 Off/Low/High/Max
9
- // - 输入类型官方 schema 仅接受 text/image;「视频」等以 dockTags 标注随配置持久化(不参与请求路由)
10
- // - schemastery 保留未知字段(实测),dockTags 对官方校验透明
11
- // - POST 也收图片理解代理配置(visionProxy 分支,写 dsh-dock 自有命名空间)
12
- import { DOCK_NS, sendJson, readBody, walkPath } from '../../src/host-core.js'
13
-
14
- /** 官方 schema 接受的输入模态(写回配置的 input 只允许这两个)。 */
15
- const HOST_MODALITIES = ['text', 'image']
16
- /** pi-ai 支持的思考档位(升序)。 */
17
- const PI_AI_LEVELS = ['off', 'minimal', 'low', 'medium', 'high', 'xhigh', 'max']
18
- /** deepseek 官方支持的思考档位。 */
19
- const DEEPSEEK_LEVELS = ['off', 'low', 'high', 'max']
20
-
21
- /** deepseek-official 内置默认值(与 balance 模块同款兜底)。 */
22
- const DEEPSEEK_DEFAULTS = {
23
- apiKeyEnv: 'DEEPSEEK_API_KEY',
24
- baseURL: 'https://api.deepseek.com',
25
- }
26
-
27
- function providerKindOf(entry) {
28
- if (entry.settingsNs === 'llm-deepseek') return 'deepseek'
29
- if (entry.settingsNs === 'llm-pi-ai') return 'pi-ai'
30
- return 'other'
31
- }
32
-
33
- /** 正整数或 undefined(目录数字字段透传)。 */
34
- function posInt(v) {
35
- return Number.isInteger(v) && v > 0 ? v : undefined
36
- }
37
-
38
- /** 字符串数组透传(仅保留非空字符串)。 */
39
- function strList(v) {
40
- return Array.isArray(v) ? v.filter((x) => typeof x === 'string' && x) : []
41
- }
42
-
43
- /** 把 pi-ai 模型条目归一为面板视图(raw = 原始条目,保存时原样回传保留未知字段)。 */
44
- function piAiModelView(entry, routeDefaultInput) {
45
- const input = strList(entry.input).filter((m) => HOST_MODALITIES.includes(m))
46
- const raw = entry && typeof entry === 'object' && !Array.isArray(entry) ? entry : {}
47
- return {
48
- id: typeof entry.id === 'string' ? entry.id : '',
49
- name: typeof entry.name === 'string' ? entry.name : undefined,
50
- contextWindow: posInt(entry.contextWindow),
51
- maxTokens: posInt(entry.maxTokens),
52
- input: input.length > 0 ? input : (strList(routeDefaultInput).length > 0 ? strList(routeDefaultInput) : ['text']),
53
- inputInherited: input.length === 0,
54
- efforts: raw.reasoningEfforts === undefined ? 'inherit' : raw.reasoningEfforts === false ? 'off' : 'custom',
55
- effortMap: raw.reasoningEfforts && typeof raw.reasoningEfforts === 'object' ? raw.reasoningEfforts : null,
56
- tags: strList(entry.dockTags),
57
- raw,
58
- }
59
- }
60
-
61
- /** 把 deepseek 目录模型归一为面板视图。 */
62
- function deepSeekModelView(entry) {
63
- const input = strList(entry.inputModalities).filter((m) => HOST_MODALITIES.includes(m))
64
- const raw = entry && typeof entry === 'object' && !Array.isArray(entry) ? entry : {}
65
- return {
66
- id: typeof entry.id === 'string' ? entry.id : '',
67
- name: typeof entry.name === 'string' ? entry.name : undefined,
68
- contextWindow: posInt(entry.contextWindow),
69
- maxTokens: posInt(entry.maxTokens),
70
- input: input.length > 0 ? input : ['text'],
71
- inputInherited: input.length === 0,
72
- tags: strList(entry.dockTags),
73
- raw,
74
- }
75
- }
76
-
77
- /**
78
- * 读模型目录:按"已配置"口径枚举 Provider,
79
- * 输出各 Provider 的模型列表(含输入类型 / 思考强度)与各 settings 命名空间的 revision。
80
- * (visionproxy 模块的目录兜底缓存也复用本函数)
81
- */
82
- export function readModelDirectory(ctx) {
83
- const llm = ctx.get('llm')
84
- const settings = ctx.get('settings')
85
- const entries = (llm && llm.listConfigurableProviders ? llm.listConfigurableProviders() : []) || []
86
- let views = []
87
- try {
88
- views = (settings && settings.describe ? settings.describe({ redactSecrets: true }) : []) || []
89
- } catch {
90
- // settings 未挂载
91
- }
92
- const descOf = (ns) => views.find((v) => v && String(v.ns) === ns)
93
-
94
- let sel = null
95
- try {
96
- sel = (ctx.get('agentDefaultModel') && ctx.get('agentDefaultModel').currentSelection()) || null
97
- } catch {
98
- // 服务缺失
99
- }
100
-
101
- const providers = []
102
- for (const e of entries) {
103
- const kind = providerKindOf(e)
104
- if (kind === 'other') continue // 官方之外的适配器暂不编辑(无稳定 schema)
105
- const desc = descOf(e.settingsNs)
106
- if (!desc) continue
107
- const profileUser = desc.user ? walkPath(desc.user, e.settingsPath) : undefined
108
- const profileBase = desc.base ? walkPath(desc.base, e.settingsPath) : undefined
109
- const isDefaultProvider = !!(sel && sel.provider === e.provider)
110
- // deepseek 官方始终可编辑(schema 默认目录兜底);其余按"已配置"口径
111
- const configured = kind === 'deepseek' || isDefaultProvider
112
- || (profileUser && typeof profileUser === 'object')
113
- || (profileBase && typeof profileBase === 'object')
114
- if (!configured) continue
115
-
116
- const resolved = walkPath(desc.value, e.settingsPath) // schema 默认已套上的合成视图
117
- if (kind === 'deepseek') {
118
- const section = resolved && typeof resolved === 'object' ? resolved : {}
119
- const models = Array.isArray(section.models) ? section.models : []
120
- providers.push({
121
- id: e.provider,
122
- displayName: (profileUser && profileUser.displayName) || e.displayName || e.provider,
123
- api: 'deepseek',
124
- baseURL: (profileUser && profileUser.baseURL) || section.baseURL || DEEPSEEK_DEFAULTS.baseURL,
125
- apiKeyEnv: (profileUser && profileUser.apiKeyEnv) || DEEPSEEK_DEFAULTS.apiKeyEnv,
126
- kind,
127
- settingsNs: e.settingsNs,
128
- settingsPath: [...e.settingsPath],
129
- thinking: section.thinking === 'disabled' ? 'disabled' : 'enabled',
130
- defaultEffort: DEEPSEEK_LEVELS.includes(section.reasoningEffort) ? section.reasoningEffort : undefined,
131
- effortLevels: DEEPSEEK_LEVELS.slice(),
132
- models: models.map(deepSeekModelView),
133
- })
134
- } else {
135
- const route = e.settingsPath[1] || e.provider
136
- const profile = (profileUser && typeof profileUser === 'object' ? profileUser
137
- : (profileBase && typeof profileBase === 'object' ? profileBase : resolved)) || {}
138
- const resolvedProfile = (resolved && typeof resolved === 'object' ? resolved : {}) || {}
139
- const models = Array.isArray(profile.models) ? profile.models : []
140
- providers.push({
141
- id: e.provider,
142
- route,
143
- displayName: profile.displayName || e.displayName || e.provider,
144
- api: typeof profile.api === 'string' ? profile.api : undefined,
145
- baseURL: typeof profile.baseURL === 'string' ? profile.baseURL : undefined,
146
- apiKeyEnv: typeof profile.apiKeyEnv === 'string' ? profile.apiKeyEnv : undefined,
147
- kind,
148
- settingsNs: e.settingsNs,
149
- settingsPath: [...e.settingsPath],
150
- defaultInput: strList(resolvedProfile.defaultInput),
151
- models: models.map((m) => piAiModelView(m && typeof m === 'object' ? m : { id: String(m) }, resolvedProfile.defaultInput)),
152
- })
153
- }
154
- }
155
-
156
- const revisions = {}
157
- for (const ns of ['llm-pi-ai', 'llm-deepseek']) {
158
- const d = descOf(ns)
159
- if (d && d.revision !== undefined) revisions[ns] = d.revision
160
- }
161
- return { generatedAt: Date.now(), default: sel, revisions, providers }
162
- }
163
-
164
- /**
165
- * 给目录视图补运行时视角的输入模态(llm.resolveModelInfo,与官方投影同源:
166
- * entry.input → 内置目录 → 路由默认)。面板据此展示「原图直发 vs 走视觉代理」,
167
- * 避免"条目留空但目录继承多模态"的模型被误分组。
168
- * 注意:功能启用后 llm.resolveModelInfo 被本插件包装(虚拟多模态),此处须取原函数保真。
169
- */
170
- async function enrichRuntimeInput(ctx, dir) {
171
- const llm = ctx.get('llm')
172
- const resolve = llm && (llm.__dockOrigResolveModelInfo
173
- || (typeof llm.resolveModelInfo === 'function' ? llm.resolveModelInfo.bind(llm) : null))
174
- if (!resolve) return
175
- await Promise.all((dir.providers || []).flatMap((p) => (p.models || []).map(async (m) => {
176
- try {
177
- const info = await resolve(p.id, m.id)
178
- if (info && Array.isArray(info.inputModalities)) {
179
- m.runtimeInput = info.inputModalities.filter((x) => HOST_MODALITIES.includes(x))
180
- }
181
- } catch {
182
- // 单模型解析失败不影响整表(面板回退用 input 展示)
183
- }
184
- })))
185
- }
186
-
187
- /** 校验面板数字草稿并落到条目上(空 = 删除字段,继承默认)。 */
188
- function setPosInt(entry, field, value) {
189
- if (value === undefined || value === null || value === '') {
190
- delete entry[field]
191
- return
192
- }
193
- const n = Number(value)
194
- if (!Number.isInteger(n) || n <= 0) throw new Error(`模型 ${entry.id} 的 ${field} 须为正整数`)
195
- entry[field] = n
196
- }
197
-
198
- /**
199
- * 校验面板提交的模型草稿,产出写回 pi-ai 的模型条目(不合法直接抛错)。
200
- * 基于原条目(raw)合并编辑:未知字段(用户自配 compat/description 等)原样保留。
201
- * 启用思考(custom)时的兜底(2026-08-22 事故修复):
202
- * 不在 pi-ai 内置目录里的模型没有 catalog compat,检测默认 supportsDeveloperRole=true,
203
- * 而 pi-ai 对 reasoning 模型会把 system 改写成 developer 角色 → 百炼等 OpenAI 兼容端点 400。
204
- * 故凡启用思考一律显式 supportsDeveloperRole:false(system 全端点通用);
205
- * 百炼/阿里云家族 baseURL 再补 thinkingFormat:'qwen'(与目录内模型一致,思考参数用 qwen 格式)。
206
- */
207
- function piAiModelWrite(m, p) {
208
- if (!m || typeof m.id !== 'string' || !m.id) throw new Error('模型 id 不能为空')
209
- const raw = m.raw && typeof m.raw === 'object' && !Array.isArray(m.raw) ? m.raw : {}
210
- const entry = Object.assign({}, raw)
211
- entry.id = m.id
212
- if (typeof m.name === 'string' && m.name) entry.name = m.name
213
- else delete entry.name
214
- setPosInt(entry, 'contextWindow', m.contextWindow)
215
- setPosInt(entry, 'maxTokens', m.maxTokens)
216
- const input = Array.isArray(m.input) ? m.input.filter((x) => HOST_MODALITIES.includes(x)) : []
217
- if (input.length > 0) entry.input = input // 留空 = 继承路由/目录默认
218
- else delete entry.input
219
- const tags = strList(m.tags)
220
- if (tags.length > 0) entry.dockTags = tags // 标注(如 video),官方校验忽略、随配置持久化
221
- else delete entry.dockTags
222
- if (m.effortsMode === 'off') {
223
- entry.reasoningEfforts = false
224
- } else if (m.effortsMode === 'custom') {
225
- const levels = m.effortLevels || {}
226
- const map = {}
227
- let real = 0
228
- for (const lv of PI_AI_LEVELS) {
229
- if (!levels[lv]) continue
230
- if (lv === 'off') map.off = null // 支持关闭:不发参数
231
- else { map[lv] = lv; real += 1 }
232
- }
233
- if (real === 0) throw new Error(`模型 ${m.id} 至少要勾选一个思考档位(off 之外)`)
234
- entry.reasoningEfforts = map
235
- // 目录路由(用户层未写 api/baseURL,靠 pi-ai 内置目录提供)视作 openai 兼容
236
- const apiOk = !p.api || (typeof p.api === 'string' && p.api.indexOf('openai') === 0)
237
- if (apiOk) {
238
- const compat = Object.assign({}, entry.compat)
239
- compat.supportsDeveloperRole = false
240
- // 百炼/阿里云家族(路由 id 或 baseURL 命中)思考参数用 qwen 格式,与目录内模型一致
241
- if (/qwen|dashscope|aliyuncs/i.test(String(p.route || '') + ' ' + String(p.baseURL || ''))) {
242
- compat.thinkingFormat = 'qwen'
243
- }
244
- entry.compat = compat
245
- }
246
- } else {
247
- delete entry.reasoningEfforts // inherit = 不写该字段,跟随内置目录
248
- }
249
- return entry
250
- }
251
-
252
- /** 校验并产出写回 deepseek 官方的目录模型条目(raw 未知字段保留)。 */
253
- function deepSeekModelWrite(m) {
254
- if (!m || typeof m.id !== 'string' || !m.id) throw new Error('模型 id 不能为空')
255
- const raw = m.raw && typeof m.raw === 'object' && !Array.isArray(m.raw) ? m.raw : {}
256
- const entry = Object.assign({}, raw)
257
- entry.id = m.id
258
- if (typeof m.name === 'string' && m.name) entry.name = m.name
259
- else delete entry.name
260
- setPosInt(entry, 'contextWindow', m.contextWindow)
261
- setPosInt(entry, 'maxTokens', m.maxTokens)
262
- const input = Array.isArray(m.input) ? m.input.filter((x) => HOST_MODALITIES.includes(x)) : []
263
- if (input.includes('image')) entry.inputModalities = input // 纯文本留空走 schema 默认 ['text']
264
- else delete entry.inputModalities
265
- const tags = strList(m.tags)
266
- if (tags.length > 0) entry.dockTags = tags
267
- else delete entry.dockTags
268
- return entry
269
- }
270
-
271
- /**
272
- * 写回官方配置:按 Provider kind 生成 settings.mutate ops。
273
- * 校验失败/官方 schema 拒绝时抛错(路由层转 4xx 带信息)。
274
- */
275
- async function writeModelConfig(ctx, body) {
276
- if (!body || typeof body !== 'object') throw new Error('请求体不是 JSON 对象')
277
-
278
- const settings = ctx.get('settings')
279
- if (!settings || typeof settings.mutate !== 'function') throw new Error('settings 服务不可用或不可写')
280
-
281
- // 图片理解代理配置:独立于 Provider 目录的小分支
282
- if (body.visionProxy !== undefined) {
283
- const vp = body.visionProxy
284
- if (!vp || typeof vp !== 'object' || Array.isArray(vp)) throw new Error('visionProxy 格式不符')
285
- const value = {
286
- enabled: !!vp.enabled,
287
- provider: typeof vp.provider === 'string' ? vp.provider : '',
288
- model: typeof vp.model === 'string' ? vp.model : '',
289
- }
290
- if (value.enabled && (!value.provider || !value.model)) {
291
- const err = new Error('启用图片理解代理需要选择视觉模型')
292
- err.statusCode = 400
293
- throw err
294
- }
295
- const revision = body.revisions && typeof body.revisions[DOCK_NS] === 'number' ? body.revisions[DOCK_NS] : undefined
296
- try {
297
- await settings.mutate(DOCK_NS, [{ op: 'set', path: ['visionProxy'], value }], revision)
298
- } catch (e) {
299
- const msg = e && e.message ? e.message : String(e)
300
- const err = new Error(`保存图片理解代理配置被拒绝:${msg}`)
301
- err.statusCode = /conflict|revision/i.test(msg) ? 409 : 400
302
- throw err
303
- }
304
- return { ok: true, savedAt: Date.now() }
305
- }
306
-
307
- const dir = readModelDirectory(ctx)
308
- if (typeof body.provider !== 'string' || !body.provider) {
309
- // 典型场景:新 Client(图片代理面板)打到旧 Host(无 visionProxy 分支)
310
- const err = new Error('请求缺少 provider——若正在保存图片代理配置,说明宿主进程是旧版本,重启 dsh web 后重试')
311
- err.statusCode = 400
312
- throw err
313
- }
314
- const p = dir.providers.find((x) => x.id === body.provider)
315
- if (!p) throw new Error(`未找到 Provider:${body.provider}`)
316
- if (p.kind === 'other') throw new Error('该 Provider 暂不支持编辑')
317
- if (!Array.isArray(body.models)) throw new Error('models 必须是数组')
318
-
319
- const seen = new Set()
320
- for (const m of body.models) {
321
- const id = m && typeof m.id === 'string' ? m.id : ''
322
- if (!id) throw new Error('模型 id 不能为空')
323
- if (seen.has(id)) throw new Error(`模型 id 重复:${id}`)
324
- seen.add(id)
325
- }
326
-
327
- const ops = []
328
- if (p.kind === 'deepseek') {
329
- ops.push({ op: 'set', path: ['models'], value: body.models.map(deepSeekModelWrite) })
330
- if (body.thinking === 'enabled' || body.thinking === 'disabled') {
331
- ops.push({ op: 'set', path: ['thinking'], value: body.thinking })
332
- }
333
- if (DEEPSEEK_LEVELS.includes(body.defaultEffort)) {
334
- ops.push({ op: 'set', path: ['reasoningEffort'], value: body.defaultEffort })
335
- }
336
- } else {
337
- const route = p.route
338
- if (!route) throw new Error('pi-ai 路由 key 缺失,无法写回')
339
- ops.push({ op: 'set', path: ['providers', route, 'models'], value: body.models.map((m) => piAiModelWrite(m, p)) })
340
- }
341
-
342
- const revision = body.revisions && typeof body.revisions[p.settingsNs] === 'number'
343
- ? body.revisions[p.settingsNs]
344
- : undefined
345
- try {
346
- await settings.mutate(p.settingsNs, ops, revision)
347
- } catch (e) {
348
- const msg = e && e.message ? e.message : String(e)
349
- const conflict = /conflict|revision/i.test(msg)
350
- const err = new Error(`写回官方配置被拒绝:${msg}`)
351
- err.statusCode = conflict ? 409 : 400
352
- throw err
353
- }
354
- return { ok: true, savedAt: Date.now() }
355
- }
356
-
357
- /** 功能注册表条目:index.js 组装 hostSetups 时使用。 */
358
- export const feature = {
359
- id: 'models',
360
- name: '模型设置',
361
- description: '模型目录读写:编辑输入类型与思考强度,写回官方配置热生效',
362
- defaultEnabled: false,
363
- setup(ctx) {
364
- const webServer = ctx.get('webServer')
365
- if (webServer === undefined) throw new Error('webServer 服务不可用,无法提供模型设置路由')
366
- const handler = async (req, res) => {
367
- try {
368
- if (req.method === 'GET') {
369
- const payload = readModelDirectory(ctx)
370
- await enrichRuntimeInput(ctx, payload)
371
- // 附带图片理解代理配置与自有命名空间 revision(面板保存用)
372
- try {
373
- const settings = ctx.get('settings')
374
- const v = settings && typeof settings.get === 'function' ? settings.get(DOCK_NS) : undefined
375
- payload.visionProxy = v && v.visionProxy
376
- ? { enabled: !!v.visionProxy.enabled, provider: String(v.visionProxy.provider || ''), model: String(v.visionProxy.model || '') }
377
- : { enabled: false, provider: '', model: '' }
378
- const desc = settings && typeof settings.describe === 'function'
379
- ? settings.describe({ redactSecrets: true }) : []
380
- for (const d of desc || []) {
381
- if (d && d.ns === DOCK_NS && d.revision !== undefined) payload.revisions[DOCK_NS] = d.revision
382
- }
383
- } catch {
384
- payload.visionProxy = { enabled: false, provider: '', model: '' }
385
- }
386
- sendJson(res, 200, payload)
387
- return
388
- }
389
- if (req.method === 'POST') {
390
- const body = await readBody(req)
391
- const result = await writeModelConfig(ctx, body)
392
- sendJson(res, 200, result)
393
- return
394
- }
395
- sendJson(res, 405, { error: `不支持的请求方法:${req.method}` })
396
- } catch (e) {
397
- const status = e && e.statusCode ? e.statusCode : 500
398
- sendJson(res, status, { ok: false, error: e instanceof Error ? e.message : String(e) })
399
- }
400
- }
401
- return webServer.register({ kind: 'exact', path: '/dsh-dock/models', handler })
402
- },
403
- }
1
+ // dsh-dock · 功能模块【模型设置】· 宿主半部(v0.3.0 迁自 index.js,行为不变)
2
+ //
3
+ // GET /dsh-dock/models —— 枚举全部已配置 Provider 的模型目录(含输入类型/思考强度)
4
+ // POST /dsh-dock/models —— 把面板编辑写回官方 settings(settings.mutate,热生效)
5
+ // 官方链路(集成不改内核):
6
+ // - 会话模型选择器按 model.reasoning.efforts 展示强度档;档位源自 Provider 配置:
7
+ // · pi-ai 路由:每模型 reasoningEfforts(档位 → wire 值;off:null 表示"支持关闭、不发参数";false = 不支持思考)
8
+ // · deepseek 官方:连接级 thinking(enabled/disabled)+ reasoningEffort 默认档,全模型共享 Off/Low/High/Max
9
+ // - 输入类型官方 schema 仅接受 text/image;「视频」等以 dockTags 标注随配置持久化(不参与请求路由)
10
+ // - schemastery 保留未知字段(实测),dockTags 对官方校验透明
11
+ // - POST 也收图片理解代理配置(visionProxy 分支,写 dsh-dock 自有命名空间)
12
+ import { DOCK_NS, sendJson, readBody, walkPath } from '../../src/host-core.js'
13
+
14
+ /** 官方 schema 接受的输入模态(写回配置的 input 只允许这两个)。 */
15
+ const HOST_MODALITIES = ['text', 'image']
16
+ /** pi-ai 支持的思考档位(升序)。 */
17
+ const PI_AI_LEVELS = ['off', 'minimal', 'low', 'medium', 'high', 'xhigh', 'max']
18
+ /** deepseek 官方支持的思考档位。 */
19
+ const DEEPSEEK_LEVELS = ['off', 'low', 'high', 'max']
20
+
21
+ /** deepseek-official 内置默认值(与 balance 模块同款兜底)。 */
22
+ const DEEPSEEK_DEFAULTS = {
23
+ apiKeyEnv: 'DEEPSEEK_API_KEY',
24
+ baseURL: 'https://api.deepseek.com',
25
+ }
26
+
27
+ function providerKindOf(entry) {
28
+ if (entry.settingsNs === 'llm-deepseek') return 'deepseek'
29
+ if (entry.settingsNs === 'llm-pi-ai') return 'pi-ai'
30
+ return 'other'
31
+ }
32
+
33
+ /** 正整数或 undefined(目录数字字段透传)。 */
34
+ function posInt(v) {
35
+ return Number.isInteger(v) && v > 0 ? v : undefined
36
+ }
37
+
38
+ /** 字符串数组透传(仅保留非空字符串)。 */
39
+ function strList(v) {
40
+ return Array.isArray(v) ? v.filter((x) => typeof x === 'string' && x) : []
41
+ }
42
+
43
+ /** 把 pi-ai 模型条目归一为面板视图(raw = 原始条目,保存时原样回传保留未知字段)。 */
44
+ function piAiModelView(entry, routeDefaultInput) {
45
+ const input = strList(entry.input).filter((m) => HOST_MODALITIES.includes(m))
46
+ const raw = entry && typeof entry === 'object' && !Array.isArray(entry) ? entry : {}
47
+ return {
48
+ id: typeof entry.id === 'string' ? entry.id : '',
49
+ name: typeof entry.name === 'string' ? entry.name : undefined,
50
+ contextWindow: posInt(entry.contextWindow),
51
+ maxTokens: posInt(entry.maxTokens),
52
+ input: input.length > 0 ? input : (strList(routeDefaultInput).length > 0 ? strList(routeDefaultInput) : ['text']),
53
+ inputInherited: input.length === 0,
54
+ efforts: raw.reasoningEfforts === undefined ? 'inherit' : raw.reasoningEfforts === false ? 'off' : 'custom',
55
+ effortMap: raw.reasoningEfforts && typeof raw.reasoningEfforts === 'object' ? raw.reasoningEfforts : null,
56
+ tags: strList(entry.dockTags),
57
+ raw,
58
+ }
59
+ }
60
+
61
+ /** 把 deepseek 目录模型归一为面板视图。 */
62
+ function deepSeekModelView(entry) {
63
+ const input = strList(entry.inputModalities).filter((m) => HOST_MODALITIES.includes(m))
64
+ const raw = entry && typeof entry === 'object' && !Array.isArray(entry) ? entry : {}
65
+ return {
66
+ id: typeof entry.id === 'string' ? entry.id : '',
67
+ name: typeof entry.name === 'string' ? entry.name : undefined,
68
+ contextWindow: posInt(entry.contextWindow),
69
+ maxTokens: posInt(entry.maxTokens),
70
+ input: input.length > 0 ? input : ['text'],
71
+ inputInherited: input.length === 0,
72
+ tags: strList(entry.dockTags),
73
+ raw,
74
+ }
75
+ }
76
+
77
+ /**
78
+ * 读模型目录:按"已配置"口径枚举 Provider,
79
+ * 输出各 Provider 的模型列表(含输入类型 / 思考强度)与各 settings 命名空间的 revision。
80
+ * (visionproxy 模块的目录兜底缓存也复用本函数)
81
+ */
82
+ export function readModelDirectory(ctx) {
83
+ const llm = ctx.get('llm')
84
+ const settings = ctx.get('settings')
85
+ const entries = (llm && llm.listConfigurableProviders ? llm.listConfigurableProviders() : []) || []
86
+ let views = []
87
+ try {
88
+ views = (settings && settings.describe ? settings.describe({ redactSecrets: true }) : []) || []
89
+ } catch {
90
+ // settings 未挂载
91
+ }
92
+ const descOf = (ns) => views.find((v) => v && String(v.ns) === ns)
93
+
94
+ let sel = null
95
+ try {
96
+ sel = (ctx.get('agentDefaultModel') && ctx.get('agentDefaultModel').currentSelection()) || null
97
+ } catch {
98
+ // 服务缺失
99
+ }
100
+
101
+ const providers = []
102
+ for (const e of entries) {
103
+ const kind = providerKindOf(e)
104
+ if (kind === 'other') continue // 官方之外的适配器暂不编辑(无稳定 schema)
105
+ const desc = descOf(e.settingsNs)
106
+ if (!desc) continue
107
+ const profileUser = desc.user ? walkPath(desc.user, e.settingsPath) : undefined
108
+ const profileBase = desc.base ? walkPath(desc.base, e.settingsPath) : undefined
109
+ const isDefaultProvider = !!(sel && sel.provider === e.provider)
110
+ // deepseek 官方始终可编辑(schema 默认目录兜底);其余按"已配置"口径
111
+ const configured = kind === 'deepseek' || isDefaultProvider
112
+ || (profileUser && typeof profileUser === 'object')
113
+ || (profileBase && typeof profileBase === 'object')
114
+ if (!configured) continue
115
+
116
+ const resolved = walkPath(desc.value, e.settingsPath) // schema 默认已套上的合成视图
117
+ if (kind === 'deepseek') {
118
+ const section = resolved && typeof resolved === 'object' ? resolved : {}
119
+ const models = Array.isArray(section.models) ? section.models : []
120
+ providers.push({
121
+ id: e.provider,
122
+ displayName: (profileUser && profileUser.displayName) || e.displayName || e.provider,
123
+ api: 'deepseek',
124
+ baseURL: (profileUser && profileUser.baseURL) || section.baseURL || DEEPSEEK_DEFAULTS.baseURL,
125
+ apiKeyEnv: (profileUser && profileUser.apiKeyEnv) || DEEPSEEK_DEFAULTS.apiKeyEnv,
126
+ kind,
127
+ settingsNs: e.settingsNs,
128
+ settingsPath: [...e.settingsPath],
129
+ thinking: section.thinking === 'disabled' ? 'disabled' : 'enabled',
130
+ defaultEffort: DEEPSEEK_LEVELS.includes(section.reasoningEffort) ? section.reasoningEffort : undefined,
131
+ effortLevels: DEEPSEEK_LEVELS.slice(),
132
+ models: models.map(deepSeekModelView),
133
+ })
134
+ } else {
135
+ const route = e.settingsPath[1] || e.provider
136
+ const profile = (profileUser && typeof profileUser === 'object' ? profileUser
137
+ : (profileBase && typeof profileBase === 'object' ? profileBase : resolved)) || {}
138
+ const resolvedProfile = (resolved && typeof resolved === 'object' ? resolved : {}) || {}
139
+ const models = Array.isArray(profile.models) ? profile.models : []
140
+ providers.push({
141
+ id: e.provider,
142
+ route,
143
+ displayName: profile.displayName || e.displayName || e.provider,
144
+ api: typeof profile.api === 'string' ? profile.api : undefined,
145
+ baseURL: typeof profile.baseURL === 'string' ? profile.baseURL : undefined,
146
+ apiKeyEnv: typeof profile.apiKeyEnv === 'string' ? profile.apiKeyEnv : undefined,
147
+ kind,
148
+ settingsNs: e.settingsNs,
149
+ settingsPath: [...e.settingsPath],
150
+ defaultInput: strList(resolvedProfile.defaultInput),
151
+ models: models.map((m) => piAiModelView(m && typeof m === 'object' ? m : { id: String(m) }, resolvedProfile.defaultInput)),
152
+ })
153
+ }
154
+ }
155
+
156
+ const revisions = {}
157
+ for (const ns of ['llm-pi-ai', 'llm-deepseek']) {
158
+ const d = descOf(ns)
159
+ if (d && d.revision !== undefined) revisions[ns] = d.revision
160
+ }
161
+ return { generatedAt: Date.now(), default: sel, revisions, providers }
162
+ }
163
+
164
+ /**
165
+ * 给目录视图补运行时视角的输入模态(llm.resolveModelInfo,与官方投影同源:
166
+ * entry.input → 内置目录 → 路由默认)。面板据此展示「原图直发 vs 走视觉代理」,
167
+ * 避免"条目留空但目录继承多模态"的模型被误分组。
168
+ * 注意:功能启用后 llm.resolveModelInfo 被本插件包装(虚拟多模态),此处须取原函数保真。
169
+ */
170
+ async function enrichRuntimeInput(ctx, dir) {
171
+ const llm = ctx.get('llm')
172
+ const resolve = llm && (llm.__dockOrigResolveModelInfo
173
+ || (typeof llm.resolveModelInfo === 'function' ? llm.resolveModelInfo.bind(llm) : null))
174
+ if (!resolve) return
175
+ await Promise.all((dir.providers || []).flatMap((p) => (p.models || []).map(async (m) => {
176
+ try {
177
+ const info = await resolve(p.id, m.id)
178
+ if (info && Array.isArray(info.inputModalities)) {
179
+ m.runtimeInput = info.inputModalities.filter((x) => HOST_MODALITIES.includes(x))
180
+ }
181
+ } catch {
182
+ // 单模型解析失败不影响整表(面板回退用 input 展示)
183
+ }
184
+ })))
185
+ }
186
+
187
+ /** 校验面板数字草稿并落到条目上(空 = 删除字段,继承默认)。 */
188
+ function setPosInt(entry, field, value) {
189
+ if (value === undefined || value === null || value === '') {
190
+ delete entry[field]
191
+ return
192
+ }
193
+ const n = Number(value)
194
+ if (!Number.isInteger(n) || n <= 0) throw new Error(`模型 ${entry.id} 的 ${field} 须为正整数`)
195
+ entry[field] = n
196
+ }
197
+
198
+ /**
199
+ * 校验面板提交的模型草稿,产出写回 pi-ai 的模型条目(不合法直接抛错)。
200
+ * 基于原条目(raw)合并编辑:未知字段(用户自配 compat/description 等)原样保留。
201
+ * 启用思考(custom)时的兜底(2026-08-22 事故修复):
202
+ * 不在 pi-ai 内置目录里的模型没有 catalog compat,检测默认 supportsDeveloperRole=true,
203
+ * 而 pi-ai 对 reasoning 模型会把 system 改写成 developer 角色 → 百炼等 OpenAI 兼容端点 400。
204
+ * 故凡启用思考一律显式 supportsDeveloperRole:false(system 全端点通用);
205
+ * 百炼/阿里云家族 baseURL 再补 thinkingFormat:'qwen'(与目录内模型一致,思考参数用 qwen 格式)。
206
+ */
207
+ function piAiModelWrite(m, p) {
208
+ if (!m || typeof m.id !== 'string' || !m.id) throw new Error('模型 id 不能为空')
209
+ const raw = m.raw && typeof m.raw === 'object' && !Array.isArray(m.raw) ? m.raw : {}
210
+ const entry = Object.assign({}, raw)
211
+ entry.id = m.id
212
+ if (typeof m.name === 'string' && m.name) entry.name = m.name
213
+ else delete entry.name
214
+ setPosInt(entry, 'contextWindow', m.contextWindow)
215
+ setPosInt(entry, 'maxTokens', m.maxTokens)
216
+ const input = Array.isArray(m.input) ? m.input.filter((x) => HOST_MODALITIES.includes(x)) : []
217
+ if (input.length > 0) entry.input = input // 留空 = 继承路由/目录默认
218
+ else delete entry.input
219
+ const tags = strList(m.tags)
220
+ if (tags.length > 0) entry.dockTags = tags // 标注(如 video),官方校验忽略、随配置持久化
221
+ else delete entry.dockTags
222
+ if (m.effortsMode === 'off') {
223
+ entry.reasoningEfforts = false
224
+ } else if (m.effortsMode === 'custom') {
225
+ const levels = m.effortLevels || {}
226
+ const map = {}
227
+ let real = 0
228
+ for (const lv of PI_AI_LEVELS) {
229
+ if (!levels[lv]) continue
230
+ if (lv === 'off') map.off = null // 支持关闭:不发参数
231
+ else { map[lv] = lv; real += 1 }
232
+ }
233
+ if (real === 0) throw new Error(`模型 ${m.id} 至少要勾选一个思考档位(off 之外)`)
234
+ entry.reasoningEfforts = map
235
+ // 目录路由(用户层未写 api/baseURL,靠 pi-ai 内置目录提供)视作 openai 兼容
236
+ const apiOk = !p.api || (typeof p.api === 'string' && p.api.indexOf('openai') === 0)
237
+ if (apiOk) {
238
+ const compat = Object.assign({}, entry.compat)
239
+ compat.supportsDeveloperRole = false
240
+ // 百炼/阿里云家族(路由 id 或 baseURL 命中)思考参数用 qwen 格式,与目录内模型一致
241
+ if (/qwen|dashscope|aliyuncs/i.test(String(p.route || '') + ' ' + String(p.baseURL || ''))) {
242
+ compat.thinkingFormat = 'qwen'
243
+ }
244
+ entry.compat = compat
245
+ }
246
+ } else {
247
+ delete entry.reasoningEfforts // inherit = 不写该字段,跟随内置目录
248
+ }
249
+ return entry
250
+ }
251
+
252
+ /** 校验并产出写回 deepseek 官方的目录模型条目(raw 未知字段保留)。 */
253
+ function deepSeekModelWrite(m) {
254
+ if (!m || typeof m.id !== 'string' || !m.id) throw new Error('模型 id 不能为空')
255
+ const raw = m.raw && typeof m.raw === 'object' && !Array.isArray(m.raw) ? m.raw : {}
256
+ const entry = Object.assign({}, raw)
257
+ entry.id = m.id
258
+ if (typeof m.name === 'string' && m.name) entry.name = m.name
259
+ else delete entry.name
260
+ setPosInt(entry, 'contextWindow', m.contextWindow)
261
+ setPosInt(entry, 'maxTokens', m.maxTokens)
262
+ const input = Array.isArray(m.input) ? m.input.filter((x) => HOST_MODALITIES.includes(x)) : []
263
+ if (input.includes('image')) entry.inputModalities = input // 纯文本留空走 schema 默认 ['text']
264
+ else delete entry.inputModalities
265
+ const tags = strList(m.tags)
266
+ if (tags.length > 0) entry.dockTags = tags
267
+ else delete entry.dockTags
268
+ return entry
269
+ }
270
+
271
+ /**
272
+ * 写回官方配置:按 Provider kind 生成 settings.mutate ops。
273
+ * 校验失败/官方 schema 拒绝时抛错(路由层转 4xx 带信息)。
274
+ */
275
+ async function writeModelConfig(ctx, body) {
276
+ if (!body || typeof body !== 'object') throw new Error('请求体不是 JSON 对象')
277
+
278
+ const settings = ctx.get('settings')
279
+ if (!settings || typeof settings.mutate !== 'function') throw new Error('settings 服务不可用或不可写')
280
+
281
+ // 图片理解代理配置:独立于 Provider 目录的小分支
282
+ if (body.visionProxy !== undefined) {
283
+ const vp = body.visionProxy
284
+ if (!vp || typeof vp !== 'object' || Array.isArray(vp)) throw new Error('visionProxy 格式不符')
285
+ const value = {
286
+ enabled: !!vp.enabled,
287
+ provider: typeof vp.provider === 'string' ? vp.provider : '',
288
+ model: typeof vp.model === 'string' ? vp.model : '',
289
+ }
290
+ if (value.enabled && (!value.provider || !value.model)) {
291
+ const err = new Error('启用图片理解代理需要选择视觉模型')
292
+ err.statusCode = 400
293
+ throw err
294
+ }
295
+ const revision = body.revisions && typeof body.revisions[DOCK_NS] === 'number' ? body.revisions[DOCK_NS] : undefined
296
+ try {
297
+ await settings.mutate(DOCK_NS, [{ op: 'set', path: ['visionProxy'], value }], revision)
298
+ } catch (e) {
299
+ const msg = e && e.message ? e.message : String(e)
300
+ const err = new Error(`保存图片理解代理配置被拒绝:${msg}`)
301
+ err.statusCode = /conflict|revision/i.test(msg) ? 409 : 400
302
+ throw err
303
+ }
304
+ return { ok: true, savedAt: Date.now() }
305
+ }
306
+
307
+ const dir = readModelDirectory(ctx)
308
+ if (typeof body.provider !== 'string' || !body.provider) {
309
+ // 典型场景:新 Client(图片代理面板)打到旧 Host(无 visionProxy 分支)
310
+ const err = new Error('请求缺少 provider——若正在保存图片代理配置,说明宿主进程是旧版本,重启 dsh web 后重试')
311
+ err.statusCode = 400
312
+ throw err
313
+ }
314
+ const p = dir.providers.find((x) => x.id === body.provider)
315
+ if (!p) throw new Error(`未找到 Provider:${body.provider}`)
316
+ if (p.kind === 'other') throw new Error('该 Provider 暂不支持编辑')
317
+ if (!Array.isArray(body.models)) throw new Error('models 必须是数组')
318
+
319
+ const seen = new Set()
320
+ for (const m of body.models) {
321
+ const id = m && typeof m.id === 'string' ? m.id : ''
322
+ if (!id) throw new Error('模型 id 不能为空')
323
+ if (seen.has(id)) throw new Error(`模型 id 重复:${id}`)
324
+ seen.add(id)
325
+ }
326
+
327
+ const ops = []
328
+ if (p.kind === 'deepseek') {
329
+ ops.push({ op: 'set', path: ['models'], value: body.models.map(deepSeekModelWrite) })
330
+ if (body.thinking === 'enabled' || body.thinking === 'disabled') {
331
+ ops.push({ op: 'set', path: ['thinking'], value: body.thinking })
332
+ }
333
+ if (DEEPSEEK_LEVELS.includes(body.defaultEffort)) {
334
+ ops.push({ op: 'set', path: ['reasoningEffort'], value: body.defaultEffort })
335
+ }
336
+ } else {
337
+ const route = p.route
338
+ if (!route) throw new Error('pi-ai 路由 key 缺失,无法写回')
339
+ ops.push({ op: 'set', path: ['providers', route, 'models'], value: body.models.map((m) => piAiModelWrite(m, p)) })
340
+ }
341
+
342
+ const revision = body.revisions && typeof body.revisions[p.settingsNs] === 'number'
343
+ ? body.revisions[p.settingsNs]
344
+ : undefined
345
+ try {
346
+ await settings.mutate(p.settingsNs, ops, revision)
347
+ } catch (e) {
348
+ const msg = e && e.message ? e.message : String(e)
349
+ const conflict = /conflict|revision/i.test(msg)
350
+ const err = new Error(`写回官方配置被拒绝:${msg}`)
351
+ err.statusCode = conflict ? 409 : 400
352
+ throw err
353
+ }
354
+ return { ok: true, savedAt: Date.now() }
355
+ }
356
+
357
+ /** 功能注册表条目:index.js 组装 hostSetups 时使用。 */
358
+ export const feature = {
359
+ id: 'models',
360
+ name: '模型设置',
361
+ description: '模型目录读写:编辑输入类型与思考强度,写回官方配置热生效',
362
+ defaultEnabled: false,
363
+ setup(ctx) {
364
+ const webServer = ctx.get('webServer')
365
+ if (webServer === undefined) throw new Error('webServer 服务不可用,无法提供模型设置路由')
366
+ const handler = async (req, res) => {
367
+ try {
368
+ if (req.method === 'GET') {
369
+ const payload = readModelDirectory(ctx)
370
+ await enrichRuntimeInput(ctx, payload)
371
+ // 附带图片理解代理配置与自有命名空间 revision(面板保存用)
372
+ try {
373
+ const settings = ctx.get('settings')
374
+ const v = settings && typeof settings.get === 'function' ? settings.get(DOCK_NS) : undefined
375
+ payload.visionProxy = v && v.visionProxy
376
+ ? { enabled: !!v.visionProxy.enabled, provider: String(v.visionProxy.provider || ''), model: String(v.visionProxy.model || '') }
377
+ : { enabled: false, provider: '', model: '' }
378
+ const desc = settings && typeof settings.describe === 'function'
379
+ ? settings.describe({ redactSecrets: true }) : []
380
+ for (const d of desc || []) {
381
+ if (d && d.ns === DOCK_NS && d.revision !== undefined) payload.revisions[DOCK_NS] = d.revision
382
+ }
383
+ } catch {
384
+ payload.visionProxy = { enabled: false, provider: '', model: '' }
385
+ }
386
+ sendJson(res, 200, payload)
387
+ return
388
+ }
389
+ if (req.method === 'POST') {
390
+ const body = await readBody(req)
391
+ const result = await writeModelConfig(ctx, body)
392
+ sendJson(res, 200, result)
393
+ return
394
+ }
395
+ sendJson(res, 405, { error: `不支持的请求方法:${req.method}` })
396
+ } catch (e) {
397
+ const status = e && e.statusCode ? e.statusCode : 500
398
+ sendJson(res, status, { ok: false, error: e instanceof Error ? e.message : String(e) })
399
+ }
400
+ }
401
+ return webServer.register({ kind: 'exact', path: '/dsh-dock/models', handler })
402
+ },
403
+ }