dsh-dock 0.15.0 → 0.16.0
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 +46 -16
- package/client.js +2392 -926
- package/features/animation/host.js +2 -1
- package/features/balance/host.js +2 -1
- package/features/mobile/host.js +9 -0
- package/features/mobile/view.js +20 -0
- package/features/mobile-relay/host.js +2 -1
- package/features/modelconfig/host.js +21 -462
- package/features/modelconfig/view.js +19 -704
- package/features/notify/host.js +2 -1
- package/features/notify/view.jsx +153 -30
- package/features/providerconfig/host.js +235 -0
- package/features/providerconfig/view.js +604 -0
- package/features/runstate/host.js +2 -1
- package/features/tokenlog/host.js +2 -1
- package/features/tts/host.js +164 -0
- package/features/tts/view.jsx +1189 -0
- package/features/visionproxy/host.js +1 -1
- package/index.js +9 -1
- package/package.json +3 -3
- package/screenshots/dock-modelconfig.png +0 -0
- package/src/client.jsx +54 -24
- package/src/host-core.js +84 -0
- package/src/model-directory.js +275 -0
- package/src/model-draft.js +119 -0
|
@@ -10,7 +10,7 @@
|
|
|
10
10
|
//
|
|
11
11
|
// 配置模型(schemastery schema 见 src/host-core.js DockConfig.animation):
|
|
12
12
|
// animationEnabled / effectMode / robotScale
|
|
13
|
-
import { ANIMATION_MODES, sendJson, readBody, readDockRoot, mutateDockSection } from '../../src/host-core.js'
|
|
13
|
+
import { ANIMATION_MODES, sendJson, readBody, rejectUntrustedRequest, readDockRoot, mutateDockSection } from '../../src/host-core.js'
|
|
14
14
|
import { acquireTaskTracker } from '../../src/task-track.js'
|
|
15
15
|
|
|
16
16
|
// 默认配置(schema 默认值一致;settings 未挂载时的兜底)
|
|
@@ -63,6 +63,7 @@ export const feature = {
|
|
|
63
63
|
kind: 'prefix',
|
|
64
64
|
path: '/dsh-dock/animation',
|
|
65
65
|
async handler(req, res) {
|
|
66
|
+
if (rejectUntrustedRequest(ctx, req, res)) return
|
|
66
67
|
try {
|
|
67
68
|
const url = new URL(req.url || '/', 'http://dsh.internal')
|
|
68
69
|
const method = url.pathname.replace(/^\/dsh-dock\/animation\/?/, '').split('/')[0] || ''
|
package/features/balance/host.js
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
// 按余额策略查询各账号余额/配额,返回归一化 JSON 给浏览器半身(同源 fetch,无需 typert)。
|
|
5
5
|
// 余额策略表沿用 @wycto/dsh-balance-panel@0.1.1 的实现(MIT,同作者)。
|
|
6
6
|
import { credentialRef } from '@deepseek-ai/dsh-credentials'
|
|
7
|
-
import { sendJson, walkPath } from '../../src/host-core.js'
|
|
7
|
+
import { sendJson, walkPath, rejectUntrustedRequest } from '../../src/host-core.js'
|
|
8
8
|
|
|
9
9
|
/** deepseek-official 内置默认值(适配器默认配置的镜像,配置缺失时兜底)。 */
|
|
10
10
|
const DEEPSEEK_DEFAULTS = {
|
|
@@ -480,6 +480,7 @@ export const feature = {
|
|
|
480
480
|
const webServer = ctx.get('webServer')
|
|
481
481
|
if (webServer === undefined) throw new Error('webServer 服务不可用,无法提供余额查询路由')
|
|
482
482
|
const handler = async (req, res) => {
|
|
483
|
+
if (rejectUntrustedRequest(ctx, req, res)) return
|
|
483
484
|
try {
|
|
484
485
|
sendJson(res, 200, await queryAll(ctx))
|
|
485
486
|
} catch (e) {
|
package/features/mobile/host.js
CHANGED
|
@@ -79,6 +79,15 @@ const MOBILE_LAYOUT_CSS = [
|
|
|
79
79
|
' .dsh-mobile-burger-badge.wait{background:#ef4444;animation:dsh-burger-badge-pulse 1.2s ease-in-out infinite}',
|
|
80
80
|
' @keyframes dsh-burger-badge-pulse{0%,100%{transform:scale(1)}50%{transform:scale(1.18)}}',
|
|
81
81
|
' @media (prefers-reduced-motion:reduce){.dsh-mobile-burger-badge.wait{animation:none}}',
|
|
82
|
+
// ── 给标题让出汉堡宽度(用户实测:标题被汉堡压住——截图里「更新所有仓库代码」的首字
|
|
83
|
+
// 直接叠在三条横线上)。汉堡是 fixed 定位(贴安全区、44×44),**不占布局**,而原生
|
|
84
|
+
// 标题从头部的 padding-left:20px 起排——真实实例 390×844 逐字实测:会话标题首字
|
|
85
|
+
// x=28、汉堡右缘 44 → **重叠 16px**;插件页/自动化任务页的页头(自身 padding-left:0,
|
|
86
|
+
// 靠外层容器给 24px)首字 x=24 → 重叠 20px。
|
|
87
|
+
// 改法照原生排版习惯走:**给容器补内边距**,而不是给汉堡加底色/加宽(那是把问题搬到
|
|
88
|
+
// 视觉上)。与客户端半部(features/mobile/view.js)逐字同款,改一处必须同步另一处。
|
|
89
|
+
' header:has([class*="titleRow"]){padding-left:calc(44px + env(safe-area-inset-left,0px))}',
|
|
90
|
+
' [class$="_pageHead"],[class$="_pageHeading"]{padding-left:calc(28px + env(safe-area-inset-left,0px));gap:4px}',
|
|
82
91
|
// ── 输入区吸附到视口底部:欢迎内容原生是 justify-content:center 垂直居中,
|
|
83
92
|
// 手机视口下输入卡片悬在中部、下方留出大片空白。改用「输入区 margin-top:auto」贴底。
|
|
84
93
|
// 底部 Tab 栏已移除,滚动体留白从 72px(Tab 栏高度 + 安全区)进一步收到
|
package/features/mobile/view.js
CHANGED
|
@@ -448,6 +448,26 @@ const css = [
|
|
|
448
448
|
".dsh-mobile-burger-badge.wait{background:#ef4444;animation:dsh-burger-badge-pulse 1.2s ease-in-out infinite}",
|
|
449
449
|
"@keyframes dsh-burger-badge-pulse{0%,100%{transform:scale(1)}50%{transform:scale(1.18)}}",
|
|
450
450
|
"@media (prefers-reduced-motion:reduce){.dsh-mobile-burger-badge.wait{animation:none}}",
|
|
451
|
+
// ⚠️ 给标题让出汉堡宽度(用户实测:标题被汉堡压住——截图里「更新所有仓库代码」的首字
|
|
452
|
+
// 直接叠在三条横线上)。汉堡是 fixed 定位(贴安全区、44×44),**不占布局**,而原生标题
|
|
453
|
+
// 从头部的 padding-left:20px 起排——真实实例 390×844 逐字实测:会话标题首字 x=28、
|
|
454
|
+
// 汉堡右缘 44 → **重叠 16px**;插件页/自动化任务页的页头(自身 padding-left:0,靠外层
|
|
455
|
+
// 容器给 24px)首字 x=24 → 重叠 20px。
|
|
456
|
+
// 改法照原生排版习惯走:**给容器补内边距**,而不是给汉堡加底色/加宽(那是把问题搬到视觉上)。
|
|
457
|
+
// · 会话页头 header:has([class*="titleRow"])(唯一含 titleRow 的 header):左内边距
|
|
458
|
+
// 20px → 44px,正好等于汉堡宽度;标题自带 8px 内边距 → 首字落在 x=52,与汉堡留 8px。
|
|
459
|
+
// · 插件页/自动化任务页头 [class$="_pageHead"] / [class$="_pageHeading"]
|
|
460
|
+
// (DSH 0.2.0-rc.2 实测:plugin-manager 的 ZVcBiW_pageHead、schedule 的
|
|
461
|
+
// CxUija_pageHeading):左内边距 0 → 28px → 首字 x=52,同样留 8px。
|
|
462
|
+
// 这两条页头是 flex + gap:16px(标题块 | 工具栏),补内边距会压缩内容宽度——实测
|
|
463
|
+
// 430/390/360/320px 下「添加插件」按钮与描述文案会被挤到换行(按钮从单行变两行)。
|
|
464
|
+
// 故同时把 gap 收到 4px 补回 12px:四个宽度实测行数零回归、无横向溢出。
|
|
465
|
+
// 用 `[class$=…]`(语义后缀)而非 `[class*=…]`:DSH 类名是「哈希前缀 + 语义后缀」,
|
|
466
|
+
// 后缀稳定、前缀随版本变;`$=` 也避开 `pageHeadActions` 这类同前缀的无关元素。
|
|
467
|
+
// 挂 body[data-dk-mobile="1"]:本模块 css 字段会被无条件并入全局样式,停用本功能即摘掉
|
|
468
|
+
// body 属性 → 规则空转、原生排版还原(与文件头「开关协议」一致)。
|
|
469
|
+
'body[data-dk-mobile="1"] header:has([class*="titleRow"]){padding-left:calc(44px + env(safe-area-inset-left,0px))}',
|
|
470
|
+
'body[data-dk-mobile="1"] [class$="_pageHead"],body[data-dk-mobile="1"] [class$="_pageHeading"]{padding-left:calc(28px + env(safe-area-inset-left,0px));gap:4px}',
|
|
451
471
|
// ⚠️ 「设置页滑不动」的关键兜底(客户端无条件携带 = 刷新即生效,不必等宿主重启):
|
|
452
472
|
// 窄屏把设置弹层改成纵向堆叠后,内容列若是默认 min-height:auto 就会被内容撑高
|
|
453
473
|
// (无头 390×844 实测列高 1216px 塞在 844px 视口里)、再被 .panel 的 overflow:hidden
|
|
@@ -20,7 +20,7 @@ import { stat } from 'node:fs/promises'
|
|
|
20
20
|
import { homedir } from 'node:os'
|
|
21
21
|
import { dirname, join } from 'node:path'
|
|
22
22
|
import yaml from 'js-yaml'
|
|
23
|
-
import { DOCK_NS, readBody, sendJson, readDockRoot, mutateDockSection } from '../../src/host-core.js'
|
|
23
|
+
import { DOCK_NS, readBody, sendJson, rejectUntrustedRequest, readDockRoot, mutateDockSection } from '../../src/host-core.js'
|
|
24
24
|
import { lanAddresses, startProtectedLanGateway } from './gateway.js'
|
|
25
25
|
|
|
26
26
|
function dshHome() { return process.env.DSH_HOME || join(homedir(), '.dsh') }
|
|
@@ -389,6 +389,7 @@ export const feature = {
|
|
|
389
389
|
kind: 'prefix',
|
|
390
390
|
path: '/dsh-dock/mobile-relay',
|
|
391
391
|
async handler(req, res) {
|
|
392
|
+
if (rejectUntrustedRequest(ctx, req, res)) return
|
|
392
393
|
try {
|
|
393
394
|
const method = routeMethod(req)
|
|
394
395
|
const payload = await readBody(req)
|
|
@@ -1,7 +1,12 @@
|
|
|
1
|
-
// dsh-dock · 功能模块【模型设置】·
|
|
1
|
+
// dsh-dock · 功能模块【模型设置】· 宿主半部
|
|
2
|
+
//
|
|
3
|
+
// GET /dsh-dock/models —— 读模型目录(各 Provider 的模型、输入模态、思考强度)
|
|
4
|
+
// POST /dsh-dock/models —— 把面板对**单个模型**的编辑写回官方 settings(热生效)
|
|
5
|
+
//
|
|
6
|
+
// 边界(Provider 配置拆出后):本模块只管模型条目自身的属性——输入模态
|
|
7
|
+
// (text/image)与思考强度/默认档。Provider 自身的增删改、API Key、拉取上游模型
|
|
8
|
+
// 列表属于【模型提供商】(features/providerconfig/),两个功能可各自启停。
|
|
2
9
|
//
|
|
3
|
-
// GET /dsh-dock/models —— 枚举全部已配置 Provider 的模型目录(含输入类型/思考强度)
|
|
4
|
-
// POST /dsh-dock/models —— 把面板编辑写回官方 settings(settings.mutate,热生效)
|
|
5
10
|
// 官方链路(集成不改内核):
|
|
6
11
|
// - 会话模型选择器按 model.reasoning.efforts 展示强度档;档位源自 Provider 配置:
|
|
7
12
|
// · pi-ai 路由:每模型 reasoningEfforts(档位 → wire 值;off:null 表示"支持关闭、不发参数";false = 不支持思考)
|
|
@@ -9,293 +14,14 @@
|
|
|
9
14
|
// - 输入类型官方 schema 仅接受 text/image;「视频」等以 dockTags 标注随配置持久化(不参与请求路由)
|
|
10
15
|
// - schemastery 保留未知字段(实测),dockTags 对官方校验透明
|
|
11
16
|
// - POST 也收图片理解代理配置(visionProxy 分支,写 dsh-dock 自有命名空间)
|
|
12
|
-
import { DOCK_NS, sendJson, readBody,
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
/** pi-ai 路由支持的 API 协议。 */
|
|
21
|
-
const PI_AI_APIS = ['openai-completions', 'openai-responses', 'anthropic-messages']
|
|
22
|
-
/** Provider 路由 key(= settings 里 providers.<route> 的 key),须能安全用作对象键与凭据寻址。 */
|
|
23
|
-
const ROUTE_KEY_RE = /^[A-Za-z0-9][A-Za-z0-9._-]*$/
|
|
24
|
-
/** pi-ai 支持的思考档位(升序)。 */
|
|
25
|
-
const PI_AI_LEVELS = ['off', 'minimal', 'low', 'medium', 'high', 'xhigh', 'max']
|
|
26
|
-
/** deepseek 官方支持的思考档位。 */
|
|
27
|
-
const DEEPSEEK_LEVELS = ['off', 'low', 'high', 'max']
|
|
28
|
-
|
|
29
|
-
/** deepseek-official 内置默认值(与 balance 模块同款兜底)。 */
|
|
30
|
-
const DEEPSEEK_DEFAULTS = {
|
|
31
|
-
apiKeyEnv: 'DEEPSEEK_API_KEY',
|
|
32
|
-
baseURL: 'https://api.deepseek.com',
|
|
33
|
-
}
|
|
34
|
-
|
|
35
|
-
function providerKindOf(entry) {
|
|
36
|
-
if (entry.settingsNs === 'llm-deepseek') return 'deepseek'
|
|
37
|
-
if (entry.settingsNs === 'llm-pi-ai') return 'pi-ai'
|
|
38
|
-
return 'other'
|
|
39
|
-
}
|
|
40
|
-
|
|
41
|
-
/** 正整数或 undefined(目录数字字段透传)。 */
|
|
42
|
-
function posInt(v) {
|
|
43
|
-
return Number.isInteger(v) && v > 0 ? v : undefined
|
|
44
|
-
}
|
|
45
|
-
|
|
46
|
-
/** 字符串数组透传(仅保留非空字符串)。 */
|
|
47
|
-
function strList(v) {
|
|
48
|
-
return Array.isArray(v) ? v.filter((x) => typeof x === 'string' && x) : []
|
|
49
|
-
}
|
|
50
|
-
|
|
51
|
-
/** 把 pi-ai 模型条目归一为面板视图(raw = 原始条目,保存时原样回传保留未知字段)。 */
|
|
52
|
-
function piAiModelView(entry, routeDefaultInput) {
|
|
53
|
-
const input = strList(entry.input).filter((m) => HOST_MODALITIES.includes(m))
|
|
54
|
-
const raw = entry && typeof entry === 'object' && !Array.isArray(entry) ? entry : {}
|
|
55
|
-
return {
|
|
56
|
-
id: typeof entry.id === 'string' ? entry.id : '',
|
|
57
|
-
name: typeof entry.name === 'string' ? entry.name : undefined,
|
|
58
|
-
contextWindow: posInt(entry.contextWindow),
|
|
59
|
-
maxTokens: posInt(entry.maxTokens),
|
|
60
|
-
input: input.length > 0 ? input : (strList(routeDefaultInput).length > 0 ? strList(routeDefaultInput) : ['text']),
|
|
61
|
-
inputInherited: input.length === 0,
|
|
62
|
-
efforts: raw.reasoningEfforts === undefined ? 'inherit' : raw.reasoningEfforts === false ? 'off' : 'custom',
|
|
63
|
-
effortMap: raw.reasoningEfforts && typeof raw.reasoningEfforts === 'object' ? raw.reasoningEfforts : null,
|
|
64
|
-
tags: strList(entry.dockTags),
|
|
65
|
-
raw,
|
|
66
|
-
}
|
|
67
|
-
}
|
|
68
|
-
|
|
69
|
-
/** 把 deepseek 目录模型归一为面板视图。 */
|
|
70
|
-
function deepSeekModelView(entry) {
|
|
71
|
-
const input = strList(entry.inputModalities).filter((m) => HOST_MODALITIES.includes(m))
|
|
72
|
-
const raw = entry && typeof entry === 'object' && !Array.isArray(entry) ? entry : {}
|
|
73
|
-
return {
|
|
74
|
-
id: typeof entry.id === 'string' ? entry.id : '',
|
|
75
|
-
name: typeof entry.name === 'string' ? entry.name : undefined,
|
|
76
|
-
contextWindow: posInt(entry.contextWindow),
|
|
77
|
-
maxTokens: posInt(entry.maxTokens),
|
|
78
|
-
input: input.length > 0 ? input : ['text'],
|
|
79
|
-
inputInherited: input.length === 0,
|
|
80
|
-
tags: strList(entry.dockTags),
|
|
81
|
-
raw,
|
|
82
|
-
}
|
|
83
|
-
}
|
|
84
|
-
|
|
85
|
-
/**
|
|
86
|
-
* 读模型目录:按"已配置"口径枚举 Provider,
|
|
87
|
-
* 输出各 Provider 的模型列表(含输入类型 / 思考强度)与各 settings 命名空间的 revision。
|
|
88
|
-
* (visionproxy 模块的目录兜底缓存也复用本函数)
|
|
89
|
-
*/
|
|
90
|
-
export function readModelDirectory(ctx) {
|
|
91
|
-
const llm = ctx.get('llm')
|
|
92
|
-
const settings = ctx.get('settings')
|
|
93
|
-
const entries = (llm && llm.listConfigurableProviders ? llm.listConfigurableProviders() : []) || []
|
|
94
|
-
let views = []
|
|
95
|
-
try {
|
|
96
|
-
views = (settings && settings.describe ? settings.describe({ redactSecrets: true }) : []) || []
|
|
97
|
-
} catch {
|
|
98
|
-
// settings 未挂载
|
|
99
|
-
}
|
|
100
|
-
const descOf = (ns) => views.find((v) => v && String(v.ns) === ns)
|
|
101
|
-
|
|
102
|
-
let sel = null
|
|
103
|
-
try {
|
|
104
|
-
sel = (ctx.get('agentDefaultModel') && ctx.get('agentDefaultModel').currentSelection()) || null
|
|
105
|
-
} catch {
|
|
106
|
-
// 服务缺失
|
|
107
|
-
}
|
|
108
|
-
|
|
109
|
-
const providers = []
|
|
110
|
-
for (const e of entries) {
|
|
111
|
-
const kind = providerKindOf(e)
|
|
112
|
-
if (kind === 'other') continue // 官方之外的适配器暂不编辑(无稳定 schema)
|
|
113
|
-
const desc = descOf(e.settingsNs)
|
|
114
|
-
if (!desc) continue
|
|
115
|
-
const profileUser = desc.user ? walkPath(desc.user, e.settingsPath) : undefined
|
|
116
|
-
const profileBase = desc.base ? walkPath(desc.base, e.settingsPath) : undefined
|
|
117
|
-
const isDefaultProvider = !!(sel && sel.provider === e.provider)
|
|
118
|
-
// deepseek 官方始终可编辑(schema 默认目录兜底);其余按"已配置"口径
|
|
119
|
-
const configured = kind === 'deepseek' || isDefaultProvider
|
|
120
|
-
|| (profileUser && typeof profileUser === 'object')
|
|
121
|
-
|| (profileBase && typeof profileBase === 'object')
|
|
122
|
-
if (!configured) continue
|
|
123
|
-
|
|
124
|
-
const resolved = walkPath(desc.value, e.settingsPath) // schema 默认已套上的合成视图
|
|
125
|
-
if (kind === 'deepseek') {
|
|
126
|
-
const section = resolved && typeof resolved === 'object' ? resolved : {}
|
|
127
|
-
const models = Array.isArray(section.models) ? section.models : []
|
|
128
|
-
providers.push({
|
|
129
|
-
id: e.provider,
|
|
130
|
-
displayName: (profileUser && profileUser.displayName) || e.displayName || e.provider,
|
|
131
|
-
api: 'deepseek',
|
|
132
|
-
baseURL: (profileUser && profileUser.baseURL) || section.baseURL || DEEPSEEK_DEFAULTS.baseURL,
|
|
133
|
-
apiKeyEnv: (profileUser && profileUser.apiKeyEnv) || DEEPSEEK_DEFAULTS.apiKeyEnv,
|
|
134
|
-
kind,
|
|
135
|
-
settingsNs: e.settingsNs,
|
|
136
|
-
settingsPath: [...e.settingsPath],
|
|
137
|
-
thinking: section.thinking === 'disabled' ? 'disabled' : 'enabled',
|
|
138
|
-
defaultEffort: DEEPSEEK_LEVELS.includes(section.reasoningEffort) ? section.reasoningEffort : undefined,
|
|
139
|
-
effortLevels: DEEPSEEK_LEVELS.slice(),
|
|
140
|
-
models: models.map(deepSeekModelView),
|
|
141
|
-
})
|
|
142
|
-
} else {
|
|
143
|
-
const route = e.settingsPath[1] || e.provider
|
|
144
|
-
const profile = (profileUser && typeof profileUser === 'object' ? profileUser
|
|
145
|
-
: (profileBase && typeof profileBase === 'object' ? profileBase : resolved)) || {}
|
|
146
|
-
const resolvedProfile = (resolved && typeof resolved === 'object' ? resolved : {}) || {}
|
|
147
|
-
const models = Array.isArray(profile.models) ? profile.models : []
|
|
148
|
-
providers.push({
|
|
149
|
-
id: e.provider,
|
|
150
|
-
route,
|
|
151
|
-
displayName: profile.displayName || e.displayName || e.provider,
|
|
152
|
-
api: typeof profile.api === 'string' ? profile.api : undefined,
|
|
153
|
-
baseURL: typeof profile.baseURL === 'string' ? profile.baseURL : undefined,
|
|
154
|
-
apiKeyEnv: typeof profile.apiKeyEnv === 'string' ? profile.apiKeyEnv : undefined,
|
|
155
|
-
kind,
|
|
156
|
-
settingsNs: e.settingsNs,
|
|
157
|
-
settingsPath: [...e.settingsPath],
|
|
158
|
-
// 用户层原始配置:编辑表单按它回填,未知字段(自配 compat/description 等)原样保留。
|
|
159
|
-
rawProfile: profileUser && typeof profileUser === 'object' && !Array.isArray(profileUser) ? profileUser : {},
|
|
160
|
-
apis: PI_AI_APIS.slice(),
|
|
161
|
-
defaultInput: strList(resolvedProfile.defaultInput),
|
|
162
|
-
models: models.map((m) => piAiModelView(m && typeof m === 'object' ? m : { id: String(m) }, resolvedProfile.defaultInput)),
|
|
163
|
-
})
|
|
164
|
-
}
|
|
165
|
-
}
|
|
166
|
-
|
|
167
|
-
const revisions = {}
|
|
168
|
-
for (const ns of ['llm-pi-ai', 'llm-deepseek']) {
|
|
169
|
-
const d = descOf(ns)
|
|
170
|
-
if (d && d.revision !== undefined) revisions[ns] = d.revision
|
|
171
|
-
}
|
|
172
|
-
return { generatedAt: Date.now(), default: sel, revisions, providers }
|
|
173
|
-
}
|
|
174
|
-
|
|
175
|
-
/**
|
|
176
|
-
* 给目录视图补运行时视角的输入模态(llm.resolveModelInfo,与官方投影同源:
|
|
177
|
-
* entry.input → 内置目录 → 路由默认)。面板据此展示「原图直发 vs 走视觉代理」,
|
|
178
|
-
* 避免"条目留空但目录继承多模态"的模型被误分组。
|
|
179
|
-
* 注意:功能启用后 llm.resolveModelInfo 被本插件包装(虚拟多模态),此处须取原函数保真。
|
|
180
|
-
*/
|
|
181
|
-
async function enrichRuntimeInput(ctx, dir) {
|
|
182
|
-
const llm = ctx.get('llm')
|
|
183
|
-
const resolve = llm && (llm.__dockOrigResolveModelInfo
|
|
184
|
-
|| (typeof llm.resolveModelInfo === 'function' ? llm.resolveModelInfo.bind(llm) : null))
|
|
185
|
-
if (!resolve) return
|
|
186
|
-
await Promise.all((dir.providers || []).flatMap((p) => (p.models || []).map(async (m) => {
|
|
187
|
-
try {
|
|
188
|
-
const info = await resolve(p.id, m.id)
|
|
189
|
-
if (info && Array.isArray(info.inputModalities)) {
|
|
190
|
-
m.runtimeInput = info.inputModalities.filter((x) => HOST_MODALITIES.includes(x))
|
|
191
|
-
}
|
|
192
|
-
} catch {
|
|
193
|
-
// 单模型解析失败不影响整表(面板回退用 input 展示)
|
|
194
|
-
}
|
|
195
|
-
})))
|
|
196
|
-
}
|
|
197
|
-
|
|
198
|
-
/**
|
|
199
|
-
* 并行补 Provider 的 apiKeyConfigured:apiKeyEnv 指向的条目是否已在本地凭据库配置。
|
|
200
|
-
* 凭据库不可用 / 环境变量名非法时一律降级为 false(不抛错,避免整表 500)。
|
|
201
|
-
*/
|
|
202
|
-
async function enrichApiKeyStatus(ctx, dir) {
|
|
203
|
-
const credentials = ctx.get('credentials')
|
|
204
|
-
if (!credentials || typeof credentials.describe !== 'function') return
|
|
205
|
-
await Promise.all((dir.providers || []).filter((p) => p.kind === 'pi-ai').map(async (p) => {
|
|
206
|
-
if (typeof p.apiKeyEnv !== 'string' || !p.apiKeyEnv) { p.apiKeyConfigured = false; return }
|
|
207
|
-
try {
|
|
208
|
-
const d = await credentials.describe(credentialRef(p.apiKeyEnv))
|
|
209
|
-
p.apiKeyConfigured = !!(d && d.configured)
|
|
210
|
-
} catch {
|
|
211
|
-
p.apiKeyConfigured = false
|
|
212
|
-
}
|
|
213
|
-
}))
|
|
214
|
-
}
|
|
215
|
-
|
|
216
|
-
/** 校验面板数字草稿并落到条目上(空 = 删除字段,继承默认)。 */
|
|
217
|
-
function setPosInt(entry, field, value) {
|
|
218
|
-
if (value === undefined || value === null || value === '') {
|
|
219
|
-
delete entry[field]
|
|
220
|
-
return
|
|
221
|
-
}
|
|
222
|
-
const n = Number(value)
|
|
223
|
-
if (!Number.isInteger(n) || n <= 0) throw new Error(`模型 ${entry.id} 的 ${field} 须为正整数`)
|
|
224
|
-
entry[field] = n
|
|
225
|
-
}
|
|
226
|
-
|
|
227
|
-
/**
|
|
228
|
-
* 校验面板提交的模型草稿,产出写回 pi-ai 的模型条目(不合法直接抛错)。
|
|
229
|
-
* 基于原条目(raw)合并编辑:未知字段(用户自配 compat/description 等)原样保留。
|
|
230
|
-
* 启用思考(custom)时的兜底(2026-08-22 事故修复):
|
|
231
|
-
* 不在 pi-ai 内置目录里的模型没有 catalog compat,检测默认 supportsDeveloperRole=true,
|
|
232
|
-
* 而 pi-ai 对 reasoning 模型会把 system 改写成 developer 角色 → 百炼等 OpenAI 兼容端点 400。
|
|
233
|
-
* 故凡启用思考一律显式 supportsDeveloperRole:false(system 全端点通用);
|
|
234
|
-
* 百炼/阿里云家族 baseURL 再补 thinkingFormat:'qwen'(与目录内模型一致,思考参数用 qwen 格式)。
|
|
235
|
-
*/
|
|
236
|
-
function piAiModelWrite(m, p) {
|
|
237
|
-
if (!m || typeof m.id !== 'string' || !m.id) throw new Error('模型 id 不能为空')
|
|
238
|
-
const raw = m.raw && typeof m.raw === 'object' && !Array.isArray(m.raw) ? m.raw : {}
|
|
239
|
-
const entry = Object.assign({}, raw)
|
|
240
|
-
entry.id = m.id
|
|
241
|
-
if (typeof m.name === 'string' && m.name) entry.name = m.name
|
|
242
|
-
else delete entry.name
|
|
243
|
-
setPosInt(entry, 'contextWindow', m.contextWindow)
|
|
244
|
-
setPosInt(entry, 'maxTokens', m.maxTokens)
|
|
245
|
-
const input = Array.isArray(m.input) ? m.input.filter((x) => HOST_MODALITIES.includes(x)) : []
|
|
246
|
-
if (input.length > 0) entry.input = input // 留空 = 继承路由/目录默认
|
|
247
|
-
else delete entry.input
|
|
248
|
-
const tags = strList(m.tags)
|
|
249
|
-
if (tags.length > 0) entry.dockTags = tags // 标注(如 video),官方校验忽略、随配置持久化
|
|
250
|
-
else delete entry.dockTags
|
|
251
|
-
if (m.effortsMode === 'off') {
|
|
252
|
-
entry.reasoningEfforts = false
|
|
253
|
-
} else if (m.effortsMode === 'custom') {
|
|
254
|
-
const levels = m.effortLevels || {}
|
|
255
|
-
const map = {}
|
|
256
|
-
let real = 0
|
|
257
|
-
for (const lv of PI_AI_LEVELS) {
|
|
258
|
-
if (!levels[lv]) continue
|
|
259
|
-
if (lv === 'off') map.off = null // 支持关闭:不发参数
|
|
260
|
-
else { map[lv] = lv; real += 1 }
|
|
261
|
-
}
|
|
262
|
-
if (real === 0) throw new Error(`模型 ${m.id} 至少要勾选一个思考档位(off 之外)`)
|
|
263
|
-
entry.reasoningEfforts = map
|
|
264
|
-
// 目录路由(用户层未写 api/baseURL,靠 pi-ai 内置目录提供)视作 openai 兼容
|
|
265
|
-
const apiOk = !p.api || (typeof p.api === 'string' && p.api.indexOf('openai') === 0)
|
|
266
|
-
if (apiOk) {
|
|
267
|
-
const compat = Object.assign({}, entry.compat)
|
|
268
|
-
compat.supportsDeveloperRole = false
|
|
269
|
-
// 百炼/阿里云家族(路由 id 或 baseURL 命中)思考参数用 qwen 格式,与目录内模型一致
|
|
270
|
-
if (/qwen|dashscope|aliyuncs/i.test(String(p.route || '') + ' ' + String(p.baseURL || ''))) {
|
|
271
|
-
compat.thinkingFormat = 'qwen'
|
|
272
|
-
}
|
|
273
|
-
entry.compat = compat
|
|
274
|
-
}
|
|
275
|
-
} else {
|
|
276
|
-
delete entry.reasoningEfforts // inherit = 不写该字段,跟随内置目录
|
|
277
|
-
}
|
|
278
|
-
return entry
|
|
279
|
-
}
|
|
280
|
-
|
|
281
|
-
/** 校验并产出写回 deepseek 官方的目录模型条目(raw 未知字段保留)。 */
|
|
282
|
-
function deepSeekModelWrite(m) {
|
|
283
|
-
if (!m || typeof m.id !== 'string' || !m.id) throw new Error('模型 id 不能为空')
|
|
284
|
-
const raw = m.raw && typeof m.raw === 'object' && !Array.isArray(m.raw) ? m.raw : {}
|
|
285
|
-
const entry = Object.assign({}, raw)
|
|
286
|
-
entry.id = m.id
|
|
287
|
-
if (typeof m.name === 'string' && m.name) entry.name = m.name
|
|
288
|
-
else delete entry.name
|
|
289
|
-
setPosInt(entry, 'contextWindow', m.contextWindow)
|
|
290
|
-
setPosInt(entry, 'maxTokens', m.maxTokens)
|
|
291
|
-
const input = Array.isArray(m.input) ? m.input.filter((x) => HOST_MODALITIES.includes(x)) : []
|
|
292
|
-
if (input.includes('image')) entry.inputModalities = input // 纯文本留空走 schema 默认 ['text']
|
|
293
|
-
else delete entry.inputModalities
|
|
294
|
-
const tags = strList(m.tags)
|
|
295
|
-
if (tags.length > 0) entry.dockTags = tags
|
|
296
|
-
else delete entry.dockTags
|
|
297
|
-
return entry
|
|
298
|
-
}
|
|
17
|
+
import { DOCK_NS, sendJson, readBody, rejectUntrustedRequest, readDockRoot, mutateDockSection } from '../../src/host-core.js'
|
|
18
|
+
import {
|
|
19
|
+
DEEPSEEK_LEVELS,
|
|
20
|
+
readModelDirectory,
|
|
21
|
+
enrichRuntimeInput,
|
|
22
|
+
piAiModelWrite,
|
|
23
|
+
deepSeekModelWrite,
|
|
24
|
+
} from '../../src/model-directory.js'
|
|
299
25
|
|
|
300
26
|
/**
|
|
301
27
|
* 写回官方配置:按 Provider kind 生成 settings.mutate ops。
|
|
@@ -307,7 +33,8 @@ async function writeModelConfig(ctx, body) {
|
|
|
307
33
|
const settings = ctx.get('settings')
|
|
308
34
|
if (!settings || typeof settings.mutate !== 'function') throw new Error('settings 服务不可用或不可写')
|
|
309
35
|
|
|
310
|
-
//
|
|
36
|
+
// 图片理解代理配置:独立于模型目录的小分支(该功能的界面挂在【模型设置】页内,
|
|
37
|
+
// 拆出 Provider 配置时未动它,仍是同一条 POST 的旁路)。
|
|
311
38
|
if (body.visionProxy !== undefined) {
|
|
312
39
|
const vp = body.visionProxy
|
|
313
40
|
if (!vp || typeof vp !== 'object' || Array.isArray(vp)) throw new Error('visionProxy 格式不符')
|
|
@@ -383,182 +110,21 @@ async function writeModelConfig(ctx, body) {
|
|
|
383
110
|
return { ok: true, savedAt: Date.now() }
|
|
384
111
|
}
|
|
385
112
|
|
|
386
|
-
/**
|
|
387
|
-
* Provider 级增 / 改 / 删(走 llm-pi-ai 的 providers 字典,不改内核)。
|
|
388
|
-
* body.action = 'create' | 'update' | 'delete'
|
|
389
|
-
* body.route = providers 字典的 key(= 路由 id)
|
|
390
|
-
* body.profile = { displayName, api, baseURL, apiKeyEnv, defaultInput, models: [草稿...] }
|
|
391
|
-
* body.apiKey = 可选明文 Key(写本地凭据库,不落到配置文件)
|
|
392
|
-
* body.revisions['llm-pi-ai'] = GET 拿到的 revision(乐观锁,撞车 409)
|
|
393
|
-
* 未在 profile 里出现的未知字段?——不会:base 以原 profile 为基础合并,用户自配的
|
|
394
|
-
* compat / description 等原样保留,只覆盖面板负责的那几个键。
|
|
395
|
-
*/
|
|
396
|
-
async function writeProviderConfig(ctx, body) {
|
|
397
|
-
const settings = ctx.get('settings')
|
|
398
|
-
if (!settings || typeof settings.mutate !== 'function') throw new Error('settings 服务不可用或不可写')
|
|
399
|
-
const action = body.action
|
|
400
|
-
const route = typeof body.route === 'string' ? body.route.trim() : ''
|
|
401
|
-
const revision = body.revisions && typeof body.revisions['llm-pi-ai'] === 'number'
|
|
402
|
-
? body.revisions['llm-pi-ai'] : undefined
|
|
403
|
-
const bad = (msg) => { const e = new Error(msg); e.statusCode = 400; return e }
|
|
404
|
-
|
|
405
|
-
if (action === 'delete') {
|
|
406
|
-
if (!route) throw bad('请求缺少 route')
|
|
407
|
-
const dir = readModelDirectory(ctx)
|
|
408
|
-
const p = (dir.providers || []).find((x) => (x.route || x.id) === route)
|
|
409
|
-
if (!p) throw bad(`未找到 Provider:${route}`)
|
|
410
|
-
if (p.kind !== 'pi-ai') throw bad('内置 Provider(如 DeepSeek 官方)不能删除,只能编辑其模型')
|
|
411
|
-
try {
|
|
412
|
-
await settings.mutate('llm-pi-ai', [{ op: 'unset', path: ['providers', p.route || route] }], revision)
|
|
413
|
-
} catch (e) {
|
|
414
|
-
const msg = e && e.message ? e.message : String(e)
|
|
415
|
-
const err = new Error(`删除被拒绝:${msg}`)
|
|
416
|
-
err.statusCode = /conflict|revision/i.test(msg) ? 409 : 400
|
|
417
|
-
throw err
|
|
418
|
-
}
|
|
419
|
-
return { ok: true, deleted: p.route || route, savedAt: Date.now() }
|
|
420
|
-
}
|
|
421
|
-
|
|
422
|
-
if (action !== 'create' && action !== 'update') throw bad(`不支持的 action:${action}`)
|
|
423
|
-
if (!route || !ROUTE_KEY_RE.test(route)) throw bad('路由 key 非法:须以字母数字开头,只能含字母、数字与 . _ -')
|
|
424
|
-
const src = body.profile && typeof body.profile === 'object' && !Array.isArray(body.profile) ? body.profile : {}
|
|
425
|
-
const api = typeof src.api === 'string' ? src.api : ''
|
|
426
|
-
if (PI_AI_APIS.indexOf(api) < 0) throw bad(`api 非法:只支持 ${PI_AI_APIS.join(' / ')}`)
|
|
427
|
-
const baseURL = typeof src.baseURL === 'string' ? src.baseURL.trim() : ''
|
|
428
|
-
if (!/^https?:\/\//i.test(baseURL)) throw bad('baseURL 非法:须以 http:// 或 https:// 开头')
|
|
429
|
-
let apiKeyEnv = typeof src.apiKeyEnv === 'string' ? src.apiKeyEnv.trim() : ''
|
|
430
|
-
if (apiKeyEnv) {
|
|
431
|
-
try { credentialRef(apiKeyEnv) } catch { throw bad('apiKeyEnv 非法:须是合法的环境变量名(如 MODELSCOPE_API_KEY)') }
|
|
432
|
-
}
|
|
433
|
-
const models = Array.isArray(src.models) ? src.models : []
|
|
434
|
-
if (models.length === 0) throw bad('至少需要一个模型')
|
|
435
|
-
const seen = new Set()
|
|
436
|
-
for (const m of models) {
|
|
437
|
-
const id = m && typeof m.id === 'string' ? m.id.trim() : ''
|
|
438
|
-
if (!id) throw bad('模型 id 不能为空')
|
|
439
|
-
if (seen.has(id)) throw bad(`模型 id 重复:${id}`)
|
|
440
|
-
seen.add(id)
|
|
441
|
-
}
|
|
442
|
-
|
|
443
|
-
const base = Object.assign({}, src)
|
|
444
|
-
delete base.models
|
|
445
|
-
const displayName = typeof src.displayName === 'string' ? src.displayName.trim() : ''
|
|
446
|
-
if (displayName) base.displayName = displayName; else delete base.displayName
|
|
447
|
-
base.api = api
|
|
448
|
-
base.baseURL = baseURL
|
|
449
|
-
if (apiKeyEnv) base.apiKeyEnv = apiKeyEnv; else delete base.apiKeyEnv
|
|
450
|
-
base.models = models.map((m) => piAiModelWrite(
|
|
451
|
-
Object.assign({}, m, { id: String(m.id).trim() }),
|
|
452
|
-
{ api, route, baseURL }
|
|
453
|
-
))
|
|
454
|
-
|
|
455
|
-
const apiKey = typeof body.apiKey === 'string' ? body.apiKey.trim() : ''
|
|
456
|
-
if (apiKey) {
|
|
457
|
-
if (!apiKeyEnv) {
|
|
458
|
-
// Key 填了但 apiKeyEnv 留空:按路由 key 自动生成凭据库条目名(与客户端 dkmEnvFromRoute 一致),
|
|
459
|
-
// 不再要求用户现编一个环境变量名
|
|
460
|
-
const s = String(route || '').toUpperCase().replace(/[^A-Z0-9]+/g, '_').replace(/^_+|_+$/g, '')
|
|
461
|
-
apiKeyEnv = (s ? (/^[0-9]/.test(s) ? 'KEY_' + s : s) : 'PROVIDER') + '_API_KEY'
|
|
462
|
-
base.apiKeyEnv = apiKeyEnv
|
|
463
|
-
}
|
|
464
|
-
const credentials = ctx.get('credentials')
|
|
465
|
-
if (!credentials || typeof credentials.set !== 'function') throw bad('凭据库不可用,无法保存 API Key')
|
|
466
|
-
try {
|
|
467
|
-
await credentials.set(credentialRef(apiKeyEnv), apiKey)
|
|
468
|
-
} catch (e) {
|
|
469
|
-
const msg = e && e.message ? e.message : String(e)
|
|
470
|
-
const err = new Error(`写入本地凭据库失败:${msg}`)
|
|
471
|
-
err.statusCode = 400
|
|
472
|
-
throw err
|
|
473
|
-
}
|
|
474
|
-
}
|
|
475
|
-
|
|
476
|
-
try {
|
|
477
|
-
await settings.mutate('llm-pi-ai', [{ op: 'set', path: ['providers', route], value: base }], revision)
|
|
478
|
-
} catch (e) {
|
|
479
|
-
const msg = e && e.message ? e.message : String(e)
|
|
480
|
-
const err = new Error(`写回官方配置被拒绝:${msg}`)
|
|
481
|
-
err.statusCode = /conflict|revision/i.test(msg) ? 409 : 400
|
|
482
|
-
throw err
|
|
483
|
-
}
|
|
484
|
-
return { ok: true, route, created: action === 'create', savedAt: Date.now() }
|
|
485
|
-
}
|
|
486
|
-
|
|
487
|
-
/**
|
|
488
|
-
* 拉取 Provider 的可用模型列表:OpenAI 兼容端点 GET {baseURL}/models。
|
|
489
|
-
* Key 优先用请求里刚填的明文(还没保存就能先拉列表),否则回退到已配置的凭据库条目。
|
|
490
|
-
* 只做「列目录」,不代表该模型真能调用(额度/权限以上游为准)。
|
|
491
|
-
*/
|
|
492
|
-
async function fetchProviderModels(ctx, body) {
|
|
493
|
-
const bad = (msg) => { const e = new Error(msg); e.statusCode = 400; return e }
|
|
494
|
-
const baseURL = typeof body.baseURL === 'string' ? body.baseURL.trim().replace(/\/+$/, '') : ''
|
|
495
|
-
if (!/^https?:\/\//i.test(baseURL)) throw bad('请先填写合法的 baseURL(http:// 或 https://)')
|
|
496
|
-
let key = typeof body.apiKey === 'string' ? body.apiKey.trim() : ''
|
|
497
|
-
if (!key && typeof body.apiKeyEnv === 'string' && body.apiKeyEnv.trim()) {
|
|
498
|
-
const credentials = ctx.get('credentials')
|
|
499
|
-
if (credentials && typeof credentials.resolve === 'function') {
|
|
500
|
-
try {
|
|
501
|
-
const got = await credentials.resolve(credentialRef(body.apiKeyEnv.trim()))
|
|
502
|
-
key = typeof got === 'string' ? got : (got && typeof got.value === 'string' ? got.value : '')
|
|
503
|
-
} catch { key = '' }
|
|
504
|
-
}
|
|
505
|
-
}
|
|
506
|
-
const headers = { accept: 'application/json' }
|
|
507
|
-
if (key) {
|
|
508
|
-
if (body.api === 'anthropic-messages') {
|
|
509
|
-
headers['x-api-key'] = key
|
|
510
|
-
headers['anthropic-version'] = '2023-06-01'
|
|
511
|
-
} else {
|
|
512
|
-
headers.authorization = 'Bearer ' + key
|
|
513
|
-
}
|
|
514
|
-
}
|
|
515
|
-
let res
|
|
516
|
-
try {
|
|
517
|
-
res = await fetch(baseURL + '/models', { headers, signal: AbortSignal.timeout(20000) })
|
|
518
|
-
} catch (e) {
|
|
519
|
-
const err = new Error(`请求 ${baseURL}/models 失败:${(e && e.message) || String(e)}`)
|
|
520
|
-
err.statusCode = 400
|
|
521
|
-
throw err
|
|
522
|
-
}
|
|
523
|
-
let text = ''
|
|
524
|
-
try { text = await res.text() } catch { text = '' }
|
|
525
|
-
if (!res.ok) {
|
|
526
|
-
const err = new Error(`上游返回 HTTP ${res.status}${text ? ':' + text.slice(0, 240) : ''}`)
|
|
527
|
-
err.statusCode = 400
|
|
528
|
-
throw err
|
|
529
|
-
}
|
|
530
|
-
let json = null
|
|
531
|
-
try { json = JSON.parse(text) } catch { /* 非 JSON:下面按空列表处理 */ }
|
|
532
|
-
let list = []
|
|
533
|
-
if (Array.isArray(json)) list = json
|
|
534
|
-
else if (json && Array.isArray(json.data)) list = json.data
|
|
535
|
-
else if (json && Array.isArray(json.models)) list = json.models
|
|
536
|
-
const models = list.map((x) => {
|
|
537
|
-
if (typeof x === 'string') return { id: x }
|
|
538
|
-
const o = x && typeof x === 'object' ? x : {}
|
|
539
|
-
return { id: String(o.id || o.model || o.name || ''), name: typeof o.name === 'string' ? o.name : undefined }
|
|
540
|
-
}).filter((m) => m.id)
|
|
541
|
-
return { ok: true, count: models.length, models, fetchedAt: Date.now() }
|
|
542
|
-
}
|
|
543
|
-
|
|
544
113
|
/** 功能注册表条目:index.js 组装 hostSetups 时使用。 */
|
|
545
114
|
export const feature = {
|
|
546
|
-
// id 必须与客户端视图描述符(view.js 的 feature.id)一致:面板开关 / 补推同步都按它
|
|
547
|
-
// POST /dsh-dock/features,历史上这里叫 'models' 而客户端叫 'modelconfig',
|
|
548
|
-
// 开关永远被宿主当「未知功能」拒掉,目录接口随之 404(迁移见 host-core 的 migrateModelsFeatureId)。
|
|
549
115
|
id: 'modelconfig',
|
|
550
116
|
name: '模型设置',
|
|
551
|
-
description: '
|
|
117
|
+
description: '模型目录:编辑各模型的输入模态与思考强度,写回官方配置热生效',
|
|
552
118
|
defaultEnabled: false,
|
|
553
119
|
setup(ctx) {
|
|
554
120
|
const webServer = ctx.get('webServer')
|
|
555
121
|
if (webServer === undefined) throw new Error('webServer 服务不可用,无法提供模型设置路由')
|
|
556
122
|
const handler = async (req, res) => {
|
|
123
|
+
if (rejectUntrustedRequest(ctx, req, res)) return
|
|
557
124
|
try {
|
|
558
125
|
if (req.method === 'GET') {
|
|
559
126
|
const payload = readModelDirectory(ctx)
|
|
560
127
|
await enrichRuntimeInput(ctx, payload)
|
|
561
|
-
await enrichApiKeyStatus(ctx, payload)
|
|
562
128
|
// 附带图片理解代理配置与自有命名空间 revision(面板保存用)
|
|
563
129
|
try {
|
|
564
130
|
const v = readDockRoot(ctx)
|
|
@@ -579,14 +145,7 @@ export const feature = {
|
|
|
579
145
|
}
|
|
580
146
|
if (req.method === 'POST') {
|
|
581
147
|
const body = await readBody(req)
|
|
582
|
-
|
|
583
|
-
// action 显式区分三条链路:拉模型列表 / Provider 增删改 / 旧版模型目录写回
|
|
584
|
-
const result = action === 'listModels'
|
|
585
|
-
? await fetchProviderModels(ctx, body)
|
|
586
|
-
: (action === 'create' || action === 'update' || action === 'delete')
|
|
587
|
-
? await writeProviderConfig(ctx, body)
|
|
588
|
-
: await writeModelConfig(ctx, body)
|
|
589
|
-
sendJson(res, 200, result)
|
|
148
|
+
sendJson(res, 200, await writeModelConfig(ctx, body))
|
|
590
149
|
return
|
|
591
150
|
}
|
|
592
151
|
sendJson(res, 405, { error: `不支持的请求方法:${req.method}` })
|