@arcaneorion/dsh-model-channel-manager 0.1.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/src/client.js ADDED
@@ -0,0 +1,1414 @@
1
+ /** @arcaneorion/dsh-model-channel-manager — client 半(现代化设计版)。
2
+ * 遵循 DSH 规范:inject ["slots","connection"],
3
+ * apply(ctx) 捕获 ctx.get("connection").api,组件经闭包使用。
4
+ * 数据通道 = api.settings / api.llm / api.credentials(公共 seam,无私有 RPC)。
5
+ * 会话模型选择器(座位遮蔽)已拆出为独立插件 @arcaneorion/dsh-model-selector-search,
6
+ * 本包只保留 conversation.view 模型配置页签。
7
+ */
8
+ window.__ModuleLoader__.load({
9
+ id: '@arcaneorion/dsh-model-channel-manager',
10
+ factory: (require) => {
11
+ const { createElement: el, useState, useEffect, useRef, useSyncExternalStore } = require('react')
12
+ let apiRef = null
13
+ const savedKeys = {}
14
+
15
+ const CSS = `
16
+ .mcm-root { display:flex; flex-direction:column; height:100%; overflow:hidden; font-size:13px; color:var(--dsw-alias-label-primary); background:var(--dsw-alias-bg-base); }
17
+ .mcm-header { display:flex; align-items:center; justify-content:space-between; padding:12px 24px; border-bottom:1px solid var(--dsw-alias-border-l1); background:var(--dsw-alias-bg-layer-1); flex-shrink:0; }
18
+ .mcm-nav { display:flex; background:var(--dsw-alias-bg-layer-2); padding:3px; border-radius:10px; border:1px solid var(--dsw-alias-border-l2); gap:2px; }
19
+ .mcm-nav-item { padding:6px 16px; font-size:12px; font-weight:500; color:var(--dsw-alias-label-tertiary); cursor:pointer; border-radius:8px; transition:all 0.15s ease; user-select:none; }
20
+ .mcm-nav-item:hover { color:var(--dsw-alias-label-primary); }
21
+ .mcm-nav-item.active { color:var(--dsw-alias-brand-primary); background:var(--dsw-alias-bg-layer-1); box-shadow:0 1px 3px rgba(0,0,0,0.06); font-weight:600; }
22
+ .mcm-actions { display:flex; align-items:center; gap:8px; }
23
+ .mcm-btn { box-sizing:border-box; height:30px; border:1px solid var(--dsw-alias-border-l2); background:var(--dsw-alias-bg-layer-1); color:var(--dsw-alias-label-primary); border-radius:8px; padding:0 14px; font-size:12px; font-weight:500; cursor:pointer; font:inherit; display:inline-flex; align-items:center; gap:6px; transition:all 0.15s ease; }
24
+ .mcm-btn:hover { background:var(--dsw-alias-interactive-bg-hover); border-color:var(--dsw-alias-border-l3); }
25
+ .mcm-btn.primary { background:var(--dsw-alias-button-primary-fill); color:var(--dsw-alias-label-primary-foreground); border:none; box-shadow:0 1px 2px rgba(0,0,0,0.1); }
26
+ .mcm-btn.primary:hover { background:var(--dsw-alias-button-primary-hover); }
27
+ .mcm-btn.danger { color:var(--dsw-alias-state-error-primary); border-color:transparent; background:transparent; }
28
+ .mcm-btn.danger:hover { background:var(--dsw-alias-interactive-bg-hover-danger); }
29
+ .mcm-body { flex:1; overflow-y:auto; padding:20px 24px 60px; }
30
+ .mcm-banner { display:flex; align-items:center; justify-content:space-between; padding:12px 16px; border-radius:10px; background:var(--dsw-alias-bg-layer-1); border:1px solid var(--dsw-alias-border-l1); margin-bottom:16px; font-size:12px; }
31
+ .mcm-card { border:1px solid var(--dsw-alias-border-l1); border-radius:12px; margin-bottom:12px; background:var(--dsw-alias-bg-layer-1); box-shadow:0 1px 3px rgba(0,0,0,0.02); transition:border-color 0.15s ease; overflow:hidden; }
32
+ .mcm-card:hover { border-color:var(--dsw-alias-border-l2); }
33
+ .mcm-card-h { display:flex; align-items:center; gap:12px; padding:12px 16px; cursor:pointer; user-select:none; }
34
+ .mcm-card-h:hover { background:var(--dsw-alias-interactive-bg-hover); }
35
+ .mcm-card-title { font-size:14px; font-weight:600; display:flex; align-items:center; gap:8px; }
36
+ .mcm-badge { font-size:11px; font-weight:500; padding:2px 8px; border-radius:6px; border:1px solid var(--dsw-alias-border-l2); color:var(--dsw-alias-label-secondary); background:var(--dsw-alias-bg-layer-2); font-family:var(--ds-font-family-code); }
37
+ .mcm-badge.brand { color:var(--dsw-alias-brand-primary); border-color:var(--dsw-alias-brand-primary); background:rgba(var(--dsw-rgb-brand-primary, 59, 130, 246), 0.08); }
38
+ .mcm-badge.success { color:var(--dsw-alias-state-success-primary); border-color:var(--dsw-alias-state-success-primary); background:rgba(16, 185, 129, 0.08); }
39
+ .mcm-badge.error { color:var(--dsw-alias-state-error-primary); border-color:var(--dsw-alias-state-error-primary); background:rgba(239, 68, 68, 0.08); }
40
+ .mcm-editor { background:var(--dsw-alias-bg-layer-2); border-top:1px solid var(--dsw-alias-border-l1); padding:16px 20px; display:flex; flex-direction:column; gap:16px; }
41
+ .mcm-field { display:flex; flex-direction:column; gap:6px; }
42
+ .mcm-fl { display:flex; align-items:center; gap:6px; font-size:12px; font-weight:600; color:var(--dsw-alias-label-secondary); }
43
+ .mcm-hint { color:var(--dsw-alias-label-dimmed); cursor:help; font-size:11px; border:1px solid var(--dsw-alias-border-l2); border-radius:50%; width:15px; height:15px; display:inline-flex; align-items:center; justify-content:center; }
44
+ .mcm-in { box-sizing:border-box; width:100%; border:1px solid var(--dsw-alias-border-l2); background:var(--dsw-alias-bg-layer-1); color:var(--dsw-alias-label-primary); border-radius:8px; height:34px; padding:0 12px; font:inherit; font-size:13px; transition:border-color 0.15s ease; }
45
+ .mcm-in:focus { border-color:var(--dsw-alias-brand-primary); outline:none; }
46
+ .mcm-in.mono { font-family:var(--ds-font-family-code); font-size:12px; }
47
+ .mcm-row { display:flex; flex-wrap:wrap; gap:12px; }
48
+ .mcm-row > .mcm-field { flex:1; min-width:180px; }
49
+ .mcm-subcard { border:1px solid var(--dsw-alias-border-l2); border-radius:10px; margin-bottom:8px; background:var(--dsw-alias-bg-layer-1); overflow:hidden; }
50
+ .mcm-subcard-h { display:flex; align-items:center; gap:10px; padding:10px 14px; cursor:pointer; }
51
+ .mcm-subcard-h:hover { background:var(--dsw-alias-interactive-bg-hover); }
52
+ .mcm-subcard-b { padding:14px 16px; border-top:1px solid var(--dsw-alias-border-l1); display:flex; flex-direction:column; gap:12px; background:var(--dsw-alias-bg-layer-2); }
53
+ .mcm-metrics-grid { display:grid; grid-template-columns:repeat(auto-fit, minmax(200px, 1fr)); gap:12px; margin-bottom:20px; }
54
+ .mcm-metric-card { background:var(--dsw-alias-bg-layer-1); border:1px solid var(--dsw-alias-border-l1); border-radius:12px; padding:14px 16px; display:flex; flex-direction:column; gap:4px; box-shadow:0 1px 3px rgba(0,0,0,0.02); }
55
+ .mcm-metric-label { font-size:12px; color:var(--dsw-alias-label-tertiary); font-weight:500; }
56
+ .mcm-metric-value { font-size:24px; font-weight:700; color:var(--dsw-alias-label-primary); font-family:var(--ds-font-family-code); }
57
+ .mcm-meter { height:6px; border-radius:3px; background:var(--dsw-alias-bg-layer-2); overflow:hidden; margin-top:6px; display:flex; }
58
+ .mcm-meter-fill { height:100%; border-radius:3px; background:var(--dsw-alias-state-success-primary); }
59
+ .mcm-meter-fill.warn { background:var(--dsw-alias-state-warn-primary); }
60
+ .mcm-meter-fill.danger { background:var(--dsw-alias-state-error-primary); }
61
+ .mcm-health-grid { display:grid; grid-template-columns:repeat(auto-fill, minmax(320px, 1fr)); gap:12px; }
62
+ .mcm-health-card { background:var(--dsw-alias-bg-layer-1); border:1px solid var(--dsw-alias-border-l1); border-radius:12px; padding:14px 16px; display:flex; flex-direction:column; gap:10px; }
63
+ .mcm-health-card-h { display:flex; align-items:center; justify-content:space-between; }
64
+ .mcm-status-dot { width:8px; height:8px; border-radius:50%; display:inline-block; }
65
+ .mcm-status-dot.ok { background:var(--dsw-alias-state-success-primary); box-shadow:0 0 0 3px rgba(16, 185, 129, 0.15); }
66
+ .mcm-status-dot.err { background:var(--dsw-alias-state-error-primary); box-shadow:0 0 0 3px rgba(239, 68, 68, 0.15); }
67
+ .mcm-mask { position:fixed; inset:0; background:rgba(0,0,0,.4); backdrop-filter:blur(3px); display:flex; align-items:center; justify-content:center; z-index:999; }
68
+ .mcm-modal { background:var(--dsw-alias-bg-base); border-radius:14px; box-shadow:0 12px 40px rgba(0,0,0,.25); width:92%; max-width:540px; max-height:85vh; display:flex; flex-direction:column; border:1px solid var(--dsw-alias-border-l2); overflow:hidden; }
69
+ .mcm-modal-h { padding:14px 20px; border-bottom:1px solid var(--dsw-alias-border-l1); display:flex; align-items:center; justify-content:space-between; background:var(--dsw-alias-bg-layer-1); }
70
+ .mcm-modal-b { padding:18px 20px; overflow-y:auto; flex:1; display:flex; flex-direction:column; gap:12px; }
71
+ .mcm-modal-f { padding:12px 20px; border-top:1px solid var(--dsw-alias-border-l1); display:flex; justify-content:flex-end; gap:8px; background:var(--dsw-alias-bg-layer-1); }
72
+ .mcm-empty { color:var(--dsw-alias-label-tertiary); padding:32px 16px; text-align:center; font-size:13px; }
73
+ .mcm-spin { width:18px; height:18px; border:2px solid var(--dsw-alias-border-l2); border-top-color:var(--dsw-alias-brand-primary); border-radius:50%; animation:mcm-spin .8s linear infinite; display:inline-block; }
74
+ @keyframes mcm-spin { to { transform:rotate(360deg); } }
75
+ `
76
+
77
+ const clone = (v) => JSON.parse(JSON.stringify(v))
78
+ // 生成第一个未占用的 prefix-N 名字,避免「删除中间项后 length+1 撞已有键」导致覆盖/静默去重
79
+ const uniqueSuffixName = (prefix, taken) => { let i = 1; while (taken(prefix + i)) i++; return prefix + i }
80
+ const APIS = ['openai-completions', 'openai-responses', 'anthropic-messages']
81
+ const LEVELS = ['off', 'minimal', 'low', 'medium', 'high', 'xhigh', 'max']
82
+ // thinkingFormat 合法集合 = harness THINKING_FORMAT_GATE(8 项)。
83
+ // thinkingFormat 全集 = 安装运行时 rc.2 的 SUPPORTED_THINKING_FORMATS(10 项)。
84
+ // 此前注解「chat-template/qwen-chat-template 被 harness withheld」是源码仓快照的
85
+ // 认知——rc.2 实际 offer 这两项(实测 schema 接受);适配器契约必须以安装运行时为准。
86
+ const TF = ['openai', 'deepseek', 'openrouter', 'together', 'zai', 'qwen', 'chat-template', 'qwen-chat-template', 'string-thinking', 'ant-ling']
87
+ const MAX_TOKENS_FIELDS = ['max_completion_tokens', 'max_tokens']
88
+ const CACHE_CONTROL_FORMATS = ['anthropic']
89
+ const TRANSPORTS = ['sse', 'websocket', 'websocket-cached', 'auto']
90
+ const CACHE = ['none', 'short', 'long']
91
+ // apiKeyEnv 凭据引用必须是 POSIX 环境变量名(DSH apiproxy zod: /^[A-Za-z_][A-Za-z0-9_]*$/)
92
+ const isLegalCredentialRef = (v) => /^[A-Za-z_][A-Za-z0-9_]*$/.test(v)
93
+ const normalizeCredentialRef = (raw) => {
94
+ const v = String(raw == null ? '' : raw).trim()
95
+ if (!v) return ''
96
+ if (isLegalCredentialRef(v)) return v
97
+ // 非法的引用(如含连字符 '-22'):大写化、非法字符转下划线、数字开头补下划线前缀
98
+ let s = v.toUpperCase().replace(/[^A-Z0-9_]+/g, '_').replace(/^[0-9]+/, (m) => '_' + m).replace(/^_+/, '')
99
+ if (!s) s = 'API_KEY'
100
+ if (!isLegalCredentialRef(s)) s = '_' + s
101
+ return s
102
+ }
103
+ // 参考 pi-provider-manager 的默认请求头(浏览器伪装,降低被上游风控的概率)。
104
+ // 只在「新增供应商」时注入一次;加载与保存不再强制合并——编辑器里删除即真实生效。
105
+ // 不含 User-Agent:pi-ai 的 requestHeaders 会强制用 deepseek-harness 归属 UA 覆盖,自定义是死数据。
106
+ const DEFAULT_HEADERS = {
107
+ 'Accept': 'text/event-stream, text/html, application/json, */*',
108
+ 'Accept-Language': 'en-US,en;q=0.9,zh-CN;q=0.8,zh;q=0.7',
109
+ 'Accept-Encoding': 'gzip, deflate, br',
110
+ 'Cache-Control': 'no-cache',
111
+ 'Connection': 'keep-alive',
112
+ 'Origin': 'https://chat.openai.com',
113
+ 'Referer': 'https://chat.openai.com/',
114
+ }
115
+ const btn = (label, onClick, kind) => el('button', { className: 'mcm-btn' + (kind ? ' ' + kind : ''), onClick }, label)
116
+ const field = (label, tip, req, children) => el('div', { className: 'mcm-field' }, el('div', { className: 'mcm-fl' }, label, req ? el('span', { style: { color: 'var(--dsw-alias-state-error-primary)' } }, '*') : null, tip ? el('span', { className: 'mcm-hint', title: tip }, '?') : null), children)
117
+ const tf = (label, tip, value, onSet, mono) => field(label, tip, false, el('input', { className: 'mcm-in' + (mono ? ' mono' : ''), value: value == null ? '' : String(value), onChange: (e) => onSet(e.target.value) }))
118
+ const nf = (label, tip, value, onSet) => tf(label, tip, value, (v) => onSet(Number(v) || 0))
119
+ const sel = (label, tip, value, options, onSet) => field(label, tip, false, el('select', { className: 'mcm-in', value, onChange: (e) => onSet(e.target.value) }, ...options.map((o) => Array.isArray(o) ? el('option', { key: o[0], value: o[0] }, o[1]) : el('option', { key: o, value: o }, o))))
120
+
121
+ function kvEditor(kvs, onSet) {
122
+ const entries = Object.entries(kvs || {})
123
+ return el('div', { style: { display: 'flex', flexDirection: 'column', gap: 6 } },
124
+ entries.length > 0 ? entries.map(([k, v], i) => el('div', { key: i, style: { display: 'flex', gap: 6, alignItems: 'center' } },
125
+ el('input', { className: 'mcm-in mono', style: { width: '35%' }, placeholder: 'Header Key', value: k, onChange: (e) => { const n = Object.assign({}, kvs); delete n[k]; n[e.target.value] = v; onSet(n) } }),
126
+ el('input', { className: 'mcm-in mono', style: { flex: 1 }, placeholder: 'Value', value: v, onChange: (e) => { const n = Object.assign({}, kvs); n[k] = e.target.value; onSet(n) } }),
127
+ btn('✕', () => { const n = Object.assign({}, kvs); delete n[k]; onSet(n) }, 'danger')
128
+ )) : el('div', { style: { fontSize: 12, color: 'var(--dsw-alias-label-tertiary)', padding: '4px 0' } }, '暂无自定义请求头'),
129
+ btn('+添加 Header', () => onSet(Object.assign({}, kvs, { '': '' })))
130
+ )
131
+ }
132
+
133
+ // rc.2 llm-pi-ai PiAiCompatProfile:全部字段真实生效(以安装运行时 d.ts/lib 为准,
134
+ // 非源码仓快照)。按协议分组——模型级开关填错协议会使 resolve 直接失败,
135
+ // route 级会跳过不适配的模型,所以编辑器只渲染当前协议接受的字段。
136
+ const COMPAT_BOOL = {
137
+ 'openai-completions': ['supportsStore', 'supportsDeveloperRole', 'supportsReasoningEffort', 'supportsUsageInStreaming', 'requiresToolResultName', 'requiresAssistantAfterToolResult', 'requiresThinkingAsText', 'requiresReasoningContentOnAssistantMessages', 'supportsStrictMode', 'supportsLongCacheRetention'],
138
+ 'openai-responses': ['supportsDeveloperRole', 'supportsStrictMode', 'supportsLongCacheRetention'],
139
+ 'anthropic-messages': ['supportsEagerToolInputStreaming', 'supportsCacheControlOnTools', 'supportsTemperature', 'forceAdaptiveThinking', 'allowEmptySignature', 'supportsStrictTools', 'supportsLongCacheRetention'],
140
+ }
141
+ const COMPAT_SELECTS = {
142
+ 'openai-completions': [
143
+ ['thinkingFormat', TF, '推理参数格式;chat-template / qwen-chat-template 会把 thinking 状态注入 chat_template_kwargs'],
144
+ ['maxTokensField', MAX_TOKENS_FIELDS, '输出上限字段拼写(缺省 max_tokens)'],
145
+ ['cacheControlFormat', CACHE_CONTROL_FORMATS, 'prompt-cache 标记约定'],
146
+ ],
147
+ }
148
+ const COMPAT_KV = { 'openai-completions': [['chatTemplateKwargs', 'chat_template_kwargs 键值对;值支持 $var 占位(如 {"$var":"thinking.enabled","omitWhenOff":true},此处仅支持字符串值,占位对象请手改配置文件)']] }
149
+
150
+ function compatEditor(compat, onSet, apiType) {
151
+ const c = compat || {}
152
+ const setK = (k, v) => { const n = Object.assign({}, c); if (v === '' || v === undefined) delete n[k]; else n[k] = v; onSet(n) }
153
+ const label = (k, d) => el('label', { style: { fontSize: 11, color: 'var(--dsw-alias-label-tertiary)' }, title: d }, k)
154
+ // 布尔三态:未设置 = 交给 pi-ai 按 baseURL 自动探测;checkbox 表达不了三态
155
+ const boolF = (k, d) => el('div', { style: { display: 'flex', flexDirection: 'column', gap: 2 } },
156
+ label(k, d),
157
+ el('select', { className: 'mcm-in', style: { height: 28, fontSize: 12 }, value: c[k] == null ? '' : String(c[k]), onChange: (e) => setK(k, e.target.value === '' ? undefined : e.target.value === 'true') },
158
+ el('option', { value: '' }, '— 默认 —'),
159
+ el('option', { value: 'true' }, '开'),
160
+ el('option', { value: 'false' }, '关')
161
+ )
162
+ )
163
+ const selectF = (k, opts, d) => el('div', { style: { display: 'flex', flexDirection: 'column', gap: 2 } },
164
+ label(k, d),
165
+ el('select', { className: 'mcm-in', style: { height: 28, fontSize: 12 }, value: c[k] || '', onChange: (e) => setK(k, e.target.value || undefined) },
166
+ el('option', { value: '' }, '— 默认 —'),
167
+ ...opts.map((o) => el('option', { key: o, value: o }, o))
168
+ )
169
+ )
170
+ const kvF = (k, d) => field(k, d, false, kvEditor(c[k] && typeof c[k] === 'object' ? c[k] : {}, (v) => setK(k, v && Object.keys(v).length ? v : undefined)))
171
+ const api = apiType || 'openai-completions'
172
+ const items = [
173
+ ...(COMPAT_BOOL[api] || []).map((k) => boolF(k)),
174
+ ...(COMPAT_SELECTS[api] || []).map(([k, opts, d]) => selectF(k, opts, d)),
175
+ ...(COMPAT_KV[api] || []).map(([k, d]) => kvF(k, d)),
176
+ ]
177
+ return el('div', { style: { display: 'flex', flexDirection: 'column', gap: 10, padding: '8px 0' } },
178
+ items.length > 0
179
+ ? el('div', { style: { display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(200px, 1fr))', gap: 10 } }, items)
180
+ : el('div', { style: { fontSize: 11, color: 'var(--dsw-alias-label-tertiary)' } }, '当前协议(' + api + ')没有可配置的 compat 字段'),
181
+ el('div', { style: { fontSize: 11, color: 'var(--dsw-alias-label-tertiary)', lineHeight: 1.6 } },
182
+ 'Compat 字段以安装运行时(rc.2 PiAiCompatProfile,20 项)为准、全部真实生效;保存时全量透传不再剥离。本编辑器只显示当前协议(' + api + ')接受的字段——模型级开关填错协议会使 resolve 直接失败。「— 默认 —」= 删除该键,交给 pi-ai 按 baseURL 自动探测。'
183
+ )
184
+ )
185
+ }
186
+
187
+ function levelsEditor(efforts, onSet) {
188
+ const cur = efforts && typeof efforts === 'object' ? efforts : {}
189
+ return el('div', { style: { display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(85px, 1fr))', gap: 6 } }, LEVELS.map((lv) => {
190
+ const wire = cur[lv]
191
+ return el('div', { key: lv, style: { display: 'flex', flexDirection: 'column', gap: 2 } }, el('label', { style: { fontSize: 11, color: 'var(--dsw-alias-label-tertiary)' } }, lv), el('input', { className: 'mcm-in mono', style: { height: 28, fontSize: 12, padding: '0 6px' }, value: wire == null ? '' : String(wire), placeholder: lv === 'off' ? '(null)' : 'wire', onChange: (e) => { const v = e.target.value; const n = Object.assign({}, cur); if (v === '' && lv !== 'off') { delete n[lv] } else if (v === '') { n[lv] = null } else { n[lv] = v }; onSet(n) } }))
192
+ }))
193
+ }
194
+
195
+ function GlobalTestConfigModal(props) {
196
+ const win = props.win; const setWin = props.setWin
197
+ const [prompt, setPrompt] = useState(localStorage.getItem('mcm_test_prompt') || '用一句话介绍你自己')
198
+ const [maxTokens, setMaxTokens] = useState(Number(localStorage.getItem('mcm_test_max_tokens')) || 256)
199
+
200
+ const save = () => {
201
+ localStorage.setItem('mcm_test_prompt', prompt)
202
+ localStorage.setItem('mcm_test_max_tokens', String(maxTokens))
203
+ setWin(false)
204
+ }
205
+
206
+ if (!win) return null
207
+ return el('div', { className: 'mcm-mask', onClick: () => setWin(false) },
208
+ el('div', { className: 'mcm-modal', onClick: (e) => e.stopPropagation() },
209
+ el('div', { className: 'mcm-modal-h' },
210
+ el('h3', { style: { margin: 0, fontSize: 15, fontWeight: 600 } }, '⚙️ 全局模型测试参数配置'),
211
+ btn('✕', () => setWin(false))
212
+ ),
213
+ el('div', { className: 'mcm-modal-b' },
214
+ field('测试 Prompt', '点击单模型测试时直接发送的用户消息', false,
215
+ el('textarea', { className: 'mcm-in', style: { height: 80, padding: 8, resize: 'vertical' }, value: prompt, onChange: (e) => setPrompt(e.target.value) })
216
+ ),
217
+ field('Max Tokens', '单次快速测试的最大 Token 限制', false,
218
+ el('input', { type: 'number', className: 'mcm-in', value: maxTokens, onChange: (e) => setMaxTokens(Number(e.target.value) || 256) })
219
+ ),
220
+ el('div', { style: { fontSize: 12, color: 'var(--dsw-alias-label-tertiary)' } }, '说明:配置后点击模型行「⚡测试」将静默发起测试,无需每次确认。')
221
+ ),
222
+ el('div', { className: 'mcm-modal-f' },
223
+ btn('取消', () => setWin(false)),
224
+ btn('保存配置', save, 'primary')
225
+ )
226
+ )
227
+ )
228
+ }
229
+
230
+ function TestResultDetailModal(props) {
231
+ const win = props.win; const setWin = props.setWin
232
+ if (!win) return null
233
+ const res = win.result || {}
234
+ return el('div', { className: 'mcm-mask', onClick: () => setWin(null) },
235
+ el('div', { className: 'mcm-modal', onClick: (e) => e.stopPropagation() },
236
+ el('div', { className: 'mcm-modal-h' },
237
+ el('h3', { style: { margin: 0, fontSize: 15, fontWeight: 600 } }, (res.status === 'ok' ? '✓ 测试成功 · ' : '✗ 测试异常 · ') + win.model),
238
+ btn('✕', () => setWin(null))
239
+ ),
240
+ el('div', { className: 'mcm-modal-b' },
241
+ el('div', { style: { display: 'flex', gap: 8, alignItems: 'center' } },
242
+ el('span', { className: 'mcm-badge brand' }, win.provider),
243
+ el('span', { className: 'mcm-badge' }, win.model),
244
+ res.status === 'ok' ? el('span', { className: 'mcm-badge success' }, 'HTTP 200 OK') : el('span', { className: 'mcm-badge error' }, res.code || 'ERROR')
245
+ ),
246
+ res.status === 'ok' ? el('div', { style: { background: 'var(--dsw-alias-bg-layer-2)', padding: 12, borderRadius: 8, border: '1px solid var(--dsw-alias-border-l1)', display: 'flex', flexDirection: 'column', gap: 6 } },
247
+ el('div', { style: { display: 'flex', gap: 16, fontSize: 12, color: 'var(--dsw-alias-state-success-primary)', fontWeight: 600 } },
248
+ el('span', null, 'TTFT 首字响应: ' + (res.ttftMs != null ? (res.ttftMs / 1000).toFixed(2) + 's' : '—')),
249
+ el('span', null, '总延迟耗时: ' + (res.latencyMs != null ? (res.latencyMs / 1000).toFixed(2) + 's' : '—'))
250
+ ),
251
+ res.reasoning ? el('div', { style: { marginTop: 6 } },
252
+ el('span', { style: { fontSize: 11, color: 'var(--dsw-alias-label-tertiary)' } }, '思考过程 (Reasoning)'),
253
+ el('pre', { style: { whiteSpace: 'pre-wrap', fontSize: 12, fontFamily: 'var(--ds-font-family-code)', maxHeight: 140, overflow: 'auto', color: 'var(--dsw-alias-label-secondary)', margin: '4px 0' } }, res.reasoning)
254
+ ) : null,
255
+ res.text ? el('div', { style: { marginTop: 6 } },
256
+ el('span', { style: { fontSize: 11, color: 'var(--dsw-alias-label-tertiary)' } }, '正文回复 (Text)'),
257
+ el('pre', { style: { whiteSpace: 'pre-wrap', fontSize: 12, fontFamily: 'var(--ds-font-family-code)', maxHeight: 180, overflow: 'auto', color: 'var(--dsw-alias-label-primary)', margin: '4px 0' } }, res.text)
258
+ ) : null
259
+ ) : el('div', { style: { color: 'var(--dsw-alias-state-error-primary)', fontSize: 13, padding: 12, background: 'rgba(239, 68, 68, 0.08)', borderRadius: 8, display: 'flex', flexDirection: 'column', gap: 4 } },
260
+ el('strong', null, '错误代码: ' + (res.code || 'UNKNOWN')),
261
+ el('div', { style: { fontFamily: 'var(--ds-font-family-code)', fontSize: 12 } }, res.error || '未知网络或认证异常')
262
+ )
263
+ ),
264
+ el('div', { className: 'mcm-modal-f' },
265
+ btn('关闭', () => setWin(null))
266
+ )
267
+ )
268
+ )
269
+ }
270
+
271
+ function RenameProviderModal(props) {
272
+ const win = props.win; const setWin = props.setWin
273
+ const [value, setValue] = useState('')
274
+ const [err, setErr] = useState(null)
275
+ useEffect(() => { if (win) { setValue(win.value || ''); setErr(null) } }, [win])
276
+ if (!win) return null
277
+ const submit = () => {
278
+ const next = value.trim()
279
+ if (next === win.from) { setWin(null); return }
280
+ if (!/^[a-z][a-z0-9-]*$/.test(next)) {
281
+ setErr('ID 必须以小写字母开头,只能包含小写字母、数字与连字符 (-)')
282
+ return
283
+ }
284
+ const outcome = props.onRename(win.from, next)
285
+ if (outcome && outcome.error) { setErr(outcome.error); return }
286
+ setWin(null)
287
+ }
288
+ return el('div', { className: 'mcm-mask', onClick: () => setWin(null) },
289
+ el('div', { className: 'mcm-modal', style: { maxWidth: 460 }, onClick: (e) => e.stopPropagation() },
290
+ el('div', { className: 'mcm-modal-h' },
291
+ el('h3', { style: { margin: 0, fontSize: 15, fontWeight: 600 } }, '重命名供应商 ID · ' + win.from),
292
+ btn('✕', () => setWin(null))
293
+ ),
294
+ el('div', { className: 'mcm-modal-b' },
295
+ field('新 Provider ID', '作为 settings 键、会话日志引用与轮询组候选的标识', true,
296
+ el('input', { className: 'mcm-in mono', autoFocus: true, value, onChange: (e) => setValue(e.target.value) })
297
+ ),
298
+ err ? el('div', { style: { color: 'var(--dsw-alias-state-error-primary)', fontSize: 12 } }, err) : null,
299
+ el('div', { style: { fontSize: 12, color: 'var(--dsw-alias-label-tertiary)', lineHeight: 1.6 } },
300
+ el('div', null, '· 轮询组候选池中的引用会自动同步更新。'),
301
+ el('div', null, '· apiKeyEnv 凭据引用保持不变,已存储的 Key 继续有效;如需更换引用名请手动编辑该字段。'),
302
+ el('div', null, '· 历史健康统计保留在原 ID 名下(历史存档不受影响)。')
303
+ )
304
+ ),
305
+ el('div', { className: 'mcm-modal-f' },
306
+ btn('取消', () => setWin(null)),
307
+ btn('确认重命名', submit, 'primary')
308
+ )
309
+ )
310
+ )
311
+ }
312
+
313
+ function fetchModal(name, p, disc, setDisc, getTypedKey, onApply, fq, setFq) {
314
+ if (!disc || disc.provider !== name) return null
315
+ let body
316
+ if (disc.loading) {
317
+ body = el('div', { style: { display: 'flex', flexDirection: 'column', alignItems: 'center', gap: 12, padding: 32 } },
318
+ el('div', { className: 'mcm-spin', style: { width: 24, height: 24 } }),
319
+ el('span', { style: { fontSize: 13, color: 'var(--dsw-alias-label-secondary)' } }, '正在连接上游端点拉取模型列表…')
320
+ )
321
+ } else if (disc.error) {
322
+ body = el('div', { style: { color: 'var(--dsw-alias-state-error-primary)', padding: 16, textAlign: 'center', fontSize: 13 } },
323
+ el('div', { style: { marginBottom: 12 } }, '拉取失败: ' + disc.error),
324
+ btn('重新尝试', () => { setDisc({ provider: name, loading: true }); doFetch(name, p, setDisc, getTypedKey()) }, 'primary')
325
+ )
326
+ } else {
327
+ const d = disc
328
+ const all = [
329
+ ...d.missing.map((id) => ({ id, tag: 'add', txt: '+ 可添加' })),
330
+ ...d.configured.map((id) => ({ id, tag: 'ok', txt: '✓ 已配置' })),
331
+ ...d.stale.map((id) => ({ id, tag: 'stale', txt: '! 端点已下线' }))
332
+ ]
333
+ // 搜索过滤:模型 id 子串匹配(不区分大小写);badge 统计仍按全量
334
+ const fql = (fq || '').trim().toLowerCase()
335
+ const visible = fql ? all.filter((item) => item.id.toLowerCase().includes(fql)) : all
336
+ const toggle = (item) => {
337
+ const s = new Set(d.selected)
338
+ if (s.has(item.id)) { s.delete(item.id) } else { s.add(item.id) }
339
+ setDisc(Object.assign({}, d, { selected: s }))
340
+ }
341
+ body = el('div', { style: { display: 'flex', flexDirection: 'column', gap: 12 } },
342
+ el('div', { style: { display: 'flex', gap: 8, flexWrap: 'wrap', background: 'var(--dsw-alias-bg-layer-2)', padding: '8px 12px', borderRadius: 8 } },
343
+ el('span', { className: 'mcm-badge' }, '端点模型总数: ' + d.available.length),
344
+ el('span', { className: 'mcm-badge success' }, '✓ 已匹配: ' + d.configured.length),
345
+ el('span', { className: 'mcm-badge brand' }, '+ 可新增: ' + d.missing.length),
346
+ d.stale.length > 0 ? el('span', { className: 'mcm-badge error' }, '! 端点无此模型: ' + d.stale.length) : null
347
+ ),
348
+ el('div', { style: { display: 'flex', gap: 10, alignItems: 'center' } },
349
+ el('input', {
350
+ className: 'mcm-in', style: { flex: 1 }, placeholder: '搜索模型 id…(不区分大小写)', value: fq || '',
351
+ onChange: (e) => setFq(e.target.value)
352
+ }),
353
+ el('span', { style: { fontSize: 11, color: 'var(--dsw-alias-label-tertiary)', flexShrink: 0 } }, '显示 ' + visible.length + ' / ' + all.length)
354
+ ),
355
+ el('div', { style: { fontSize: 12, color: 'var(--dsw-alias-label-tertiary)' } }, '说明:勾选 = 保留或添加;取消勾选 = 删除。已配置项默认勾选。'),
356
+ el('div', { style: { maxHeight: 300, overflowY: 'auto', border: '1px solid var(--dsw-alias-border-l1)', borderRadius: 8, background: 'var(--dsw-alias-bg-layer-1)' } },
357
+ visible.length === 0 ? el('div', { className: 'mcm-empty' }, fql ? '无匹配「' + fq.trim() + '」的模型' : '端点未返回任何可用模型') :
358
+ visible.map((item) => {
359
+ const checked = d.selected.has(item.id)
360
+ return el('div', {
361
+ key: item.id,
362
+ style: { display: 'flex', alignItems: 'center', gap: 10, padding: '8px 12px', borderBottom: '1px solid var(--dsw-alias-border-l1)', cursor: 'pointer', background: checked ? 'rgba(var(--dsw-rgb-brand-primary, 59, 130, 246), 0.04)' : 'transparent' },
363
+ onClick: () => toggle(item)
364
+ },
365
+ el('input', { type: 'checkbox', checked, onChange: (e) => { e.stopPropagation(); toggle(item) } }),
366
+ el('label', { style: { flex: 1, fontFamily: 'var(--ds-font-family-code)', fontSize: 12, cursor: 'pointer' } }, item.id),
367
+ el('span', { className: 'mcm-badge ' + (item.tag === 'ok' ? 'success' : item.tag === 'add' ? 'brand' : 'error') }, item.txt)
368
+ )
369
+ })
370
+ )
371
+ )
372
+ }
373
+ const cc = disc && disc.selected ? disc.selected.size : 0
374
+ return el('div', { className: 'mcm-mask', onClick: () => setDisc(null) },
375
+ el('div', { className: 'mcm-modal', onClick: (e) => e.stopPropagation() },
376
+ el('div', { className: 'mcm-modal-h' },
377
+ el('h3', { style: { margin: 0, fontSize: 15, fontWeight: 600 } }, '拉取上游端点模型 · ' + (p.displayName || name)),
378
+ btn('✕', () => setDisc(null))
379
+ ),
380
+ el('div', { className: 'mcm-modal-b' }, body),
381
+ el('div', { className: 'mcm-modal-f' },
382
+ btn('取消', () => setDisc(null)),
383
+ btn('应用选中模型 (' + cc + ')', () => { if (disc.selected) { onApply([...disc.selected]); setDisc(null) } }, 'primary')
384
+ )
385
+ )
386
+ )
387
+ }
388
+
389
+ function doFetch(name, p, setDisc, typedKey) {
390
+ if (!apiRef) { setDisc({ provider: name, error: 'api unavailable' }); return }
391
+ const key = (typedKey && typedKey.trim()) ? typedKey.trim() : (savedKeys[name] || undefined)
392
+ apiRef.llm.discoverModels({
393
+ settingsNs: 'llm-pi-ai',
394
+ provider: name,
395
+ baseURL: p.baseURL || undefined,
396
+ api: p.api || undefined,
397
+ apiKey: key
398
+ }).then((resp) => {
399
+ const r = resp && resp.result ? resp.result : resp
400
+ if (r && r.ok === false) throw new Error((r.error && (r.error.message || r.error)) || 'discover failed')
401
+ const result = r && r.value !== undefined ? r.value : r
402
+ const models = (result && result.models) || []
403
+ const av = models.map((m) => m.id).filter(Boolean)
404
+ const cf = new Set((p.models || []).map((m) => m.id))
405
+ setDisc({
406
+ provider: name,
407
+ available: av,
408
+ configured: [...cf].filter((id) => av.includes(id)),
409
+ missing: av.filter((id) => !cf.has(id)),
410
+ stale: [...cf].filter((id) => !av.includes(id)),
411
+ selected: new Set([...cf].filter((id) => av.includes(id)))
412
+ })
413
+ }).catch((e) => setDisc({ provider: name, error: String((e && e.message) || e) }))
414
+ }
415
+
416
+ function ModelConfigPanel(props) {
417
+ const draft = props._draft; const setDraft = props._setDraft
418
+ const setNotice = props._setNotice || (() => {})
419
+ const [exp, setExp] = useState({}); const [em, setEm] = useState({}); const [live, setLive] = useState({})
420
+ const [sa, setSa] = useState({})
421
+ const [keyInput, setKeyInput] = useState({})
422
+ const [testStates, setTestStates] = useState({})
423
+ const [detailWin, setDetailWin] = useState(null)
424
+ const [configWin, setConfigWin] = useState(false)
425
+ const [disc, setDisc] = useState(null)
426
+ const [renameWin, setRenameWin] = useState(null)
427
+ const [dragIdx, setDragIdx] = useState(null)
428
+ const [overIdx, setOverIdx] = useState(null)
429
+ const [pq, setPq] = useState('')
430
+ // 拉取上游模型弹窗的搜索词(打开新弹窗时复位)
431
+ const [fq, setFq] = useState('')
432
+ const providers = draft || {}
433
+
434
+ // 拖动排序:provider 字典保序重建(对象键插入顺序即 YAML/JSON 持久化顺序)
435
+ const moveProvider = (from, to) => {
436
+ setDraft((d) => {
437
+ const entries = Object.entries(d || {})
438
+ if (from === to || from < 0 || to < 0 || from >= entries.length || to >= entries.length) return d
439
+ const [item] = entries.splice(from, 1)
440
+ entries.splice(to, 0, item)
441
+ const out = {}
442
+ for (const [k, v] of entries) out[k] = v
443
+ return out
444
+ })
445
+ }
446
+
447
+ const renameProvider = (from, to) => {
448
+ if (!providers[from]) return { error: '找不到原 ID(可能未保存,刷新后再试)' }
449
+ if (providers[to]) return { error: '新 ID 已存在,请换一个' }
450
+ const order = Object.keys(providers)
451
+ const next = {}
452
+ for (const k of order) {
453
+ next[(k === from) ? to : k] = providers[k]
454
+ }
455
+ setDraft(next)
456
+ setExp((s) => { const n = Object.assign({}, s); if (Object.prototype.hasOwnProperty.call(n, from)) { n[to] = n[from]; delete n[from] } return n })
457
+ setEm((s) => { const n = Object.assign({}, s); for (const k of Object.keys(n)) { if (k === from || k.indexOf(from + '::') === 0) { n[(k === from ? to : to + '::' + k.slice(from.length + 2))] = n[k]; delete n[k] } } return n })
458
+ setKeyInput((s) => { const n = Object.assign({}, s); if (Object.prototype.hasOwnProperty.call(n, from)) { n[to] = n[from]; delete n[from] } return n })
459
+ setLive((s) => { const n = Object.assign({}, s); for (const k of Object.keys(n)) { if (k === from || k.indexOf(from + '_') === 0) { n[(k === from ? to : to + k.slice(from.length))] = n[k] } } return n })
460
+ delete savedKeys[to]; if (savedKeys[from] !== undefined) { savedKeys[to] = savedKeys[from]; delete savedKeys[from] }
461
+ if (props._renameProviderInChannels) props._renameProviderInChannels(from, to)
462
+ setNotice('已重命名 ' + from + ' → ' + to + '(点击「保存全部变更」生效)')
463
+ setTimeout(() => setNotice(null), 4000)
464
+ return {}
465
+ }
466
+
467
+ const pollTest = (nonce, provider, model, attempt = 0) => {
468
+ if (!apiRef) return
469
+ const key = provider + '::' + model
470
+ // host 侧测试超时 60s,约 55 次 × 1.2s ≈ 66s 后放弃,避免结果被覆盖时无限轮询
471
+ const giveUp = () => setTestStates((s) => Object.assign({}, s, { [key]: { status: 'error', code: 'POLL_TIMEOUT', error: '等待测试结果超时:host 可能未处理该请求(host 半更新后需重启 DSH)' } }))
472
+ apiRef.settings.describe({}).then((resp) => {
473
+ const r = resp && resp.result ? resp.result : resp
474
+ const d = r && r.value !== undefined ? r.value : r
475
+ const ns = ((d && d.namespaces) || []).find((n) => n && n.ns === 'model-channel-health')
476
+ const tr = (ns && ns.value && ns.value.testResults) || {}
477
+ const e = tr[nonce]
478
+ if (e && (e.status === 'ok' || e.status === 'error')) {
479
+ setTestStates((s) => Object.assign({}, s, { [key]: e }))
480
+ } else if (attempt >= 55) {
481
+ giveUp()
482
+ } else {
483
+ setTimeout(() => pollTest(nonce, provider, model, attempt + 1), 1200)
484
+ }
485
+ }).catch(() => {
486
+ if (attempt >= 55) giveUp()
487
+ else setTimeout(() => pollTest(nonce, provider, model, attempt + 1), 1500)
488
+ })
489
+ }
490
+
491
+ const fireTest = (provider, model) => {
492
+ if (!apiRef) return
493
+ const key = provider + '::' + model
494
+ setTestStates((s) => Object.assign({}, s, { [key]: { status: 'running' } }))
495
+ const nonce = Date.now() % 1000000000
496
+ const prompt = localStorage.getItem('mcm_test_prompt') || '用一句话介绍你自己'
497
+ const maxTokens = Number(localStorage.getItem('mcm_test_max_tokens')) || 256
498
+ apiRef.settings.update({
499
+ ns: 'model-channel-health',
500
+ patch: { testRequest: { nonce, provider, model, prompt, maxTokens } }
501
+ }).then(() => {
502
+ pollTest(nonce, provider, model)
503
+ }).catch((e) => {
504
+ setTestStates((s) => Object.assign({}, s, { [key]: { status: 'error', error: String((e && e.message) || e) } }))
505
+ })
506
+ }
507
+
508
+ const launchTest = (provider, model) => {
509
+ // 模型尚未保存到配置中 → 友好提示
510
+ const currentSavedModels = (props._state?.providers?.[provider]?.models || []).map((m) => m.id)
511
+ if (!currentSavedModels.includes(model)) {
512
+ setNotice('请先点击右上角「保存全部变更」,保存后方可进行真实链路测试')
513
+ setTimeout(() => setNotice(null), 4000)
514
+ return
515
+ }
516
+ const pCfg = providers[provider] || {}
517
+ const ref = typeof pCfg.apiKeyEnv === 'string' && pCfg.apiKeyEnv.trim() ? pCfg.apiKeyEnv.trim() : ''
518
+ const typed = (keyInput[provider] || '').trim()
519
+ // 无凭据引用 → 直接测试(例如原生认证渠道)
520
+ if (!ref) { fireTest(provider, model); return }
521
+ // 输入框有 Key → 自动写入存储后再测试(与 DSH Models 页同一语义:键入即写入)
522
+ if (typed) {
523
+ savedKeys[provider] = typed
524
+ apiRef.credentials.set({ ref, value: typed }).then((resp) => {
525
+ const r = resp && resp.result ? resp.result : resp
526
+ if (r && r.ok === false) throw new Error((r.error && (r.error.message || r.error)) || 'set failed')
527
+ setLive((l) => Object.assign({}, l, { [provider + '_key']: 'saved' }))
528
+ fireTest(provider, model)
529
+ }).catch((e) => {
530
+ setNotice('凭据写入失败: ' + String((e && e.message) || e))
531
+ setTimeout(() => setNotice(null), 5000)
532
+ })
533
+ return
534
+ }
535
+ // 输入框为空 → 询问凭据存储是否已配置
536
+ apiRef.credentials.describe({ refs: [ref] }).then((resp) => {
537
+ const r = resp && resp.result ? resp.result : resp
538
+ const d = r && r.value !== undefined ? r.value : r
539
+ const c = d && d.credentials && d.credentials[ref]
540
+ if (c && c.configured) { fireTest(provider, model); return }
541
+ setTestStates((s) => Object.assign({}, s, { [provider + '::' + model]: { status: 'error', code: 'MISSING_CREDENTIAL', error: '缺少 API Key: 请在「快速写入密钥」输入框中粘贴 Key 并点击「写入存储」,或设置环境变量 ' + ref } }))
542
+ setNotice('缺少 API Key(' + ref + '): 请粘贴 Key 到「快速写入密钥」并点击「写入存储」后再测试')
543
+ setTimeout(() => setNotice(null), 5000)
544
+ }).catch(() => fireTest(provider, model))
545
+ }
546
+ const updateP = (name, patch) => setDraft((d) => Object.assign({}, d, { [name]: Object.assign({}, d[name], patch) }))
547
+ const updateModel = (name, mi, patch) => setDraft((d) => { const ms = (d[name].models || []).map((mm, i) => i === mi ? Object.assign({}, mm, patch) : mm); return Object.assign({}, d, { [name]: Object.assign({}, d[name], { models: ms }) }) })
548
+ const removeModel = (name, mi) => setDraft((d) => { const ms = (d[name].models || []).filter((_, i) => i !== mi); return Object.assign({}, d, { [name]: Object.assign({}, d[name], { models: ms }) }) })
549
+ const saveKey = (name) => {
550
+ const ref = (providers[name] || {}).apiKeyEnv || ''
551
+ const val = (keyInput[name] || '').trim()
552
+ if (!ref || !val || !apiRef) return
553
+ savedKeys[name] = val
554
+ apiRef.credentials.set({ ref, value: val }).then((resp) => {
555
+ const r = resp && resp.result ? resp.result : resp
556
+ if (r && r.ok === false) throw new Error((r.error && (r.error.message || r.error)) || 'set failed')
557
+ setLive((l) => Object.assign({}, l, { [name + '_key']: 'saved' }))
558
+ }).catch((e) => setLive((l) => Object.assign({}, l, { [name + '_key']: 'err: ' + String((e && e.message) || e) })))
559
+ }
560
+
561
+ // 搜索过滤:provider id/显示名/模型 id/模型名子串匹配(不区分大小写);
562
+ // 搜索时命中卡片自动展开。拖拽坐标用 realIdx(全量列表真实下标),
563
+ // 过滤视图内拖放仍映射到全量列表的正确位置。
564
+ const pqLower = pq.trim().toLowerCase()
565
+ const visibleEntries = Object.entries(providers)
566
+ .map(([name, p], realIdx) => ({ name, p, realIdx }))
567
+ .filter(({ name, p }) => {
568
+ if (!pqLower) return true
569
+ if (name.toLowerCase().includes(pqLower)) return true
570
+ if (p.displayName && String(p.displayName).toLowerCase().includes(pqLower)) return true
571
+ return (p.models || []).some((m) => m && ((m.id || '').toLowerCase().includes(pqLower) || (m.name || '').toLowerCase().includes(pqLower)))
572
+ })
573
+ const cards = visibleEntries.map(({ name, p, realIdx }) => {
574
+ const isOpen = pqLower ? true : !!exp[name]
575
+ const models = p.models || []
576
+ const currentName = p.displayName !== undefined ? p.displayName : name
577
+ const isDragging = dragIdx === realIdx
578
+ const isDropTarget = overIdx === realIdx && dragIdx !== null && dragIdx !== realIdx
579
+ return el('div', {
580
+ className: 'mcm-card',
581
+ key: name,
582
+ style: Object.assign(
583
+ { transition: 'border-color 0.15s ease, opacity 0.15s ease' },
584
+ isDragging ? { opacity: 0.45 } : {},
585
+ isDropTarget ? { borderColor: 'var(--dsw-alias-brand-primary)', boxShadow: '0 0 0 2px rgba(59, 130, 246, 0.18)' } : {}
586
+ ),
587
+ onDragOver: (e) => {
588
+ e.preventDefault()
589
+ if (dragIdx !== null && dragIdx !== realIdx) setOverIdx(realIdx)
590
+ },
591
+ onDrop: (e) => {
592
+ e.preventDefault()
593
+ if (dragIdx !== null && dragIdx !== realIdx) moveProvider(dragIdx, realIdx)
594
+ setDragIdx(null)
595
+ setOverIdx(null)
596
+ }
597
+ },
598
+ el('div', { className: 'mcm-card-h', onClick: () => setExp((e) => Object.assign({}, e, { [name]: !e[name] })) },
599
+ el('span', {
600
+ title: '拖动此手柄调整供应商顺序(保存后生效)',
601
+ draggable: true,
602
+ onDragStart: (e) => { e.stopPropagation(); setDragIdx(idx); try { e.dataTransfer.effectAllowed = 'move'; e.dataTransfer.setData('text/plain', String(idx)) } catch (_e) {} },
603
+ onDragEnd: () => { setDragIdx(null); setOverIdx(null) },
604
+ onClick: (e) => e.stopPropagation(),
605
+ style: { cursor: 'grab', color: 'var(--dsw-alias-label-tertiary)', fontSize: 14, padding: '0 6px', userSelect: 'none' }
606
+ }, '⠿'),
607
+ el('span', { style: { fontSize: 11, color: 'var(--dsw-alias-label-tertiary)' } }, isOpen ? '▼' : '▶'),
608
+ el('div', { className: 'mcm-card-title' }, el('span', null, p.displayName || name), el('span', { className: 'mcm-badge brand' }, name)),
609
+ p.api ? el('span', { className: 'mcm-badge' }, p.api) : null,
610
+ el('span', { style: { fontSize: 12, color: 'var(--dsw-alias-label-tertiary)', marginLeft: 'auto' } }, models.length + ' 个模型'),
611
+ el('div', { style: { display: 'flex', gap: 6, marginLeft: 12 }, onClick: (e) => e.stopPropagation() },
612
+ btn('改名', () => setRenameWin({ from: name, value: name })),
613
+ btn('删', () => { if (confirm('删除 Provider ' + name + '?')) setDraft((d) => { const n = clone(d); delete n[name]; return n }) }, 'danger')
614
+ )
615
+ ),
616
+ isOpen ? el('div', { className: 'mcm-editor' },
617
+ el('div', { className: 'mcm-row' },
618
+ tf('显示名称', '管理面板中的可读名称', currentName, (v) => updateP(name, { displayName: v })),
619
+ sel('协议架构 (API)', '当前支持的请求格式', p.api || 'openai-completions', APIS, (v) => updateP(name, { api: v }))
620
+ ),
621
+ el('div', { className: 'mcm-row' },
622
+ tf('Base URL', 'API 端点基础地址', p.baseURL || '', (v) => updateP(name, { baseURL: v }), true),
623
+ tf('API Key 环境变量名', '凭据存储引用名', p.apiKeyEnv || '', (v) => updateP(name, { apiKeyEnv: v }), true)
624
+ ),
625
+ field('快速写入密钥', '将 API Key 安全存入 DSH 凭据存储', false,
626
+ el('div', { style: { display: 'flex', gap: 8, alignItems: 'center' } },
627
+ el('input', { type: 'password', className: 'mcm-in mono', style: { flex: 1 }, placeholder: '输入并覆盖 API Key (sk-...)', value: keyInput[name] || '', onChange: (e) => setKeyInput((k) => Object.assign({}, k, { [name]: e.target.value })) }),
628
+ btn('写入存储', () => saveKey(name)),
629
+ live[name + '_key'] === 'saved' ? el('span', { style: { color: 'var(--dsw-alias-state-success-primary)', fontSize: 12 } }, '✓ 已保存') : null
630
+ )
631
+ ),
632
+ el('div', { style: { borderTop: '1px dashed var(--dsw-alias-border-l2)', paddingTop: 10 } },
633
+ el('div', { style: { cursor: 'pointer', fontSize: 12, fontWeight: 600, color: 'var(--dsw-alias-label-secondary)', display: 'flex', alignItems: 'center', gap: 6 }, onClick: () => setSa((s) => Object.assign({}, s, { [name]: !s[name] })) },
634
+ el('span', null, sa[name] ? '▼' : '▶'), '供应商高级选项 (Headers, 传输, 超时, 图片预算, 重试策略, Compat)'
635
+ ),
636
+ sa[name] ? el('div', { style: { display: 'flex', flexDirection: 'column', gap: 12, marginTop: 10 } },
637
+ el('div', { className: 'mcm-row' },
638
+ nf('默认 Context Window', '未单独配置模型时的默认窗口', p.defaultContextWindow || 0, (v) => updateP(name, { defaultContextWindow: v })),
639
+ nf('默认 Max Tokens', '未单独配置模型时的最大输出', p.defaultMaxTokens || 0, (v) => updateP(name, { defaultMaxTokens: v }))
640
+ ),
641
+ field('自定义 HTTP Headers', '向上游端点发送的请求头', false, kvEditor(p.headers || {}, (v) => updateP(name, { headers: v }))),
642
+ el('div', { className: 'mcm-row' },
643
+ sel('默认思考强度 (Reasoning)', '供应商默认', p.reasoning || '', LEVELS, (v) => updateP(name, { reasoning: v || undefined })),
644
+ sel('传输通道 (Transport)', '协议传输形式', p.transport || 'auto', TRANSPORTS, (v) => updateP(name, { transport: v })),
645
+ sel('Prompt 缓存保留', 'KV Cache 策略', p.cacheRetention || 'none', CACHE, (v) => updateP(name, { cacheRetention: v }))
646
+ ),
647
+ el('div', { className: 'mcm-row' },
648
+ nf('请求超时 (ms)', '首响应超时时间', p.timeoutMs || 0, (v) => updateP(name, { timeoutMs: v })),
649
+ nf('流空闲超时 (ms)', 'chunk 之间最大停顿', p.streamIdleTimeoutMs || 0, (v) => updateP(name, { streamIdleTimeoutMs: v })),
650
+ nf('WebSocket 超时 (ms)', '连接建立等待', p.websocketConnectTimeoutMs || 0, (v) => updateP(name, { websocketConnectTimeoutMs: v }))
651
+ ),
652
+ el('div', { className: 'mcm-row' },
653
+ nf('图片总像素上限', 'requestImagePixelBudget', p.requestImagePixelBudget || 0, (v) => updateP(name, { requestImagePixelBudget: v })),
654
+ nf('单张图片字节上限', 'requestImageMaxBytes', p.requestImageMaxBytes || 0, (v) => updateP(name, { requestImageMaxBytes: v }))
655
+ ),
656
+ field('供应商级 Compat 兼容选项', '端点级 API 行为修正', false, compatEditor(p.compat, (v) => updateP(name, { compat: v }), p.api || 'openai-completions'))
657
+ ) : null
658
+ ),
659
+ el('div', { style: { marginTop: 8 } },
660
+ el('div', { style: { display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 10 } },
661
+ el('span', { style: { fontWeight: 600, fontSize: 13 } }, '模型列表 (' + models.length + ')'),
662
+ el('div', { style: { display: 'flex', gap: 6 } },
663
+ btn('🔍 拉取上游模型', () => { setFq(''); setDisc({ provider: name, loading: true }); doFetch(name, p, setDisc, keyInput[name]) }, 'primary'),
664
+ btn('+添加模型', () => updateP(name, { models: models.concat([{ id: '', name: '', contextWindow: 1048576, maxTokens: 131072, input: ['text', 'image'], reasoningEfforts: { off: null, low: 'low', medium: 'medium', high: 'high', xhigh: 'xhigh', max: 'max' } }]) }))
665
+ )
666
+ ),
667
+ fetchModal(name, p, disc, setDisc, () => keyInput[name], (ids) => {
668
+ const cs = new Set(ids)
669
+ const kept = models.filter((m) => cs.has(m.id))
670
+ const ex = new Set(kept.map((m) => m.id))
671
+ const news = [...cs].filter((id) => !ex.has(id)).map((id) => ({
672
+ id,
673
+ name: id,
674
+ contextWindow: 1048576,
675
+ maxTokens: 131072,
676
+ input: ['text', 'image'],
677
+ reasoningEfforts: { off: null, low: 'low', medium: 'medium', high: 'high', xhigh: 'xhigh', max: 'max' }
678
+ }))
679
+ updateP(name, { models: kept.concat(news) })
680
+ }, fq, setFq),
681
+ models.map((m, mi) => {
682
+ const isMOpen = !!em[name + '::' + mi]
683
+ const ts = testStates[name + '::' + m.id]
684
+ return el('div', { className: 'mcm-subcard', key: mi },
685
+ el('div', { className: 'mcm-subcard-h', onClick: () => setEm((e) => Object.assign({}, e, { [name + '::' + mi]: !e[name + '::' + mi] })) },
686
+ el('span', { style: { fontSize: 10, color: 'var(--dsw-alias-label-tertiary)' } }, isMOpen ? '▼' : '▶'),
687
+ el('span', { style: { fontFamily: 'var(--ds-font-family-code)', fontWeight: 600, fontSize: 12 } }, m.id || '(未命名)'),
688
+ (m.input || []).includes('image') ? el('span', { className: 'mcm-badge success' }, 'vision') : null,
689
+ m.reasoningEfforts ? el('span', { className: 'mcm-badge brand' }, 'reasoning') : null,
690
+ ts ? (
691
+ ts.status === 'running' ? el('span', { className: 'mcm-badge brand' }, '测试中…') :
692
+ ts.status === 'ok' ? el('span', {
693
+ className: 'mcm-badge success',
694
+ style: { cursor: 'pointer' },
695
+ title: '点击查看成功详情与回复',
696
+ onClick: (e) => { e.stopPropagation(); setDetailWin({ provider: name, model: m.id, result: ts }) }
697
+ }, '✓ 成功 (详情)') :
698
+ el('span', {
699
+ className: 'mcm-badge error',
700
+ style: { cursor: 'pointer' },
701
+ title: '点击查看具体异常错误原因',
702
+ onClick: (e) => { e.stopPropagation(); setDetailWin({ provider: name, model: m.id, result: ts }) }
703
+ }, '✗ 异常 (详情)')
704
+ ) : null,
705
+ el('div', { style: { marginLeft: 'auto', display: 'flex', gap: 6 }, onClick: (e) => e.stopPropagation() },
706
+ btn('⚡测试', () => launchTest(name, m.id), 'primary'),
707
+ btn('✕', () => removeModel(name, mi), 'danger')
708
+ )
709
+ ),
710
+ isMOpen ? el('div', { className: 'mcm-subcard-b' },
711
+ el('div', { className: 'mcm-row' },
712
+ tf('模型 ID', '唯一模型标识', m.id || '', (v) => updateModel(name, mi, { id: v }), true),
713
+ tf('显示别名', 'UI 呈现名称', m.name || '', (v) => updateModel(name, mi, { name: v }))
714
+ ),
715
+ el('div', { className: 'mcm-row' },
716
+ nf('Context Window', '上下文窗口长度', m.contextWindow || 0, (v) => updateModel(name, mi, { contextWindow: v })),
717
+ nf('Max Tokens', '最大生成长度', m.maxTokens || 0, (v) => updateModel(name, mi, { maxTokens: v }))
718
+ ),
719
+ field('支持的模态 (Input)', 'text=纯文本, image=图片', false,
720
+ el('div', { style: { display: 'flex', gap: 16, alignItems: 'center', height: 32 } },
721
+ el('label', { style: { display: 'inline-flex', alignItems: 'center', gap: 6, cursor: 'pointer' } },
722
+ el('input', { type: 'checkbox', checked: (m.input || ['text']).includes('text'), onChange: (e) => {
723
+ const cur = new Set(m.input || ['text'])
724
+ if (e.target.checked) cur.add('text'); else cur.delete('text')
725
+ updateModel(name, mi, { input: [...cur] })
726
+ } }),
727
+ el('span', null, 'text (文本)')
728
+ ),
729
+ el('label', { style: { display: 'inline-flex', alignItems: 'center', gap: 6, cursor: 'pointer' } },
730
+ el('input', { type: 'checkbox', checked: (m.input || []).includes('image'), onChange: (e) => {
731
+ const cur = new Set(m.input || ['text'])
732
+ if (e.target.checked) cur.add('image'); else cur.delete('image')
733
+ updateModel(name, mi, { input: [...cur] })
734
+ } }),
735
+ el('span', null, 'image (图片/视觉)')
736
+ )
737
+ )
738
+ ),
739
+ field('思考强度映射 (Reasoning Efforts)', '各档发往上游的值映射 (high, xhigh, max 等)', false, levelsEditor(m.reasoningEfforts, (v) => updateModel(name, mi, { reasoningEfforts: v }))),
740
+ el('div', { style: { borderTop: '1px dashed var(--dsw-alias-border-l2)', paddingTop: 8 } },
741
+ el('div', { style: { cursor: 'pointer', fontSize: 12, fontWeight: 500, color: 'var(--dsw-alias-label-secondary)', display: 'flex', alignItems: 'center', gap: 6 }, onClick: (e) => { e.stopPropagation(); setEm((s) => Object.assign({}, s, { ['cx_' + name + '_' + mi]: !s['cx_' + name + '_' + mi] })) } },
742
+ el('span', null, em['cx_' + name + '_' + mi] ? '▼' : '▶'), '模型级 Compat 兼容性覆写'
743
+ ),
744
+ em['cx_' + name + '_' + mi] ? el('div', { style: { marginTop: 8 } }, compatEditor(m.compat, (v) => updateModel(name, mi, { compat: v }), p.api || 'openai-completions')) : null
745
+ )
746
+ ) : null
747
+ )
748
+ })
749
+ )
750
+ ) : null
751
+ )
752
+ })
753
+
754
+ return el('div', null,
755
+ el('div', { style: { display: 'flex', justifyContent: 'space-between', alignItems: 'center', gap: 12, marginBottom: 12 } },
756
+ el('span', { style: { fontSize: 12, color: 'var(--dsw-alias-label-secondary)' } }, pqLower
757
+ ? ('匹配 ' + visibleEntries.length + ' / ' + Object.keys(providers).length + ' 个提供商')
758
+ : '已配置 ' + Object.keys(providers).length + ' 个提供商 · 拖动左侧 ⠿ 手柄可调整顺序(保存后生效)'),
759
+ el('div', { style: { display: 'flex', gap: 8, alignItems: 'center' } },
760
+ el('input', {
761
+ className: 'mcm-in', style: { width: 220, height: 30 },
762
+ placeholder: '搜索供应商 / 模型…', value: pq,
763
+ onChange: (e) => setPq(e.target.value)
764
+ }),
765
+ btn('⚙️ 配置全局测试参数', () => setConfigWin(true))
766
+ )
767
+ ),
768
+ ...cards,
769
+ visibleEntries.length === 0 && pqLower ? el('div', { className: 'mcm-empty' }, '无匹配「' + pq.trim() + '」的供应商或模型') : null,
770
+ btn('+新增提供商 (Provider)', () => {
771
+ const nm = uniqueSuffixName('provider-', (n) => Object.prototype.hasOwnProperty.call(providers, n))
772
+ const keyRef = normalizeCredentialRef(nm + '_api_key')
773
+ setDraft((d) => Object.assign({}, d || {}, { [nm]: { api: 'openai-completions', baseURL: '', apiKeyEnv: keyRef, displayName: nm, models: [], headers: Object.assign({}, DEFAULT_HEADERS) } }))
774
+ setExp((e) => Object.assign({}, e, { [nm]: true }))
775
+ }, 'primary'),
776
+ el(GlobalTestConfigModal, { win: configWin, setWin: setConfigWin }),
777
+ el(TestResultDetailModal, { win: detailWin, setWin: setDetailWin }),
778
+ el(RenameProviderModal, { win: renameWin, setWin: setRenameWin, onRename: renameProvider })
779
+ )
780
+ }
781
+
782
+ function candidatePicker(providers, cand, onSet) {
783
+ const pName = cand && cand.provider ? cand.provider : ''
784
+ const mName = cand && cand.model ? cand.model : ''
785
+ const providerCfg = (providers && providers[pName]) || {}
786
+ const modelOptions = ((providerCfg && providerCfg.models) || []).map((m) => m.id).filter(Boolean)
787
+ const provNames = Object.keys(providers || {})
788
+ return el('div', { style: { display: 'flex', gap: 8, alignItems: 'center', flex: 1 } },
789
+ el('select', {
790
+ className: 'mcm-in',
791
+ style: { flex: 1 },
792
+ value: pName,
793
+ onChange: (e) => {
794
+ const nextProv = e.target.value
795
+ const nextCfg = (providers && providers[nextProv]) || {}
796
+ const nextModels = ((nextCfg && nextCfg.models) || []).map((m) => m.id).filter(Boolean)
797
+ onSet({ provider: nextProv, model: nextModels[0] || '' })
798
+ }
799
+ },
800
+ el('option', { value: '' }, '— 选择 Provider —'),
801
+ ...provNames.map((p) => el('option', { key: p, value: p }, p))
802
+ ),
803
+ el('select', {
804
+ className: 'mcm-in',
805
+ style: { flex: 1 },
806
+ value: mName,
807
+ onChange: (e) => onSet({ provider: pName, model: e.target.value })
808
+ },
809
+ el('option', { value: '' }, '— 选择模型 —'),
810
+ ...modelOptions.map((m) => el('option', { key: m, value: m }, m))
811
+ )
812
+ )
813
+ }
814
+
815
+ function RoundrobinPanel(props) {
816
+ const groups = props.channelsDraft || []
817
+ const providers = props._providers || {}
818
+ const [speedState, setSpeedState] = useState({})
819
+ const [expanded, setExpanded] = useState({})
820
+
821
+ const addGroup = () => props.setChannelsDraft((d) => {
822
+ const base = d || []
823
+ const id = uniqueSuffixName('group-', (n) => base.some((g) => g && g.id === n))
824
+ return base.concat([{
825
+ id,
826
+ // 默认呈现名 = 组 id:多个轮询组在目录/选择器里靠名字区分(固定
827
+ // 'RoundRobin' 会全部撞名,实测反馈),需要别的名字在「虚拟模型呈现名」改;
828
+ // 默认模态含 image:host 对当前会话模型做 resolveModelInfo,缺 image 时
829
+ // 附加图片直接被拒(MODEL_DOES_NOT_SUPPORT_IMAGES),按直觉默认放开
830
+ virtualModel: { name: id, reasoning: true, input: ['text', 'image'], contextWindow: 1048576, maxTokens: 131072 },
831
+ candidates: [],
832
+ strategy: 'sticky',
833
+ timeoutMs: 30000,
834
+ cooldownMs: 60000,
835
+ maxRetriesPerCandidate: 2,
836
+ speedTest: { enabled: false, sortKey: 'ttft', prompt: '欧拉函数的意义?', maxTokens: 2048, timeoutMs: 60000, concurrency: 3, retries: 2 }
837
+ }])
838
+ })
839
+
840
+ const patchGroup = (i, patch) => props.setChannelsDraft((d) => d.map((g, gi) => gi === i ? Object.assign({}, g, patch) : g))
841
+ const patchGroupPath = (i, path, value) => props.setChannelsDraft((d) => d.map((g, gi) => {
842
+ if (gi !== i) return g
843
+ const cur = clone(g)
844
+ let ref = cur
845
+ for (let k = 0; k < path.length - 1; k++) {
846
+ if (!ref[path[k]]) ref[path[k]] = {}
847
+ ref = ref[path[k]]
848
+ }
849
+ ref[path[path.length - 1]] = value
850
+ return cur
851
+ }))
852
+
853
+ const speedtest = (gid) => {
854
+ if (!apiRef) return
855
+ const nonce = Date.now() % 1000000000
856
+ setSpeedState((s) => Object.assign({}, s, { [gid]: 'running' }))
857
+ apiRef.settings.update({ ns: 'model-channel-health', patch: { speedRequest: { group: gid, nonce } } }).then(() => {
858
+ setSpeedState((s) => Object.assign({}, s, { [gid]: 'sent' }))
859
+ setTimeout(() => setSpeedState((s) => Object.assign({}, s, { [gid]: null })), 3000)
860
+ }).catch(() => setSpeedState((s) => Object.assign({}, s, { [gid]: 'err' })))
861
+ }
862
+
863
+ if (groups.length === 0) return el('div', { className: 'mcm-empty' }, '暂无轮询组,点击下方按钮创建', el('div', { style: { marginTop: 12 } }, btn('+新增轮询组', addGroup, 'primary')))
864
+
865
+ return el('div', null,
866
+ groups.map((g, i) => {
867
+ const isOpen = !!expanded[i]
868
+ const vm = g.virtualModel || {}
869
+ const stCfg = g.speedTest || {}
870
+ const st = speedState[g.id]
871
+ return el('div', { className: 'mcm-card', key: 'rr_card_' + i },
872
+ el('div', { className: 'mcm-card-h', onClick: () => setExpanded((e) => Object.assign({}, e, { [i]: !e[i] })) },
873
+ el('span', { style: { fontSize: 11, color: 'var(--dsw-alias-label-tertiary)' } }, isOpen ? '▼' : '▶'),
874
+ el('div', { className: 'mcm-card-title' }, el('span', null, vm.name || g.id), el('span', { className: 'mcm-badge brand' }, 'roundrobin/' + g.id)),
875
+ el('span', { className: 'mcm-badge' }, (g.candidates || []).length + ' 候选'),
876
+ el('span', { className: 'mcm-badge' }, g.strategy || 'sticky'),
877
+ el('div', { style: { marginLeft: 'auto', display: 'flex', gap: 6 }, onClick: (e) => e.stopPropagation() },
878
+ btn('⚡测速排序', () => speedtest(g.id), 'primary'),
879
+ st === 'running' ? el('span', { className: 'mcm-badge brand' }, '测速中…') : st === 'sent' ? el('span', { className: 'mcm-badge success' }, '已触发') : null,
880
+ btn('删', () => { if (confirm('删除轮询组 ' + g.id + '?')) props.setChannelsDraft((d) => d.filter((_, gi) => gi !== i)) }, 'danger')
881
+ )
882
+ ),
883
+ isOpen ? el('div', { className: 'mcm-editor' },
884
+ el('div', { className: 'mcm-row' },
885
+ tf('组唯一 ID', '对应虚拟路由 roundrobin/<id>', g.id || '', (v) => patchGroup(i, { id: v }), true),
886
+ tf('虚拟模型呈现名', '对话侧栏显示的名字', vm.name || '', (v) => patchGroupPath(i, ['virtualModel', 'name'], v))
887
+ ),
888
+ field('输入模态', '声明虚拟模型接受的输入;不含图片时附加图片会被 host 直接拒绝(MODEL_DOES_NOT_SUPPORT_IMAGES)', false,
889
+ el('div', { style: { display: 'flex', gap: 16, alignItems: 'center', height: 32 } },
890
+ el('label', { style: { display: 'inline-flex', alignItems: 'center', gap: 6, opacity: 0.7 } },
891
+ el('input', { type: 'checkbox', checked: true, disabled: true }),
892
+ el('span', { style: { fontSize: 12 } }, 'text(固定)')
893
+ ),
894
+ el('label', { style: { display: 'inline-flex', alignItems: 'center', gap: 6, cursor: 'pointer' } },
895
+ el('input', { type: 'checkbox', checked: (vm.input || ['text']).includes('image'), onChange: (e) => patchGroupPath(i, ['virtualModel', 'input'], e.target.checked ? ['text', 'image'] : ['text']) }),
896
+ el('span', { style: { fontSize: 12 } }, 'image(图片/视觉)')
897
+ )
898
+ )
899
+ ),
900
+ (g.id && !/^[a-z0-9][a-z0-9-]*$/.test(g.id)) ? el('div', { style: { color: 'var(--dsw-alias-state-error-primary)', fontSize: 11 } }, '⚠ 组 ID 仅允许小写字母/数字/连字符,且以字母或数字开头;不合法的组保存后会被 host 静默丢弃') : null,
901
+ field('候选渠道池 (按序故障转移)', '首选失败后自动原地重试或切入下一候选', false,
902
+ el('div', { style: { display: 'flex', flexDirection: 'column', gap: 8 } },
903
+ (g.candidates || []).map((c, ci) => el('div', { key: 'cand_' + ci, style: { display: 'flex', gap: 8, alignItems: 'center' } },
904
+ el('span', { className: 'mcm-badge' }, '#' + (ci + 1)),
905
+ candidatePicker(providers, c, (nc) => {
906
+ const curCands = (g.candidates || []).slice()
907
+ curCands[ci] = nc
908
+ patchGroup(i, { candidates: curCands })
909
+ }),
910
+ btn('✕', () => {
911
+ const curCands = (g.candidates || []).filter((_, gi) => gi !== ci)
912
+ patchGroup(i, { candidates: curCands })
913
+ }, 'danger')
914
+ )),
915
+ el('div', { style: { marginTop: 4 } }, btn('+添加候选', () => {
916
+ const curCands = (g.candidates || []).concat([{ provider: '', model: '' }])
917
+ patchGroup(i, { candidates: curCands })
918
+ }))
919
+ )
920
+ ),
921
+ el('div', { className: 'mcm-row' },
922
+ sel('路由策略', '切换规则', g.strategy || 'sticky', [['sticky', 'Sticky (成功即锁定)'], ['round-robin', 'Round-Robin (轮询均分)'], ['primary', 'Primary (优先首选)']], (v) => patchGroup(i, { strategy: v })),
923
+ nf('超时时间 (ms)', '单候选首响应超时', g.timeoutMs || 30000, (v) => patchGroup(i, { timeoutMs: v })),
924
+ nf('冷却时间 (ms)', '故障后冷却时间', g.cooldownMs || 60000, (v) => patchGroup(i, { cooldownMs: v })),
925
+ nf('单候选重试次数', '冷却前原地退避重试', g.maxRetriesPerCandidate || 0, (v) => patchGroup(i, { maxRetriesPerCandidate: v }))
926
+ ),
927
+ el('div', { style: { borderTop: '1px solid var(--dsw-alias-border-l1)', paddingTop: 12 } },
928
+ el('span', { style: { fontWeight: 600, fontSize: 12, display: 'block', marginBottom: 8 } }, '⚡ 动态测速排序设置'),
929
+ el('div', { className: 'mcm-row' },
930
+ field('开启自动测速', '请求前测速并按指标自动重排候选', false,
931
+ el('label', { style: { display: 'flex', alignItems: 'center', gap: 6, cursor: 'pointer', height: 34 } },
932
+ el('input', { type: 'checkbox', checked: stCfg.enabled !== false, onChange: (e) => patchGroupPath(i, ['speedTest', 'enabled'], e.target.checked) }),
933
+ el('span', null, stCfg.enabled !== false ? '已启用' : '已禁用')
934
+ )
935
+ ),
936
+ sel('排序基准', '排序算法', stCfg.sortKey || 'ttft', [['ttft', 'TTFT 首字延迟优先'], ['latency', 'Total Latency 总延迟优先'], ['hybrid', 'Hybrid 加权混合'], ['smart', 'Smart 智能可靠性平滑']], (v) => patchGroupPath(i, ['speedTest', 'sortKey'], v)),
937
+ nf('并发测速数', '同时测速线程', stCfg.concurrency || 3, (v) => patchGroupPath(i, ['speedTest', 'concurrency'], v))
938
+ )
939
+ )
940
+ ) : null
941
+ )
942
+ }),
943
+ btn('+新增轮询组', addGroup, 'primary')
944
+ )
945
+ }
946
+
947
+ function HealthPanel(props) {
948
+ const health = props.health
949
+ const providers = props._providers || {}
950
+ const [windowMode, setWindowMode] = useState('30m') // '30m' | '24h' | '7d'
951
+
952
+ if (!health) return el('div', { className: 'mcm-empty' }, '健康统计数据准备中…')
953
+
954
+ const now = Date.now()
955
+ const windowCutoff = windowMode === '30m' ? now - 30 * 60 * 1000 : windowMode === '24h' ? now - 24 * 3600 * 1000 : 0
956
+ const recsMap = health.records || {}
957
+ const rawEvents = Object.values(recsMap).flat()
958
+ // 时间窗口过滤
959
+ const allEvents = rawEvents.filter((e) => (e.ts || 0) >= windowCutoff)
960
+
961
+ // 按 provider 分组
962
+ const byProvider = new Map()
963
+
964
+ // 先把用户配置好的所有 providers 和 models 填入字典(确保所有模型都有展示位)
965
+ for (const [pName, pCfg] of Object.entries(providers)) {
966
+ let pMap = byProvider.get(pName)
967
+ if (!pMap) { pMap = new Map(); byProvider.set(pName, pMap) }
968
+ for (const m of (pCfg.models || [])) {
969
+ if (m && m.id) {
970
+ pMap.set(m.id, {
971
+ provider: pName,
972
+ model: m.id,
973
+ name: m.name || m.id,
974
+ total: 0,
975
+ success: 0,
976
+ fail: 0,
977
+ ttftSum: 0,
978
+ latSum: 0,
979
+ lastTs: 0,
980
+ lastOk: null,
981
+ lastCode: null,
982
+ recentErrors: 0
983
+ })
984
+ }
985
+ }
986
+ }
987
+
988
+ // 累加时间窗口内的真实流水记录
989
+ for (const e of allEvents) {
990
+ if (!e || !e.provider || !e.model) continue
991
+ let pMap = byProvider.get(e.provider)
992
+ if (!pMap) { pMap = new Map(); byProvider.set(e.provider, pMap) }
993
+ let a = pMap.get(e.model)
994
+ if (!a) {
995
+ a = {
996
+ provider: e.provider,
997
+ model: e.model,
998
+ name: e.model,
999
+ total: 0,
1000
+ success: 0,
1001
+ fail: 0,
1002
+ ttftSum: 0,
1003
+ latSum: 0,
1004
+ lastTs: 0,
1005
+ lastOk: null,
1006
+ lastCode: null,
1007
+ recentErrors: 0
1008
+ }
1009
+ pMap.set(e.model, a)
1010
+ }
1011
+ a.total++
1012
+ if (e.ok) {
1013
+ a.success++
1014
+ if (e.ttftMs != null && e.ttftMs >= 0) a.ttftSum += e.ttftMs
1015
+ if (e.latencyMs != null && e.latencyMs >= 0) a.latSum += e.latencyMs
1016
+ } else {
1017
+ a.fail++
1018
+ if (e.code) a.lastCode = e.code
1019
+ }
1020
+ if ((e.ts || 0) > a.lastTs) {
1021
+ a.lastTs = e.ts || 0
1022
+ a.lastOk = e.ok
1023
+ }
1024
+ }
1025
+
1026
+ const totalRequests = allEvents.length
1027
+ const totalSuccess = allEvents.filter((x) => x.ok).length
1028
+ const globalRate = totalRequests > 0 ? ((totalSuccess / totalRequests) * 100).toFixed(1) + '%' : '100%'
1029
+ const validTtfts = allEvents.filter((x) => x.ok && x.ttftMs != null).map((x) => x.ttftMs)
1030
+ const avgGlobalTtft = validTtfts.length > 0 ? (validTtfts.reduce((a, b) => a + b, 0) / validTtfts.length / 1000).toFixed(2) + 's' : '—'
1031
+
1032
+ const providerGroups = [...byProvider.entries()].map(([provName, modelMap]) => {
1033
+ const models = [...modelMap.values()].map((m) => {
1034
+ const rate = m.total > 0 ? m.success / m.total : 1
1035
+ // 可用状态判定:基于当前时间窗口与最近调用
1036
+ let statusText = '空闲 (未调用)'
1037
+ let statusKind = 'idle'
1038
+ if (m.total > 0) {
1039
+ if (m.lastOk && rate >= 0.8) {
1040
+ statusText = '在线可用'
1041
+ statusKind = 'ok'
1042
+ } else if (rate >= 0.5) {
1043
+ statusText = '服务降级 (伴随异常)'
1044
+ statusKind = 'warn'
1045
+ } else {
1046
+ statusText = '不可用 / 服务故障'
1047
+ statusKind = 'err'
1048
+ }
1049
+ }
1050
+ return {
1051
+ ...m,
1052
+ statusText,
1053
+ statusKind,
1054
+ ratePercent: m.total > 0 ? (rate * 100).toFixed(0) + '%' : '未调用',
1055
+ rateValue: rate,
1056
+ avgTtft: m.success > 0 && m.ttftSum > 0 ? (m.ttftSum / m.success / 1000).toFixed(2) + 's' : '—',
1057
+ avgLat: m.success > 0 && m.latSum > 0 ? (m.latSum / m.success / 1000).toFixed(2) + 's' : '—',
1058
+ lastTimeStr: m.lastTs > 0 ? new Date(m.lastTs).toLocaleTimeString() : '无调用记录'
1059
+ }
1060
+ })
1061
+ models.sort((a, b) => (b.total - a.total) || (b.rateValue - a.rateValue))
1062
+ const pTotal = models.reduce((acc, m) => acc + m.total, 0)
1063
+ const pSuccess = models.reduce((acc, m) => acc + m.success, 0)
1064
+ const pRate = pTotal > 0 ? ((pSuccess / pTotal) * 100).toFixed(0) + '%' : '—'
1065
+ return { provider: provName, models, total: pTotal, success: pSuccess, rate: pRate }
1066
+ })
1067
+
1068
+ providerGroups.sort((a, b) => b.total - a.total)
1069
+
1070
+ return el('div', null,
1071
+ el('div', { style: { display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 14 } },
1072
+ el('div', { style: { display: 'flex', alignItems: 'center', gap: 6 } },
1073
+ el('span', { style: { fontSize: 12, fontWeight: 600, color: 'var(--dsw-alias-label-secondary)' } }, '统计时间窗口:'),
1074
+ el('div', { className: 'mcm-nav', style: { padding: 2 } },
1075
+ el('div', { className: 'mcm-nav-item' + (windowMode === '30m' ? ' active' : ''), style: { padding: '4px 12px', fontSize: 11 }, onClick: () => setWindowMode('30m') }, '🟢 近 30 分钟 (实时探针)'),
1076
+ el('div', { className: 'mcm-nav-item' + (windowMode === '24h' ? ' active' : ''), style: { padding: '4px 12px', fontSize: 11 }, onClick: () => setWindowMode('24h') }, '🟡 近 24 小时'),
1077
+ el('div', { className: 'mcm-nav-item' + (windowMode === '7d' ? ' active' : ''), style: { padding: '4px 12px', fontSize: 11 }, onClick: () => setWindowMode('7d') }, '🔵 近 7 天全量')
1078
+ )
1079
+ ),
1080
+ el('div', { style: { display: 'flex', alignItems: 'center', gap: 6, fontSize: 11, color: 'var(--dsw-alias-state-success-primary)' } },
1081
+ el('span', { className: 'mcm-status-dot ok' }),
1082
+ el('span', null, '前台 5s 自动刷新流')
1083
+ )
1084
+ ),
1085
+ el('div', { className: 'mcm-metrics-grid' },
1086
+ el('div', { className: 'mcm-metric-card' },
1087
+ el('span', { className: 'mcm-metric-label' }, windowMode === '30m' ? '近 30 分钟请求' : windowMode === '24h' ? '近 24 小时请求' : '7 天全周期请求'),
1088
+ el('span', { className: 'mcm-metric-value' }, totalRequests),
1089
+ el('span', { style: { fontSize: 11, color: 'var(--dsw-alias-label-tertiary)' } }, '真实上游交互捕获')
1090
+ ),
1091
+ el('div', { className: 'mcm-metric-card' },
1092
+ el('span', { className: 'mcm-metric-label' }, '窗口可用率'),
1093
+ el('span', { className: 'mcm-metric-value', style: { color: totalSuccess === totalRequests ? 'var(--dsw-alias-state-success-primary)' : 'var(--dsw-alias-label-primary)' } }, globalRate),
1094
+ el('span', { style: { fontSize: 11, color: 'var(--dsw-alias-label-tertiary)' } }, totalSuccess + ' 成功 / ' + (totalRequests - totalSuccess) + ' 异常')
1095
+ ),
1096
+ el('div', { className: 'mcm-metric-card' },
1097
+ el('span', { className: 'mcm-metric-label' }, '平均首 Token 延迟 (TTFT)'),
1098
+ el('span', { className: 'mcm-metric-value' }, avgGlobalTtft),
1099
+ el('span', { style: { fontSize: 11, color: 'var(--dsw-alias-label-tertiary)' } }, '首字响应速度均值')
1100
+ )
1101
+ ),
1102
+ providerGroups.length === 0 ? el('div', { className: 'mcm-empty' }, '暂无已配置供应商或调用记录') :
1103
+ providerGroups.map((pg) => el('div', { className: 'mcm-card', key: pg.provider, style: { marginBottom: 16 } },
1104
+ el('div', { style: { padding: '12px 16px', background: 'var(--dsw-alias-bg-layer-2)', borderBottom: '1px solid var(--dsw-alias-border-l1)', display: 'flex', alignItems: 'center', justifyContent: 'space-between' } },
1105
+ el('div', { style: { display: 'flex', alignItems: 'center', gap: 10 } },
1106
+ el('span', { className: 'mcm-badge brand', style: { fontSize: 12, padding: '3px 10px' } }, pg.provider),
1107
+ el('span', { style: { fontSize: 12, color: 'var(--dsw-alias-label-secondary)' } }, pg.models.length + ' 个模型')
1108
+ ),
1109
+ el('div', { style: { display: 'flex', gap: 12, fontSize: 12 } },
1110
+ el('span', { style: { color: 'var(--dsw-alias-label-tertiary)' } }, '窗口调用: ', el('strong', { style: { color: 'var(--dsw-alias-label-primary)' } }, pg.total)),
1111
+ el('span', { style: { color: 'var(--dsw-alias-label-tertiary)' } }, '可用率: ', el('strong', { style: { color: 'var(--dsw-alias-state-success-primary)' } }, pg.rate))
1112
+ )
1113
+ ),
1114
+ el('div', { style: { padding: 12 }, className: 'mcm-health-grid' },
1115
+ pg.models.map((m) => el('div', { className: 'mcm-health-card', key: pg.provider + '::' + m.model, style: { background: 'var(--dsw-alias-bg-layer-1)' } },
1116
+ el('div', { className: 'mcm-health-card-h' },
1117
+ el('div', { style: { display: 'flex', alignItems: 'center', gap: 8 } },
1118
+ el('span', {
1119
+ className: 'mcm-status-dot ' + (m.statusKind === 'idle' ? 'idle' : m.statusKind === 'ok' ? 'ok' : 'err'),
1120
+ style: m.statusKind === 'idle' ? { background: 'var(--dsw-alias-label-tertiary)' } : m.statusKind === 'warn' ? { background: 'var(--dsw-alias-state-warn-primary)' } : {}
1121
+ }),
1122
+ el('span', { style: { fontWeight: 600, fontSize: 13, fontFamily: 'var(--ds-font-family-code)' } }, m.model)
1123
+ ),
1124
+ el('span', {
1125
+ className: 'mcm-badge ' + (m.statusKind === 'ok' ? 'success' : m.statusKind === 'warn' ? 'brand' : m.statusKind === 'err' ? 'error' : '')
1126
+ }, m.statusText)
1127
+ ),
1128
+ el('div', null,
1129
+ el('div', { style: { display: 'flex', justifyContent: 'space-between', fontSize: 11, color: 'var(--dsw-alias-label-secondary)', marginBottom: 3 } },
1130
+ el('span', null, '可用率: ', el('strong', { style: { color: m.total === 0 ? 'var(--dsw-alias-label-tertiary)' : m.rateValue >= 0.8 ? 'var(--dsw-alias-state-success-primary)' : 'var(--dsw-alias-state-error-primary)' } }, m.ratePercent)),
1131
+ el('span', null, m.total === 0 ? '0 请求' : el('span', null, el('span', { style: { color: 'var(--dsw-alias-state-success-primary)' } }, '✓' + m.success), ' / ', el('span', { style: { color: m.fail > 0 ? 'var(--dsw-alias-state-error-primary)' : 'var(--dsw-alias-label-tertiary)' } }, '✗' + m.fail)))
1132
+ ),
1133
+ el('div', { className: 'mcm-meter' },
1134
+ el('div', { className: 'mcm-meter-fill ' + (m.total === 0 ? 'idle' : m.rateValue >= 0.9 ? '' : m.rateValue >= 0.7 ? 'warn' : 'danger'), style: { width: m.total === 0 ? '0%' : (m.rateValue * 100) + '%', background: m.total === 0 ? 'var(--dsw-alias-border-l2)' : undefined } })
1135
+ )
1136
+ ),
1137
+ el('div', { style: { display: 'flex', justifyContent: 'space-between', fontSize: 11, color: 'var(--dsw-alias-label-tertiary)', borderTop: '1px solid var(--dsw-alias-border-l1)', paddingTop: 8 } },
1138
+ el('span', null, 'TTFT: ', el('strong', { style: { color: 'var(--dsw-alias-label-primary)' } }, m.avgTtft)),
1139
+ el('span', null, '最近: ', el('span', { style: { color: 'var(--dsw-alias-label-secondary)' } }, m.lastTimeStr))
1140
+ )
1141
+ ))
1142
+ )
1143
+ ))
1144
+ )
1145
+ }
1146
+
1147
+ function ModelConfigView() {
1148
+ const [state, setState] = useState(null)
1149
+ const [draft, setDraft] = useState(null)
1150
+ const [notice, setNotice] = useState(null)
1151
+ const [saving, setSaving] = useState(false)
1152
+ const [tab, setTab] = useState('config')
1153
+
1154
+ const renameProviderInChannels = (oldId, newId) => {
1155
+ setChannelsDraft((d) => (d || []).map((g) => {
1156
+ const cands = (g.candidates || []).map((c) => (c && c.provider === oldId) ? Object.assign({}, c, { provider: newId }) : c)
1157
+ return Object.assign({}, g, { candidates: cands })
1158
+ }))
1159
+ }
1160
+ const [channels, setChannels] = useState(null)
1161
+ const [channelsDraft, setChannelsDraft] = useState(null)
1162
+ const [health, setHealth] = useState(null)
1163
+
1164
+ const unwrap = (resp) => {
1165
+ const r = resp && resp.result ? resp.result : resp
1166
+ if (r && r.ok === false) throw new Error((r.error && (r.error.message || r.error)) || 'request failed')
1167
+ return r && r.value !== undefined ? r.value : r
1168
+ }
1169
+
1170
+ // 按持久化的 providerOrder 重排 providers 的键序:settings-file 的 patchNode
1171
+ // 对 map 键序盲(拖拽纯重排在文件层是零 diff),顺序由 model-channels ns 里的
1172
+ // providerOrder 数组携带(数组 wholesale replace 真实落盘)。未列出的 provider
1173
+ // append 在后(保持文件键序),防止 providerOrder 过期时丢供应商。
1174
+ const applyProviderOrder = (providers, order) => {
1175
+ if (!providers || !Array.isArray(order) || order.length === 0) return providers
1176
+ const known = new Set(Object.keys(providers))
1177
+ const ranked = order.filter((k) => known.has(k))
1178
+ if (ranked.length === 0) return providers
1179
+ const rest = Object.keys(providers).filter((k) => !ranked.includes(k))
1180
+ const out = {}
1181
+ for (const k of ranked.concat(rest)) out[k] = providers[k]
1182
+ return out
1183
+ }
1184
+
1185
+ const refresh = () => {
1186
+ if (!apiRef) return
1187
+ apiRef.settings.describe({}).then((resp) => {
1188
+ const d = unwrap(resp)
1189
+ const nss = (d && d.namespaces) || []
1190
+ const findNs = (name) => nss.find((n) => n && n.ns === name)
1191
+ const ns = findNs('llm-pi-ai')
1192
+ const cn = findNs('model-channels')
1193
+ const savedOrder = (cn && cn.value && Array.isArray(cn.value.providerOrder)) ? cn.value.providerOrder : null
1194
+ const providers = applyProviderOrder((ns && ns.value && ns.value.providers) || {}, savedOrder)
1195
+ // user 层 = 仅用户手工声明的路由(用于真正删除与重排序的 mutate 依据)
1196
+ const userProviders = (ns && ns.user && ns.user.providers) || {}
1197
+ setState({ providers, userProviders, providerOrder: savedOrder || [] })
1198
+ setDraft((prev) => prev || clone(providers))
1199
+ const ch = (cn && cn.value && cn.value.groups) || []
1200
+ setChannels(ch)
1201
+ setChannelsDraft((prev) => prev || clone(ch))
1202
+ const hn = findNs('model-channel-health')
1203
+ setHealth(hn ? { records: (hn.value && hn.value.records) || {}, speedResults: (hn.value && hn.value.speedResults) || {}, runtime: (hn.value && hn.value.runtime) || {} } : null)
1204
+ }).catch((e) => setNotice('加载失败: ' + String(e)))
1205
+ }
1206
+
1207
+ useEffect(() => {
1208
+ refresh()
1209
+ }, [])
1210
+ // 健康页签激活时才 5s 静默轮询(仅拉健康命名空间),切走即停,避免后台常驻全量 describe
1211
+ useEffect(() => {
1212
+ if (tab !== 'health') return
1213
+ const pull = () => {
1214
+ if (!apiRef) return
1215
+ apiRef.settings.describe({}).then((resp) => {
1216
+ const d = unwrap(resp)
1217
+ const hn = ((d && d.namespaces) || []).find((n) => n && n.ns === 'model-channel-health')
1218
+ if (hn && hn.value) {
1219
+ setHealth({
1220
+ records: hn.value.records || {},
1221
+ speedResults: hn.value.speedResults || {},
1222
+ runtime: hn.value.runtime || {}
1223
+ })
1224
+ }
1225
+ }).catch(() => {})
1226
+ }
1227
+ pull()
1228
+ const timer = setInterval(pull, 5000)
1229
+ return () => clearInterval(timer)
1230
+ }, [tab])
1231
+
1232
+ const save = () => {
1233
+ if (!apiRef) return
1234
+ setSaving(true)
1235
+ setNotice(null)
1236
+ // Compat 全量透传:rc.2 PiAiCompatProfile 的 20 个字段全部真实生效——此前
1237
+ // 白名单净化(仅 thinkingFormat/supportsReasoningEffort)把用户配置的角色
1238
+ // 模板类字段(thinkingFormat:chat-template / chatTemplateKwargs /
1239
+ // requiresThinkingAsText 等)在每次保存时剥掉,上游报 400 角色信息不正确
1240
+ // (Ark 1214)。现仅剔除:空串/null(「— 默认 —」语义)与枚举非法值;
1241
+ // 其余键全量保留(真遇到 schema 不认的键会大声失败而不是静默丢弃)。
1242
+ const TF_SET = new Set(TF)
1243
+ const MAXTOK_SET = new Set(MAX_TOKENS_FIELDS)
1244
+ const CCF_SET = new Set(CACHE_CONTROL_FORMATS)
1245
+ const cleanCompat = (raw) => {
1246
+ if (!raw || typeof raw !== 'object') return undefined
1247
+ const out = {}
1248
+ for (const [k, v] of Object.entries(raw)) {
1249
+ if (v === '' || v == null) continue
1250
+ if (k === 'thinkingFormat' && !TF_SET.has(v)) continue
1251
+ if (k === 'maxTokensField' && !MAXTOK_SET.has(v)) continue
1252
+ if (k === 'cacheControlFormat' && !CCF_SET.has(v)) continue
1253
+ if (k === 'chatTemplateKwargs') {
1254
+ if (v && typeof v === 'object' && !Array.isArray(v) && Object.keys(v).length > 0) out[k] = v
1255
+ continue
1256
+ }
1257
+ out[k] = v
1258
+ }
1259
+ return Object.keys(out).length > 0 ? out : undefined
1260
+ }
1261
+ const cleanProviders = {}
1262
+ if (draft) {
1263
+ for (const [pKey, pVal] of Object.entries(draft)) {
1264
+ if (!pKey || !pVal) continue
1265
+ const cleanModels = (pVal.models || []).filter((m) => m && m.id && String(m.id).trim().length > 0).map((m) => {
1266
+ // 同样保留模型级未知字段(面板外 schema 合法键),仅清洗/覆盖面板管理的
1267
+ const cleaned = Object.assign({}, m)
1268
+ cleaned.id = String(m.id).trim()
1269
+ if (cleaned.name != null && String(cleaned.name).trim() === '') delete cleaned.name
1270
+ if (!(Number.isFinite(cleaned.contextWindow) && cleaned.contextWindow > 0)) delete cleaned.contextWindow
1271
+ if (!(Number.isFinite(cleaned.maxTokens) && cleaned.maxTokens > 0)) delete cleaned.maxTokens
1272
+ if (Array.isArray(cleaned.input) && cleaned.input.length > 0) cleaned.input = cleaned.input.slice()
1273
+ else delete cleaned.input
1274
+ if (cleaned.reasoningEfforts != null && typeof cleaned.reasoningEfforts === 'object' && Object.keys(cleaned.reasoningEfforts).length > 0) cleaned.reasoningEfforts = Object.assign({}, cleaned.reasoningEfforts)
1275
+ else delete cleaned.reasoningEfforts
1276
+ const mc = cleanCompat(cleaned.compat)
1277
+ if (mc) cleaned.compat = mc
1278
+ else delete cleaned.compat
1279
+ return cleaned
1280
+ })
1281
+ const pObj = Object.assign({}, pVal)
1282
+ pObj.api = pVal.api || 'openai-completions'
1283
+ pObj.models = cleanModels
1284
+ if (!(pVal.baseURL && String(pVal.baseURL).trim())) delete pObj.baseURL
1285
+ else pObj.baseURL = String(pVal.baseURL).trim()
1286
+ if (!(pVal.displayName && String(pVal.displayName).trim())) delete pObj.displayName
1287
+ else pObj.displayName = String(pVal.displayName).trim()
1288
+ const apiKeyEnvVal = normalizeCredentialRef(pVal.apiKeyEnv || '')
1289
+ if (apiKeyEnvVal) pObj.apiKeyEnv = apiKeyEnvVal
1290
+ else delete pObj.apiKeyEnv
1291
+ // 请求头原样保存(默认头仅在新建供应商时注入一次),编辑器里删除即真实生效;
1292
+ // 仅剔除空键与 User-Agent(pi-ai 会用 deepseek-harness 归属 UA 强制覆盖,自定义是死数据)
1293
+ const hdrs = {}
1294
+ for (const [hk, hv] of Object.entries((pVal.headers && typeof pVal.headers === 'object') ? pVal.headers : {})) {
1295
+ const hk2 = String(hk).trim()
1296
+ if (!hk2 || hk2.toLowerCase() === 'user-agent') continue
1297
+ hdrs[hk2] = String(hv)
1298
+ }
1299
+ if (Object.keys(hdrs).length > 0) pObj.headers = hdrs
1300
+ else delete pObj.headers
1301
+ const pc = cleanCompat(pVal.compat)
1302
+ if (pc) pObj.compat = pc
1303
+ else delete pObj.compat
1304
+ if (pVal.transport) pObj.transport = pVal.transport
1305
+ else delete pObj.transport
1306
+ if (pVal.cacheRetention) pObj.cacheRetention = pVal.cacheRetention
1307
+ else delete pObj.cacheRetention
1308
+ if (Number.isFinite(pVal.timeoutMs) && pVal.timeoutMs > 0) pObj.timeoutMs = pVal.timeoutMs
1309
+ else delete pObj.timeoutMs
1310
+ if (Number.isFinite(pVal.streamIdleTimeoutMs) && pVal.streamIdleTimeoutMs > 0) pObj.streamIdleTimeoutMs = pVal.streamIdleTimeoutMs
1311
+ else delete pObj.streamIdleTimeoutMs
1312
+ if (Number.isFinite(pVal.websocketConnectTimeoutMs) && pVal.websocketConnectTimeoutMs > 0) pObj.websocketConnectTimeoutMs = pVal.websocketConnectTimeoutMs
1313
+ else delete pObj.websocketConnectTimeoutMs
1314
+ if (Number.isSafeInteger(pVal.defaultContextWindow) && pVal.defaultContextWindow > 0) pObj.defaultContextWindow = pVal.defaultContextWindow
1315
+ else delete pObj.defaultContextWindow
1316
+ if (Number.isSafeInteger(pVal.defaultMaxTokens) && pVal.defaultMaxTokens > 0) pObj.defaultMaxTokens = pVal.defaultMaxTokens
1317
+ else delete pObj.defaultMaxTokens
1318
+ if (pVal.reasoning) pObj.reasoning = pVal.reasoning
1319
+ else delete pObj.reasoning
1320
+ if (Number.isSafeInteger(pVal.requestImagePixelBudget) && pVal.requestImagePixelBudget > 0) pObj.requestImagePixelBudget = pVal.requestImagePixelBudget
1321
+ else delete pObj.requestImagePixelBudget
1322
+ if (Number.isSafeInteger(pVal.requestImageMaxBytes) && pVal.requestImageMaxBytes > 0) pObj.requestImageMaxBytes = pVal.requestImageMaxBytes
1323
+ else delete pObj.requestImageMaxBytes
1324
+ cleanProviders[pKey] = pObj
1325
+ }
1326
+ }
1327
+ // llm-pi-ai 用 mutate path-ops 提交:
1328
+ // - settings.update 是 merge 语义:已存在键保序(无法表达拖拽重排)
1329
+ // - settings.replace 会整体重建(官方不建议持有脱敏视图时使用)
1330
+ // - mutate:先 unset 全部旧键(含删除项),再按最终顺序 set → 键序即持久化顺序
1331
+ const p1 = draft ? (() => {
1332
+ const keys = Object.keys(cleanProviders)
1333
+ // 用 resolved 键集(state.providers)而非 user 层(state.userProviders)作为 unset 依据:
1334
+ // 面板展示/编辑的正是 resolved 视图,user 层可能为空或与展示不一致,漏 unset 会导致
1335
+ // 删除/改名失效(旧键残留)与排序无效。
1336
+ const storedKeys = Object.keys((state && (state.providers || state.userProviders)) || {})
1337
+ const ops = []
1338
+ const allKeys = Array.from(new Set([...storedKeys, ...keys]))
1339
+ for (const k of allKeys) ops.push({ op: 'unset', path: ['providers', k] })
1340
+ for (const k of keys) ops.push({ op: 'set', path: ['providers', k], value: cleanProviders[k] })
1341
+ if (ops.length === 0) return Promise.resolve()
1342
+ return apiRef.settings.mutate({ ns: 'llm-pi-ai', ops }).then((resp) => {
1343
+ const r = resp && resp.result ? resp.result : resp
1344
+ if (r && r.ok === false) throw new Error((r.error && (r.error.message || r.error)) || 'llm-pi-ai save failed')
1345
+ return r
1346
+ })
1347
+ })() : Promise.resolve()
1348
+ // 顺序持久化:patchNode 对 map 键序盲,拖拽顺序写进 providerOrder 数组(同一次 update 落盘);
1349
+ // 即使 channelsDraft 为空(无轮询组),只要 draft 非空也要写——这是排序的唯二持久化时机
1350
+ const orderPatch = draft ? { providerOrder: Object.keys(cleanProviders) } : {}
1351
+ const p2 = apiRef.settings.update({
1352
+ ns: 'model-channels',
1353
+ patch: Object.assign({ groups: channelsDraft || [] }, orderPatch)
1354
+ }).then((resp) => {
1355
+ const r = resp && resp.result ? resp.result : resp
1356
+ if (r && r.ok === false) throw new Error((r.error && (r.error.message || r.error)) || 'model-channels save failed')
1357
+ return r
1358
+ })
1359
+ Promise.all([p1, p2]).then(() => {
1360
+ setNotice('已全部保存(即时生效)')
1361
+ // 同步草稿与状态为刚保存的清洗版本(含默认请求头合并结果)
1362
+ setDraft(clone(cleanProviders))
1363
+ setState((s) => Object.assign({}, s, { providers: cleanProviders, userProviders: cleanProviders, providerOrder: Object.keys(cleanProviders) }))
1364
+ setTimeout(() => setNotice(null), 3000)
1365
+ refresh()
1366
+ }).catch((e) => setNotice('保存失败: ' + String((e && e.message) || e))).finally(() => setSaving(false))
1367
+ }
1368
+
1369
+ return el('div', { className: 'mcm-root' },
1370
+ el('div', { className: 'mcm-header' },
1371
+ el('div', { className: 'mcm-nav' },
1372
+ el('div', { className: 'mcm-nav-item' + (tab === 'config' ? ' active' : ''), onClick: () => setTab('config') }, '提供商与模型'),
1373
+ el('div', { className: 'mcm-nav-item' + (tab === 'roundrobin' ? ' active' : ''), onClick: () => setTab('roundrobin') }, '故障转移轮询组'),
1374
+ el('div', { className: 'mcm-nav-item' + (tab === 'health' ? ' active' : ''), onClick: () => setTab('health') }, '全局健康统计')
1375
+ ),
1376
+ el('div', { className: 'mcm-actions' },
1377
+ notice ? el('span', { style: { fontSize: 12, color: notice.includes('失败') ? 'var(--dsw-alias-state-error-primary)' : 'var(--dsw-alias-state-success-primary)' } }, notice) : null,
1378
+ btn('刷新', refresh),
1379
+ btn(saving ? '保存中…' : '保存全部变更', save, 'primary')
1380
+ )
1381
+ ),
1382
+ el('div', { className: 'mcm-body' },
1383
+ tab === 'config' ? el(ModelConfigPanel, { _state: state, _draft: draft, _setDraft: setDraft, _setNotice: setNotice, _renameProviderInChannels: renameProviderInChannels }) :
1384
+ tab === 'roundrobin' ? el(RoundrobinPanel, { channels, channelsDraft, setChannelsDraft, _providers: (draft || (state ? state.providers : {})) }) :
1385
+ el(HealthPanel, { health, _providers: (draft || (state ? state.providers : {})) })
1386
+ )
1387
+ )
1388
+ }
1389
+
1390
+
1391
+ function apply(ctx) {
1392
+ const connection = ctx.get('connection')
1393
+ apiRef = connection && connection.api ? connection.api : null
1394
+ ctx.effect(() => {
1395
+ const tag = document.createElement('style')
1396
+ tag.dataset.mcmStyle = ''
1397
+ tag.textContent = CSS
1398
+ document.head.appendChild(tag)
1399
+ return () => tag.remove()
1400
+ }, 'model-config: styles')
1401
+ const slots = ctx.get('slots')
1402
+ if (!slots) return
1403
+ // 会话模型选择器(conversation.input.model 座位遮蔽)已拆出为独立插件
1404
+ // @arcaneorion/dsh-model-selector-search——一个占座者一个插件单元,可独立启停/替换;
1405
+ // 本插件禁用/卸载不再影响选择器,反之亦然。座位契约(priority -1 + inject face)见该仓。
1406
+ slots.inject('conversation.view', () => slots.register(
1407
+ { name: 'conversation.view', id: 'models', order: 25, label: '模型配置' },
1408
+ (p) => el(ModelConfigView, p)
1409
+ ))
1410
+ }
1411
+
1412
+ return { name: 'model-channel-manager', inject: ['slots', 'connection'], apply }
1413
+ },
1414
+ })