dsh-dock 0.11.2 → 0.11.5
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/LICENSE +18 -18
- package/README.md +255 -396
- package/client.js +596 -98
- package/cordis.patch.yml +2 -2
- package/features/animation/host.js +119 -119
- package/features/animation/view.jsx +1662 -1662
- package/features/balance/host.js +494 -494
- package/features/balance/view.js +351 -351
- package/features/games/games/breakout.jsx +227 -227
- package/features/games/games/gomoku.jsx +244 -244
- package/features/games/games/racer.jsx +169 -169
- package/features/games/games/shared.jsx +118 -118
- package/features/games/games/snake.jsx +144 -144
- package/features/games/games/sokoban.jsx +180 -180
- package/features/games/games/tank.jsx +128 -128
- package/features/games/games/tetris.jsx +308 -308
- package/features/games/games/xiangqi.jsx +277 -277
- package/features/games/tank-core.js +152 -152
- package/features/games/view.jsx +639 -639
- package/features/games/xiangqi-core.js +299 -299
- package/features/heartbeat/view.js +35 -35
- package/features/mobile-relay/gateway.js +472 -472
- package/features/mobile-relay/host.js +622 -570
- package/features/mobile-relay/view.jsx +380 -300
- package/features/modelconfig/host.js +406 -406
- package/features/modelconfig/view.js +490 -490
- package/features/notify/host.js +374 -374
- package/features/notify/view.jsx +751 -751
- package/features/theme/view.js +61 -61
- package/features/tokenlog/host.js +882 -883
- package/features/tokenlog/view.jsx +1369 -932
- package/features/visionproxy/host.js +315 -315
- package/index.js +195 -195
- package/package.json +66 -64
- package/screenshots/dock-tokenlog-charts-2.png +0 -0
- package/screenshots/dock-tokenlog-charts-3.png +0 -0
- package/screenshots/dock-tokenlog-charts.png +0 -0
- package/src/client.jsx +677 -677
- package/src/host-core.js +207 -207
- package/src/shared.js +192 -192
- package/src/task-track.js +354 -359
|
@@ -1,406 +1,406 @@
|
|
|
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 必须与客户端视图描述符(view.js 的 feature.id)一致:面板开关 / 补推同步都按它
|
|
360
|
-
// POST /dsh-dock/features,历史上这里叫 'models' 而客户端叫 'modelconfig',
|
|
361
|
-
// 开关永远被宿主当「未知功能」拒掉,目录接口随之 404(迁移见 host-core 的 migrateModelsFeatureId)。
|
|
362
|
-
id: 'modelconfig',
|
|
363
|
-
name: '模型设置',
|
|
364
|
-
description: '模型目录读写:编辑输入类型与思考强度,写回官方配置热生效',
|
|
365
|
-
defaultEnabled: false,
|
|
366
|
-
setup(ctx) {
|
|
367
|
-
const webServer = ctx.get('webServer')
|
|
368
|
-
if (webServer === undefined) throw new Error('webServer 服务不可用,无法提供模型设置路由')
|
|
369
|
-
const handler = async (req, res) => {
|
|
370
|
-
try {
|
|
371
|
-
if (req.method === 'GET') {
|
|
372
|
-
const payload = readModelDirectory(ctx)
|
|
373
|
-
await enrichRuntimeInput(ctx, payload)
|
|
374
|
-
// 附带图片理解代理配置与自有命名空间 revision(面板保存用)
|
|
375
|
-
try {
|
|
376
|
-
const settings = ctx.get('settings')
|
|
377
|
-
const v = settings && typeof settings.get === 'function' ? settings.get(DOCK_NS) : undefined
|
|
378
|
-
payload.visionProxy = v && v.visionProxy
|
|
379
|
-
? { enabled: !!v.visionProxy.enabled, provider: String(v.visionProxy.provider || ''), model: String(v.visionProxy.model || '') }
|
|
380
|
-
: { enabled: false, provider: '', model: '' }
|
|
381
|
-
const desc = settings && typeof settings.describe === 'function'
|
|
382
|
-
? settings.describe({ redactSecrets: true }) : []
|
|
383
|
-
for (const d of desc || []) {
|
|
384
|
-
if (d && d.ns === DOCK_NS && d.revision !== undefined) payload.revisions[DOCK_NS] = d.revision
|
|
385
|
-
}
|
|
386
|
-
} catch {
|
|
387
|
-
payload.visionProxy = { enabled: false, provider: '', model: '' }
|
|
388
|
-
}
|
|
389
|
-
sendJson(res, 200, payload)
|
|
390
|
-
return
|
|
391
|
-
}
|
|
392
|
-
if (req.method === 'POST') {
|
|
393
|
-
const body = await readBody(req)
|
|
394
|
-
const result = await writeModelConfig(ctx, body)
|
|
395
|
-
sendJson(res, 200, result)
|
|
396
|
-
return
|
|
397
|
-
}
|
|
398
|
-
sendJson(res, 405, { error: `不支持的请求方法:${req.method}` })
|
|
399
|
-
} catch (e) {
|
|
400
|
-
const status = e && e.statusCode ? e.statusCode : 500
|
|
401
|
-
sendJson(res, status, { ok: false, error: e instanceof Error ? e.message : String(e) })
|
|
402
|
-
}
|
|
403
|
-
}
|
|
404
|
-
return webServer.register({ kind: 'exact', path: '/dsh-dock/models', handler })
|
|
405
|
-
},
|
|
406
|
-
}
|
|
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 必须与客户端视图描述符(view.js 的 feature.id)一致:面板开关 / 补推同步都按它
|
|
360
|
+
// POST /dsh-dock/features,历史上这里叫 'models' 而客户端叫 'modelconfig',
|
|
361
|
+
// 开关永远被宿主当「未知功能」拒掉,目录接口随之 404(迁移见 host-core 的 migrateModelsFeatureId)。
|
|
362
|
+
id: 'modelconfig',
|
|
363
|
+
name: '模型设置',
|
|
364
|
+
description: '模型目录读写:编辑输入类型与思考强度,写回官方配置热生效',
|
|
365
|
+
defaultEnabled: false,
|
|
366
|
+
setup(ctx) {
|
|
367
|
+
const webServer = ctx.get('webServer')
|
|
368
|
+
if (webServer === undefined) throw new Error('webServer 服务不可用,无法提供模型设置路由')
|
|
369
|
+
const handler = async (req, res) => {
|
|
370
|
+
try {
|
|
371
|
+
if (req.method === 'GET') {
|
|
372
|
+
const payload = readModelDirectory(ctx)
|
|
373
|
+
await enrichRuntimeInput(ctx, payload)
|
|
374
|
+
// 附带图片理解代理配置与自有命名空间 revision(面板保存用)
|
|
375
|
+
try {
|
|
376
|
+
const settings = ctx.get('settings')
|
|
377
|
+
const v = settings && typeof settings.get === 'function' ? settings.get(DOCK_NS) : undefined
|
|
378
|
+
payload.visionProxy = v && v.visionProxy
|
|
379
|
+
? { enabled: !!v.visionProxy.enabled, provider: String(v.visionProxy.provider || ''), model: String(v.visionProxy.model || '') }
|
|
380
|
+
: { enabled: false, provider: '', model: '' }
|
|
381
|
+
const desc = settings && typeof settings.describe === 'function'
|
|
382
|
+
? settings.describe({ redactSecrets: true }) : []
|
|
383
|
+
for (const d of desc || []) {
|
|
384
|
+
if (d && d.ns === DOCK_NS && d.revision !== undefined) payload.revisions[DOCK_NS] = d.revision
|
|
385
|
+
}
|
|
386
|
+
} catch {
|
|
387
|
+
payload.visionProxy = { enabled: false, provider: '', model: '' }
|
|
388
|
+
}
|
|
389
|
+
sendJson(res, 200, payload)
|
|
390
|
+
return
|
|
391
|
+
}
|
|
392
|
+
if (req.method === 'POST') {
|
|
393
|
+
const body = await readBody(req)
|
|
394
|
+
const result = await writeModelConfig(ctx, body)
|
|
395
|
+
sendJson(res, 200, result)
|
|
396
|
+
return
|
|
397
|
+
}
|
|
398
|
+
sendJson(res, 405, { error: `不支持的请求方法:${req.method}` })
|
|
399
|
+
} catch (e) {
|
|
400
|
+
const status = e && e.statusCode ? e.statusCode : 500
|
|
401
|
+
sendJson(res, status, { ok: false, error: e instanceof Error ? e.message : String(e) })
|
|
402
|
+
}
|
|
403
|
+
}
|
|
404
|
+
return webServer.register({ kind: 'exact', path: '/dsh-dock/models', handler })
|
|
405
|
+
},
|
|
406
|
+
}
|