opencode-claude-code-ua 0.0.0-stage → 0.3.9

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/ui/pro.js ADDED
@@ -0,0 +1,900 @@
1
+ import { createTestPlan, createTestSample, validateTestSample } from '../lib/ua-test-plan.mjs'
2
+ import { protocolForModel, matchesAccountSite } from '../lib/ua-protocol.mjs'
3
+
4
+ export const PROTOCOLS = ['anthropic', 'responses', 'chat-completions', 'gemini']
5
+ export const OPERATIONS = { anthropic: ['messages', 'count_tokens'], responses: ['responses'], 'chat-completions': ['chat/completions'], gemini: ['generateContent', 'streamGenerateContent', 'countTokens'] }
6
+ const clone = value => JSON.parse(JSON.stringify(value))
7
+ const keyOf = template => `${template.id}@${template.revision}`
8
+ export function templateGroups(templates = []) {
9
+ const groups = new Map()
10
+ for (const item of templates) {
11
+ if (!groups.has(item.id)) groups.set(item.id, [])
12
+ groups.get(item.id).push(item)
13
+ }
14
+ return [...groups.values()].map(items => items.sort((a, b) => b.revision - a.revision))
15
+ }
16
+ export function forkTemplate(base, name) {
17
+ const draft = clone(base)
18
+ draft.basedOn = { id: base.id, revision: base.revision }
19
+ delete draft.id
20
+ delete draft.archived
21
+ draft.kind = 'custom'
22
+ draft.revision = 1
23
+ draft.name = name.trim()
24
+ return draft
25
+ }
26
+ export function jsonObject(text, label) {
27
+ let value
28
+ try { value = JSON.parse(text || '{}') } catch { throw new Error(`${label} 必须是有效 JSON`) }
29
+ if (!value || typeof value !== 'object' || Array.isArray(value)) throw new Error(`${label} 必须是 JSON 对象`)
30
+ return value
31
+ }
32
+ export function lines(text) { return [...new Set(String(text).split(/\r?\n/).map(value => value.trim()).filter(Boolean))] }
33
+ export const ACCOUNT_BINDING_EMPTY = '不绑定签到账号(仅查询 Key 用量)'
34
+ /** Same-site comparison for the optional balance/check-in account binding. */
35
+ export function siteRoot(value) {
36
+ const text = String(value ?? '').trim().replace(/\/+$/, '')
37
+ if (!text) return ''
38
+ return text.replace(/\/v1(?:beta)?$/i, '').replace(/\/+$/, '')
39
+ }
40
+ export function connectionAccountChoices(accounts = [], baseURL = '', selectedId = '') {
41
+ const list = (Array.isArray(accounts) ? accounts : []).filter(item => item && item.id)
42
+ const root = siteRoot(baseURL)
43
+ const matches = item => { try { return matchesAccountSite(baseURL, item) } catch { return false } }
44
+ const same = root ? list.filter(matches) : []
45
+ const pool = same.length ? same : list
46
+ const selected = selectedId === null || selectedId === undefined ? '' : String(selectedId)
47
+ const selectedAccount = list.find(item => String(item.id) === selected)
48
+ const choices = pool.map(item => ({
49
+ value: String(item.id),
50
+ label: `${item.name ?? item.id} · ${item.siteUrl ?? ''}${item.enabled === false ? '(已停用)' : ''}`,
51
+ }))
52
+ const invalid = Boolean(selected && (!selectedAccount || (root !== '' && !matches(selectedAccount))))
53
+ if (invalid) {
54
+ const label = selectedAccount
55
+ ? `当前绑定:${selectedAccount.name} · ${selectedAccount.siteUrl}(属于其他站点或已失效,请重新选择)`
56
+ : '当前绑定(已失效或属于其他站点,请重新选择)'
57
+ const existing = choices.findIndex(item => item.value === selected)
58
+ if (existing >= 0) choices.splice(existing, 1)
59
+ choices.unshift({ value: selected, label, invalid: true })
60
+ }
61
+ return { mode: same.length ? 'same-site' : 'all', choices, selected, invalid }
62
+ }
63
+ export function connectionAccountBindingLabel(accountId, accounts = []) {
64
+ if (!accountId) return '余额与签到:仅查询 Key 用量(未绑定站点账号)'
65
+ const account = (Array.isArray(accounts) ? accounts : []).find(item => String(item.id) === String(accountId))
66
+ return account ? `余额与签到:${account.name} · ${account.siteUrl}` : '余额与签到:绑定账号已失效,请编辑连接重新选择'
67
+ }
68
+ export function fillSelectedModels(current, selected, replace = false) {
69
+ const ids = selected.filter(id => typeof id === 'string' && id.trim()).map(id => id.trim())
70
+ return [...new Set([...(replace ? [] : lines(current)), ...ids])].join('\n')
71
+ }
72
+ const CATALOG_SOURCES = { 'host-cache': '宿主已发现的模型', 'management-cache': '上次获取的模型' }
73
+ const pad2 = value => String(value).padStart(2, '0')
74
+ function catalogTime(value) {
75
+ const raw = typeof value === 'string' && /^\d+$/.test(value.trim()) ? Number(value) : value
76
+ const date = typeof raw === 'number' ? new Date(raw < 1e11 ? raw * 1000 : raw) : new Date(raw ?? Number.NaN)
77
+ if (raw === null || raw === undefined || raw === '' || Number.isNaN(date.getTime())) return '时间未知'
78
+ return `${date.getFullYear()}-${pad2(date.getMonth() + 1)}-${pad2(date.getDate())} ${pad2(date.getHours())}:${pad2(date.getMinutes())}`
79
+ }
80
+ function catalogReason(error) {
81
+ const value = String(error?.message || error?.code || '').replace(/\b(Bearer|Basic)\s+\S+/gi, '$1 ***').replace(/\bsk-[\w-]{6,}/g, 'sk-***').replace(/\s+/g, ' ').trim()
82
+ if (!value) return '上游暂不可用'
83
+ return value.length > 200 ? `${value.slice(0, 200)}…` : value
84
+ }
85
+ export function catalogModels(result) {
86
+ return Array.isArray(result?.models) ? [...new Set(result.models.map(model => typeof model?.id === 'string' ? model.id.trim() : '').filter(Boolean))] : []
87
+ }
88
+ export function catalogFor(state, id) {
89
+ const catalogs = state?.modelCatalogs
90
+ if (!id || !catalogs || typeof catalogs !== 'object' || !Object.hasOwn(catalogs, id)) return null
91
+ const entry = catalogs[id]
92
+ return entry && typeof entry === 'object' && Array.isArray(entry.models) ? { ...entry, cached: true, refreshError: undefined } : null
93
+ }
94
+ export function modelCatalogStatus(result) {
95
+ const count = catalogModels(result).length
96
+ if (!result?.cached && !result?.refreshError) return (count ? `已获取 ${count} 个模型。选择后追加或替换,下方手填内容尚未改变。` : '上游返回空模型列表,手填内容已保留。') + (result?.catalogWarning ? ` ${result.catalogWarning}` : '')
97
+ const kept = `${CATALOG_SOURCES[result.source] ?? '缓存的模型'} · ${catalogTime(result.checkedAt)},${count ? `共 ${count} 个` : '缓存为空'};这是缓存,不代表本次刷新成功`
98
+ if (result.refreshError) return `刷新失败:${catalogReason(result.refreshError)}。保留的缓存:${kept},手填内容保留。`
99
+ return `${kept}。点击“获取模型”会重新查询;${count ? '选择后追加或替换,手填内容尚未改变。' : '手填内容保留。'}`
100
+ }
101
+ export function modelProtocolLines(text) {
102
+ const result = {}
103
+ for (const line of lines(text)) {
104
+ const match = /^([^=]+?)\s*=\s*(.+)$/.exec(line)
105
+ if (!match || !PROTOCOLS.includes(match[2].trim())) throw new Error('模型协议格式应为:模型 = 协议')
106
+ const name = match[1].trim()
107
+ if (['__proto__', 'constructor', 'prototype'].includes(name)) throw new Error('模型名称无效')
108
+ result[name] = match[2].trim()
109
+ }
110
+ return result
111
+ }
112
+ export function differences(before, after, path = '') {
113
+ if (JSON.stringify(before) === JSON.stringify(after)) return []
114
+ if (before && after && typeof before === 'object' && typeof after === 'object' && !Array.isArray(before) && !Array.isArray(after)) {
115
+ return [...new Set([...Object.keys(before), ...Object.keys(after)])].flatMap(key => differences(before[key], after[key], path ? `${path}.${key}` : key))
116
+ }
117
+ return [{ path: path || 'template', action: before === undefined ? '新增' : after === undefined ? '删除' : '修改' }]
118
+ }
119
+ export function safeTemplate(template) {
120
+ const safe = clone(template)
121
+ safe.headers = safe.headers.map(header => /token|secret|key|cookie|auth/i.test(header.name) ? { ...header, value: '[已遮罩]' } : header)
122
+ return safe
123
+ }
124
+ export function testModelOptions(connection, catalog) {
125
+ const entries = new Map()
126
+ const safe = value => typeof value === 'string' && value.trim() && !/[\x00-\x1f\x7f-\x9f]/.test(value)
127
+ for (const id of connection?.models ?? []) if (safe(id)) entries.set(id.trim(), { id: id.trim(), name: id.trim() })
128
+ for (const model of catalog?.models ?? []) {
129
+ if (!safe(model?.id)) continue
130
+ const id = model.id.trim()
131
+ entries.set(id, { id, name: safe(model.name) ? model.name.trim() : id })
132
+ }
133
+ return [...entries.values()]
134
+ }
135
+ export function selectTestModel(models, current = '') {
136
+ return models.some(model => model.id === current) ? current : models[0]?.id ?? ''
137
+ }
138
+ export function connectionHostView(state = {}, connection = {}) {
139
+ const provider = connection.independent && connection.siteId ? `ua-pro-${connection.siteId}` : 'ua-pro'
140
+ const host = state.runtime?.connections?.find(item => item.connectionId === connection.id)
141
+ if (connection.enabled === false) return { label: '连接已停用', detail: `${provider} · 此连接不参与模型请求` }
142
+ if (state.runtime?.mode !== 'host') return { label: '仅本地保存,未同步到宿主', detail: `在 Magpie 打开 UA PRO 管理并同步后,连接位于 ${provider} 的账号列表` }
143
+ if (host?.status === 'registered') return { label: '已注册到宿主', detail: `Magpie 供应商 ${host.provider || provider} · 账号 ${connection.name || host.account};请刷新宿主模型列表` }
144
+ if (host?.status === 'reload-required') return { label: '需重载插件', detail: `新增供应商 ${host.provider || provider} 尚未载入,请在 Magpie 重载 UA PRO` }
145
+ return { label: host?.status === 'conflict' ? '账号冲突,未覆盖' : '尚未同步到宿主', detail: host?.message || `点击“同步到宿主”注册到 ${provider} 的账号列表` }
146
+ }
147
+ export function connectionSaveMessage(result = {}) {
148
+ if (result.message) return result.message
149
+ if (result.registration === 'registered') return `连接已保存并注册到宿主 ${result.host?.provider || 'UA PRO'};在供应商的账号列表中查看并刷新模型`
150
+ if (result.registration === 'pending-reload') return '连接已保存,宿主尚未确认;请同步到宿主或重载 UA PRO 插件'
151
+ return '连接已保存到本地;测试成功不代表已同步,请在 Magpie 打开 UA PRO 管理并同步到宿主'
152
+ }
153
+
154
+ export function gatewayModelReference(connection, model) {
155
+ const provider = connection?.independent && connection.siteId ? `ua-pro-${connection.siteId}` : 'ua-pro'
156
+ return model ? `${provider}/${model}` : ''
157
+ }
158
+ export function testModelProtocol(connection, model, fallback = 'anthropic') {
159
+ if (!connection) return fallback
160
+ try { return protocolForModel(connection, model) } catch { return fallback }
161
+ }
162
+ export function sampleBody(protocol, model, stream) {
163
+ return createTestSample(protocol, model, stream)
164
+ }
165
+ export function operationErrorText(result) {
166
+ return [...new Set([result.error, ...Object.values(result.fields ?? {})].filter(value => typeof value === 'string' && value.trim()))].join('\n') || '操作未完成'
167
+ }
168
+ export function testRequestIdentity(payload) {
169
+ const stable = value => Array.isArray(value) ? value.map(stable) : value && typeof value === 'object' ? Object.fromEntries(Object.keys(value).sort().map(key => [key, stable(value[key])])) : value
170
+ return JSON.stringify(stable(payload))
171
+ }
172
+ export async function requestOperation(fetcher, action, payload, operationId) {
173
+ try {
174
+ const response = await fetcher(`./api/pro/${action}`, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ ...payload, operationId }) })
175
+ const data = await response.json()
176
+ if (response.ok) return { ok: true, data }
177
+ if (data.code === 'save-pending') return { ok: false, unknown: true, error: data.error }
178
+ return { ok: false, unknown: false, error: data.error || '操作未完成', fields: data.fields }
179
+ } catch { return { ok: false, unknown: true, error: '未收到操作确认,结果未知。请查询原操作,不要重复提交。' } }
180
+ }
181
+
182
+ if (typeof document !== 'undefined') void mount()
183
+ async function mount() {
184
+ const $ = id => document.getElementById(id)
185
+ let state = { templates: [], connections: [], siteAccounts: [], generation: 0, runtime: {} }
186
+ let view = 'overview', loading = false, pending = null, operation = null
187
+ let connection = null, connectionBaseline = '', template = null, templateBaseline = '', templateOrigin = null
188
+ let dirtyConnection = false, dirtyTemplate = false
189
+ let modelQuery = null, modelQueryReady = false, modelResultShown = false
190
+ let testModelQuery = null, testModels = [], testCatalog = null, testConnectionID = '', testConnectionRevision, testModelManual = false, testSampleBaseline = '', testProtocolExplicit = false
191
+ let testSelectionInitialized = false
192
+ const queriedTestConnections = new Set()
193
+ let testPlan = null, testPlanError = '', lastTestIdentity = '', testResultRunning = false, dirtyTestSample = false, testHeadersBaseline = '{}'
194
+ const text = (id, value) => { if ($(id).textContent !== String(value)) $(id).textContent = String(value) }
195
+ const node = (tag, content, className) => { const item = document.createElement(tag); if (content !== undefined) item.textContent = content; if (className) item.className = className; return item }
196
+ const button = (label, action, className = 'ghost') => { const item = node('button', label, className); item.type = 'button'; item.addEventListener('click', action); return item }
197
+ const tag = value => node('span', value, 'tag')
198
+ const status = value => text('pro-status', value)
199
+ const showError = error => { $('pro-error').hidden = !error; $('pro-error').textContent = error ? String(error.message ?? error) : '' }
200
+ function isDirty() { return dirtyTemplate || dirtyConnection || dirtyTestSample }
201
+ function locked() { return loading || Boolean(pending) || Boolean(modelQuery) }
202
+ function allowLeave() {
203
+ if (locked()) { showError('操作进行中或结果未知,请先确认结果。'); return false }
204
+ return !isDirty() || window.confirm('有未保存的草稿。确定放弃并继续吗?')
205
+ }
206
+ function options(select, values, selected = select.value) {
207
+ select.replaceChildren()
208
+ for (const [value, label] of values) { const item = node('option', label); item.value = value; select.append(item) }
209
+ if (values.some(([value]) => value === selected)) select.value = selected
210
+ }
211
+ function setBusy(value) {
212
+ loading = value
213
+ for (const element of document.querySelectorAll('button,input,select,textarea')) {
214
+ if (element.id === 'pro-recover' || element.id === 'test-cancel') continue
215
+ if (value) { element.dataset.preDisabled = element.disabled ? '1' : '0'; element.disabled = true }
216
+ else if (element.dataset.preDisabled !== undefined) { element.disabled = element.dataset.preDisabled === '1'; delete element.dataset.preDisabled }
217
+ }
218
+ $('test-cancel').hidden = !(value && operation?.action === 'test')
219
+ if(!value){$('test-cancel').disabled=false;text('test-cancel','取消测试')}
220
+ $('pro-pending').hidden = !pending
221
+ }
222
+ async function refresh(keepDraft = true) {
223
+ const response = await fetch('./api/pro/state', { cache: 'no-store' })
224
+ if (!response.ok) throw new Error('配置会话不可用,请重新打开 UA PRO')
225
+ state = await response.json()
226
+ renderState()
227
+ if (!keepDraft) { dirtyConnection = false; dirtyTemplate = false; dirtyTestSample = false }
228
+ }
229
+ async function run(action, payload, complete) {
230
+ if (locked()) return
231
+ showError(null)
232
+ operation = { action, payload, id: crypto.randomUUID(), complete }
233
+ setBusy(true)
234
+ status(action === 'test' ? '正在执行测试…' : '正在保存操作…')
235
+ const result = await requestOperation(fetch.bind(globalThis), action, payload, operation.id)
236
+ setBusy(false)
237
+ if (result.unknown) {
238
+ pending = operation
239
+ setBusy(true)
240
+ loading = false
241
+ $('pro-pending').hidden = false
242
+ text('pro-pending-text', `${result.error} 操作标识:${operation.id}`)
243
+ status('结果未知,等待查询')
244
+ return
245
+ }
246
+ operation = null
247
+ if (!result.ok) { showError(operationErrorText(result)); status('操作未完成,草稿保留'); return }
248
+ await refresh()
249
+ if (complete) await complete(result.data)
250
+ status(action === 'test' ? result.data.result?.status === 'passed' ? '测试完成:通过' : '测试完成:未通过,请查看本次原因' : action === 'connections/save' ? connectionSaveMessage(result.data) : action === 'connections/sync' ? result.data.message || '宿主同步完成,请刷新 Magpie 供应商和模型列表' : action === 'routes/apply' ? '模型路由已保存,请在 Magpie 重新获取模型或重载插件' : result.data.activation ? '已保存绑定;等待宿主后续请求确认使用版本' : '操作完成')
251
+ }
252
+ async function recover() {
253
+ if (!pending || loading) return
254
+ try {
255
+ const response = await fetch(`./api/pro/operations?id=${encodeURIComponent(pending.id)}`, { cache: 'no-store' })
256
+ const result = await response.json()
257
+ if (result.state === 'saved') {
258
+ const done = pending.complete; pending = null; setBusy(false); $('pro-pending').hidden = true
259
+ await refresh(); if (done) await done(result.result); status('已确认原操作完成')
260
+ } else if (result.state === 'failed') { pending = null; setBusy(false); $('pro-pending').hidden = true; showError(result.error?.error || '原操作已确认失败,可修正后重试') }
261
+ else text('pro-pending-text', '尚不能确认操作结果;请保留草稿并稍后查询,不重复提交。')
262
+ } catch { showError('暂时无法查询结果,请稍后重试') }
263
+ }
264
+ function navigate(next, force = false) {
265
+ if (!['overview', 'connections', 'templates', 'tests', 'settings'].includes(next)) next = 'overview'
266
+ if (!force && next !== view && !allowLeave()) return
267
+ if (next !== view && !force) { dirtyConnection = false; dirtyTemplate = false; dirtyTestSample = false }
268
+ if (next === 'templates' && !force) { $('template-library').hidden = false; $('template-editor').hidden = true }
269
+ if (next !== 'tests') cancelTestModelQuery()
270
+ view = next
271
+ for (const section of document.querySelectorAll('[data-view]')) section.hidden = section.dataset.view !== next
272
+ for (const link of document.querySelectorAll('[data-view-link]')) { if (link.dataset.viewLink === next) link.setAttribute('aria-current', 'page'); else link.removeAttribute('aria-current') }
273
+ text('pro-title', { overview: '概览', connections: '连接与模型', templates: '模板库', tests: '测试', settings: '设置' }[next])
274
+ history.replaceState(null, '', `#${next}`)
275
+ if (next === 'tests') syncTestConnection({ autoFetch: true })
276
+ }
277
+ function bindingLabel(binding) {
278
+ if (binding?.mode === 'template') return `${binding.id} @ ${binding.revision}`
279
+ return binding?.mode === 'auto' ? '自动:固定 Codex / Claude 版本' : '普通转发'
280
+ }
281
+ function renderState() {
282
+ text('pro-generation', state.generation)
283
+ text('pro-version', state.application?.version || '版本未知,请重新打开插件管理')
284
+ text('pro-runtime', state.runtime?.mode === 'host' ? 'Magpie 插件宿主' : '独立配置工具')
285
+ $('overview-metrics').replaceChildren(...[
286
+ [state.connections.filter(item => item.enabled).length, '启用连接'],
287
+ [templateGroups(state.templates).filter(items => items[0].kind === 'custom').length, '自定义模板'],
288
+ [state.runtime?.observed?.length ?? 0, '宿主已观察连接'],
289
+ ].map(([count, label]) => { const item = node('div', undefined, 'metric'); item.append(node('span', label), node('strong', count)); return item }))
290
+ $('overview-empty').hidden = state.connections.length > 0
291
+ $('host-sync').hidden = state.runtime?.mode !== 'host'
292
+ $('overview-connections').replaceChildren(...state.connections.map(item => {
293
+ const row = node('div', undefined, 'overview-item'), info = node('div'), host = connectionHostView(state, item)
294
+ info.append(node('strong', item.name), node('small', item.baseURL), node('small', bindingLabel(item.binding)), node('small', host.detail))
295
+ const seen = state.runtime?.observed?.find(entry => entry.connectionId === item.id)
296
+ row.append(info, tag(seen ? `已用于请求 · generation ${seen.generation}` : host.label))
297
+ return row
298
+ }))
299
+ $('connection-list').replaceChildren(...state.connections.map(item => { const card = node('article', undefined, 'conn-card'); card.append(node('h3', item.name), tag(item.enabled ? '启用' : '停用'), node('p', `${item.baseURL}\n${item.protocol} · ${item.auth.source === 'host' ? '宿主凭据' : '本地连接凭据'}`), node('p', bindingLabel(item.binding)), node('p', connectionAccountBindingLabel(item.newApiAccountId, state.siteAccounts), 'hint'), (() => { const link = node('a', '管理签到账号 / 补 Cookie 或登录凭据'); link.href = './accounts'; link.addEventListener('click', event => { if (!allowLeave()) event.preventDefault() }); return link })()); const actions = node('div', undefined, 'card-actions'); actions.append(button('编辑', () => editConnection(item)), button('应用模板', () => openBinding([item.id])), button('测试', () => openTest(item.id))); card.append(actions); return card }))
300
+ renderTemplates()
301
+ renderConnectionSelectors()
302
+ $('session-info').replaceChildren(...Object.entries(state.session ?? {}).map(([key, value]) => { const item = node('div'); item.append(node('dt', key), node('dd', value)); return item }))
303
+ }
304
+ function renderTemplates() {
305
+ const archived = $('template-show-archived').checked
306
+ $('template-list').replaceChildren(...templateGroups(state.templates).filter(items => archived || !items[0].archived).map(items => {
307
+ const latest = items[0], card = node('article', undefined, 'tpl-card')
308
+ card.append(node('h3', latest.name), tag(latest.kind === 'builtin' ? '内置基线 · 只读' : latest.archived ? '已归档' : '我的模板'), tag(`v${latest.revision}`), node('p', latest.protocols.join(' / ')))
309
+ const uses = state.connections.filter(item => item.binding.id === latest.id || Object.values(item.modelBindings).some(binding => binding.id === latest.id)).length
310
+ card.append(node('p', `${items.length} 个版本 · ${uses} 个连接引用`))
311
+ const actions = node('div', undefined, 'card-actions')
312
+ actions.append(button(latest.kind === 'builtin' ? '查看规则' : '编辑新版本', () => editTemplate(latest)), button('复制', () => beginFork(latest)), button('历史', () => showHistory(latest.id)))
313
+ if (latest.kind !== 'builtin') actions.append(button(latest.archived ? '恢复' : '归档', () => run('templates/archive', { id: latest.id, archived: !latest.archived })))
314
+ card.append(actions); return card
315
+ }))
316
+ }
317
+ function renderConnectionSelectors() {
318
+ const enabled = state.connections.filter(item => item.enabled)
319
+ const selected = testSelectionInitialized ? $('test-connection').value : enabled[0]?.id ?? ''
320
+ if (enabled.length) testSelectionInitialized = true
321
+ options($('test-connection'), [['', '不选择连接(仅本地预览)'], ...enabled.map(item => [item.id, item.name])], enabled.some(item => item.id === selected) ? selected : '')
322
+ const groups = templateGroups(state.templates).filter(items => !items[0].archived)
323
+ const entries = groups.map(items => [items[0].id, items[0].name])
324
+ options($('binding-template'), entries)
325
+ options($('test-template'), entries)
326
+ options($('conn-binding'), [['auto', '自动:按协议选择'], ['passthrough', '普通转发'], ...groups.map(items => [keyOf(items[0]), `${items[0].name} @ ${items[0].revision}`])])
327
+ renderRevisions('binding'); renderRevisions('test')
328
+ syncTestConnection({ autoFetch: view === 'tests' })
329
+ }
330
+ function renderRevisions(prefix, revision) {
331
+ const id = $(`${prefix}-template`).value
332
+ options($(`${prefix}-revision`), state.templates.filter(item => item.id === id).sort((a, b) => b.revision - a.revision).map(item => [String(item.revision), `版本 ${item.revision}`]), revision === undefined ? undefined : String(revision))
333
+ }
334
+ function connectionDraft() {
335
+ const auth = { ...(connection?.auth ?? { source: 'stored' }), mode: $('conn-auth-mode').value, key: $('conn-key').value, value: $('conn-value').value }
336
+ const result = { ...(connection ? clone(connection) : {}), name: $('conn-name').value.trim(), baseURL: $('conn-base').value.trim(), protocol: $('conn-protocol').value,
337
+ auth, models: lines($('conn-models').value), modelProtocols: modelProtocolLines($('conn-model-protocols').value), apiPaths: {}, enabled: $('conn-enabled').checked, independent: $('conn-independent').checked, siteId: $('conn-site').value.trim(), newApiAccountId: $('conn-site-account').value }
338
+ delete result.secrets
339
+ for (const control of document.querySelectorAll('[data-api-path]')) if (control.value.trim()) result.apiPaths[control.dataset.apiPath] = control.value.trim()
340
+ if (!connection) {
341
+ const binding = $('conn-binding').value
342
+ result.binding = binding === 'auto' || binding === 'passthrough' ? { mode: binding } : { mode: 'template', id: binding.split('@')[0], revision: Number(binding.split('@')[1]) }
343
+ }
344
+ return result
345
+ }
346
+ function updateAuthFields() {
347
+ const mode = $('conn-auth-mode').value, host = connection?.auth.source === 'host'
348
+ $('conn-key-field').hidden = host || !['api-key', 'bearer'].includes(mode)
349
+ $('conn-value-field').hidden = host || mode !== 'custom'
350
+ $('conn-host-note').hidden = !host
351
+ $('conn-auth-mode').disabled = Boolean(host)
352
+ text('conn-secret-state', host ? '凭据由 Magpie 保存,不会复制到模板。' : connection?.secrets?.key || connection?.secrets?.value ? '已有凭据;同一目标留空保留。换站需输入新凭据。' : '输入此连接自己的模型凭据。')
353
+ $('conn-clear-field').hidden = !connection || host
354
+ }
355
+ function resetModelQuery(message = '使用上方地址、认证方式和密钥读取模型,无需先保存;不会发送生成请求。') {
356
+ const previous = modelQuery
357
+ modelQuery = null
358
+ previous?.controller.abort()
359
+ modelQueryReady = false
360
+ modelResultShown = false
361
+ $('conn-model-result').hidden = true
362
+ $('conn-model-options').replaceChildren()
363
+ text('conn-model-options-label', '获取到的模型(可多选)')
364
+ $('conn-model-cancel').hidden = true
365
+ $('conn-model-fetch').disabled = loading || Boolean(pending)
366
+ $('connection-save').disabled = loading || Boolean(pending)
367
+ text('conn-model-fetch', '获取模型')
368
+ text('conn-model-status', message)
369
+ }
370
+ function modelQueryDraft() {
371
+ const apiPaths = {}
372
+ for (const input of document.querySelectorAll('[data-api-path]')) if (input.value.trim()) apiPaths[input.dataset.apiPath] = input.value.trim()
373
+ return {
374
+ ...(connection ? { id: connection.id } : {}),
375
+ name: $('conn-name').value.trim(), baseURL: $('conn-base').value.trim(), protocol: $('conn-protocol').value, apiPaths,
376
+ auth: { ...(connection?.auth ?? { source: 'stored' }), mode: $('conn-auth-mode').value, key: $('conn-key').value, value: $('conn-value').value },
377
+ }
378
+ }
379
+ async function fetchConnectionModels() {
380
+ if (locked()) return
381
+ const draft = modelQueryDraft()
382
+ if (!draft.baseURL) { text('conn-model-status', '请先填写上方站点地址。'); $('conn-base').focus(); return }
383
+ if (draft.auth.source !== 'host' && draft.auth.mode !== 'none') {
384
+ const field = draft.auth.mode === 'custom' ? 'value' : 'key'
385
+ const saved = connection?.secrets?.[field] && connection.baseURL.replace(/\/+$/, '') === draft.baseURL.replace(/\/+$/, '') && !$('conn-clear-secret').checked
386
+ if (!draft.auth[field] && !saved) { text('conn-model-status', '请先填写上方当前站点的密钥或完整 Authorization。'); return }
387
+ }
388
+ resetModelQuery('正在读取模型列表…')
389
+ const query = { controller: new AbortController() }
390
+ modelQuery = query
391
+ $('conn-model-fetch').disabled = true
392
+ $('connection-save').disabled = true
393
+ $('conn-model-cancel').hidden = false
394
+ text('conn-model-fetch', '获取中…')
395
+ try {
396
+ const response = await fetch('./api/pro/connections/models', {
397
+ method: 'POST', headers: { 'content-type': 'application/json' }, signal: query.controller.signal,
398
+ body: JSON.stringify({ connection: draft, expectedRevision: connection?.revision, clearSecrets: $('conn-clear-secret').checked ? ['key','value'] : [], confirmNetwork: true, operationId: crypto.randomUUID() }),
399
+ })
400
+ const result = await response.json()
401
+ if (modelQuery !== query) return
402
+ if (!response.ok) throw new Error(result.error || '模型列表读取失败')
403
+ if (!Array.isArray(result.models)) throw new Error('模型列表响应格式无效')
404
+ showModelResult(result)
405
+ } catch (error) {
406
+ if (modelQuery === query) text('conn-model-status', query.controller.signal.aborted ? '已取消获取,手填内容保留。' : `${error.message};手填内容保留。`)
407
+ } finally {
408
+ if (modelQuery === query) {
409
+ modelQuery = null
410
+ $('conn-model-fetch').disabled = false
411
+ $('connection-save').disabled = false
412
+ $('conn-model-cancel').hidden = true
413
+ text('conn-model-fetch', '获取模型')
414
+ }
415
+ }
416
+ }
417
+ function showModelResult(result) {
418
+ const ids = catalogModels(result)
419
+ $('conn-model-options').replaceChildren(...ids.map(id => { const option = node('option', id); option.value = id; option.selected = true; return option }))
420
+ modelQueryReady = ids.length > 0
421
+ modelResultShown = true
422
+ $('conn-model-result').hidden = !modelQueryReady
423
+ text('conn-model-options-label', result.cached || result.refreshError ? '缓存的模型(可多选,未经本次刷新确认)' : '获取到的模型(可多选)')
424
+ text('conn-model-status', modelCatalogStatus(result))
425
+ }
426
+ function applyFetchedModels(replace) {
427
+ if (!modelQueryReady || locked()) return
428
+ const selected = [...$('conn-model-options').selectedOptions].map(item => item.value)
429
+ if (!selected.length) { text('conn-model-status', '请先选择需要填入的模型。'); return }
430
+ if (replace && $('conn-models').value.trim() && !window.confirm('用选中模型替换当前手填列表?尚未保存的旧列表会被替换。')) return
431
+ $('conn-models').value = fillSelectedModels($('conn-models').value, selected, replace)
432
+ $('conn-models').dispatchEvent(new Event('input', { bubbles: true }))
433
+ text('conn-model-status', `已${replace ? '替换为' : '追加'}选中的 ${selected.length} 个模型;点击“保存连接”才会保存。`)
434
+ }
435
+ function renderConnectionAccountOptions(selectedId = $('conn-site-account').value) {
436
+ const result = connectionAccountChoices(state.siteAccounts ?? [], $('conn-base').value, selectedId)
437
+ options($('conn-site-account'), [['', ACCOUNT_BINDING_EMPTY], ...result.choices.map(item => [item.value, item.label])], result.selected)
438
+ text('conn-site-account-hint', result.invalid
439
+ ? '当前绑定已失效或属于其他站点,已保留选项等待重新选择;保存时后端会拒绝无效绑定。'
440
+ : result.mode === 'same-site'
441
+ ? '仅列出与该连接站点根地址相同的账号;只影响余额与签到,不改变模型请求和凭据。'
442
+ : '未找到同站点账号,暂显示全部;保存时后端仍会校验站点根地址一致。')
443
+ return result
444
+ }
445
+ function editConnection(item = null) {
446
+ if (!allowLeave()) return
447
+ resetModelQuery()
448
+ navigate('connections', true)
449
+ connection = item ? clone(item) : null
450
+ $('connection-panel').hidden = false; $('connection-form').hidden = false; $('binding-form').hidden = true
451
+ text('connection-form-title', item ? '编辑连接' : '添加连接')
452
+ for (const [id, value] of Object.entries({ 'conn-name': item?.name ?? '', 'conn-base': item?.baseURL ?? '', 'conn-protocol': item?.protocol ?? 'auto', 'conn-auth-mode': item?.auth.mode ?? 'api-key', 'conn-key': '', 'conn-value': '', 'conn-models': (item?.models ?? []).join('\n'), 'conn-model-protocols': Object.entries(item?.modelProtocols ?? {}).map(([key, value]) => `${key} = ${value}`).join('\n'), 'conn-site': item?.siteId ?? '' })) $(id).value = value
453
+ $('conn-enabled').checked = item?.enabled !== false; $('conn-independent').checked = Boolean(item?.independent); $('conn-clear-secret').checked = false
454
+ renderConnectionAccountOptions(item?.newApiAccountId ?? '')
455
+ for (const control of document.querySelectorAll('[data-api-path]')) control.value = item?.apiPaths?.[control.dataset.apiPath] ?? ''
456
+ $('connection-remove').hidden = !item; $('conn-binding-field').hidden = Boolean(item); $('conn-binding-note').hidden = !item
457
+ $('conn-binding').value = 'auto'; text('connection-form-error', ''); updateAuthFields()
458
+ connectionBaseline = JSON.stringify(connectionDraft()); dirtyConnection = false
459
+ const cachedCatalog = catalogFor(state, item?.id)
460
+ if (cachedCatalog) showModelResult(cachedCatalog)
461
+ }
462
+ function openBinding(ids = [], ref = null) {
463
+ if (!allowLeave()) return
464
+ navigate('connections', true)
465
+ $('connection-panel').hidden = false; $('connection-form').hidden = true; $('binding-form').hidden = false
466
+ text('binding-generation', `generation ${state.generation}`)
467
+ $('binding-connections').replaceChildren(...state.connections.map(item => { const label = node('label'); const input = document.createElement('input'); input.type = 'checkbox'; input.value = item.id; input.checked = ids.includes(item.id); label.append(input, node('span', item.name)); return label }))
468
+ $('binding-mode').value = ref ? 'template' : 'auto'; $('binding-scope').value = 'connection'; $('binding-model').value = ''
469
+ if (ref) { $('binding-template').value = ref.id; renderRevisions('binding', ref.revision) }
470
+ bindingFields()
471
+ }
472
+ function bindingFields() {
473
+ $('binding-template-fields').hidden = $('binding-mode').value !== 'template'
474
+ $('binding-model-field').hidden = $('binding-scope').value !== 'model'
475
+ text('binding-summary', '应用只改变所选连接的后续请求。已开始的请求继续使用原版本;回退请选择历史版本。')
476
+ }
477
+ function beginFork(base) {
478
+ if (!allowLeave()) return
479
+ options($('template-base-select'), state.templates.filter(item => !item.archived).map(item => [keyOf(item), `${item.name} @ ${item.revision}`]), base ? keyOf(base) : 'builtin.codex@1')
480
+ $('template-base-name').value = base ? `${base.name} 副本` : ''
481
+ $('template-base-dialog').showModal()
482
+ }
483
+ function checkboxes(container, values, checked) {
484
+ container.replaceChildren(...values.map(value => { const label = node('label'); const control = document.createElement('input'); control.type = 'checkbox'; control.value = value; control.checked = checked.includes(value); label.append(control, node('span', value)); return label }))
485
+ }
486
+ function addHeader(rule = { name: '', action: 'set', value: '', enabled: true, when: 'always' }) {
487
+ const row = document.createElement('tr'); row.dataset.headerRow = 'true'
488
+ const input = (field, kind = 'input') => { const element = document.createElement(kind); element.dataset.field = field; return element }
489
+ const on = input('enabled'); on.type = 'checkbox'; on.checked = rule.enabled !== false; on.setAttribute('aria-label', '启用请求头')
490
+ const name = input('name'); name.value = rule.name; name.setAttribute('aria-label', '请求头名称')
491
+ const action = input('action', 'select'); options(action, [['set','覆盖'],['set-if-missing','仅缺失补充'],['set-if-empty','缺失或空补充'],['delete','删除'],['preserve','保留']], rule.action)
492
+ const value = input('value','textarea'); value.value = rule.value ?? ''; value.setAttribute('aria-label', '请求头值'); value.rows = 2
493
+ const when = input('when','select'); options(when, [['always','始终'],['native','原生'],['third-party','第三方']], rule.when ?? 'always')
494
+ const ops = input('operations'); ops.value = rule.operations?.join(',') ?? ''; ops.placeholder = '全部'; ops.setAttribute('aria-label', '限定操作')
495
+ const remove = button('×', () => { row.remove(); updateDraft() }, 'danger'); remove.setAttribute('aria-label','移除请求头')
496
+ for (const control of [on,name,action,value,when,ops,remove]) { const cell = document.createElement('td'); cell.append(control); row.append(cell) }
497
+ $('tpl-headers').tBodies[0].append(row)
498
+ }
499
+ function templateDraft() {
500
+ const draft = clone(template)
501
+ draft.name = $('tpl-name').value.trim()
502
+ draft.protocols = [...$('tpl-protocols').querySelectorAll('input:checked')].map(input => input.value)
503
+ draft.operations = [...$('tpl-operations').querySelectorAll('input:checked')].map(input => input.value)
504
+ draft.identity = { ...draft.identity, detector: $('tpl-detector').value, mode: $('tpl-mode').value, session: $('tpl-session').value, userAgent: $('tpl-ua').value, version: $('tpl-version').value }
505
+ draft.headers = [...$('tpl-headers').tBodies[0].rows].map(row => { const get = field => row.querySelector(`[data-field="${field}"]`); const action = get('action').value; const entry = { name: get('name').value, action, enabled: get('enabled').checked, when: get('when').value }; if (!['delete','preserve'].includes(action)) entry.value = get('value').value; if (get('operations').value.trim()) entry.operations = get('operations').value.split(',').map(value => value.trim()).filter(Boolean); return entry })
506
+ draft.body = { ...draft.body, codexMetadata: $('tpl-codex-metadata').checked, claudeCompatibility: $('tpl-claude-compat').value, fastBeta: $('tpl-fast-beta').checked,
507
+ parameters: { defaults: jsonObject($('tpl-defaults').value,'defaults'), overrides: jsonObject($('tpl-overrides').value,'overrides'), remove: lines($('tpl-remove').value) } }
508
+ if ($('tpl-identity-prompt').value.trim()) draft.body.identityprompt = $('tpl-identity-prompt').value; else delete draft.body.identityprompt
509
+ draft.limits = { maxBodyBytes: Number($('tpl-max-body').value), bodyTimeoutMs: Number($('tpl-body-timeout').value), maxMetadataBytes: Number($('tpl-max-metadata').value) }
510
+ return draft
511
+ }
512
+ function updateDraft() {
513
+ if (!template) return
514
+ try {
515
+ const current = templateDraft()
516
+ dirtyTemplate = template.kind === 'custom' && JSON.stringify(current) !== templateBaseline
517
+ text('tpl-dirty', template.kind === 'builtin' ? '内置基线只读' : dirtyTemplate ? '草稿已修改,尚未保存为版本' : '已保存版本,连接绑定保持不变')
518
+ text('tpl-state', dirtyTemplate ? '未保存' : '无修改')
519
+ const changes = differences(templateOrigin ?? template, current)
520
+ $('tpl-changes').replaceChildren(...(changes.length ? changes.map(change => node('div', `${change.action} ${change.path}`, 'diff-line')) : [node('p','与基线相同')]))
521
+ text('tpl-json', JSON.stringify(safeTemplate(current), null, 2))
522
+ $('tpl-save').disabled = template.kind === 'builtin' || locked() || (!dirtyTemplate && Boolean(template.id))
523
+ $('tpl-apply').disabled = !template.id || dirtyTemplate || locked()
524
+ text('tpl-error','')
525
+ } catch (cause) { dirtyTemplate = true; text('tpl-error',cause.message); $('tpl-apply').disabled = true }
526
+ }
527
+ function editTemplate(item, isNew = false) {
528
+ if (!isNew && !allowLeave()) return
529
+ navigate('templates', true)
530
+ template = clone(item)
531
+ templateOrigin = item.basedOn ? state.templates.find(base => base.id === item.basedOn.id && base.revision === item.basedOn.revision) : item
532
+ $('template-library').hidden = true; $('template-editor').hidden = false
533
+ $('tpl-name').value = item.name
534
+ checkboxes($('tpl-protocols'), PROTOCOLS, item.protocols)
535
+ checkboxes($('tpl-operations'), [...new Set(Object.values(OPERATIONS).flat())], item.operations)
536
+ for (const [id, value] of Object.entries({ 'tpl-detector': item.identity.detector, 'tpl-mode': item.identity.mode, 'tpl-session': item.identity.session, 'tpl-ua': item.identity.userAgent ?? '', 'tpl-version': item.identity.version ?? '', 'tpl-claude-compat': item.body.claudeCompatibility, 'tpl-identity-prompt': item.body.identityprompt ?? '', 'tpl-defaults': JSON.stringify(item.body.parameters.defaults, null, 2), 'tpl-overrides': JSON.stringify(item.body.parameters.overrides, null, 2), 'tpl-remove': item.body.parameters.remove.join('\n'), 'tpl-max-body': item.limits.maxBodyBytes, 'tpl-body-timeout': item.limits.bodyTimeoutMs, 'tpl-max-metadata': item.limits.maxMetadataBytes })) $(id).value = value
537
+ $('tpl-codex-metadata').checked = item.body.codexMetadata; $('tpl-fast-beta').checked = item.body.fastBeta
538
+ $('tpl-headers').tBodies[0].replaceChildren(); for (const header of item.headers) addHeader(header)
539
+ text('tpl-revision', item.id ? `v${item.revision}` : '新草稿'); text('tpl-base', item.basedOn ? `${item.basedOn.id} @ ${item.basedOn.revision}` : '内置基线')
540
+ $('tpl-readonly').hidden = item.kind !== 'builtin'
541
+ for (const control of $('template-editor').querySelectorAll('.tpl-sections input,.tpl-sections select,.tpl-sections textarea,.tpl-sections button,#tpl-name')) control.disabled = item.kind === 'builtin'
542
+ templateBaseline = JSON.stringify(templateDraft()); dirtyTemplate = isNew
543
+ updateDraft(); if (isNew) { dirtyTemplate = true; text('tpl-dirty','新模板草稿,尚未保存'); $('tpl-save').disabled = false }
544
+ }
545
+ function showHistory(id) {
546
+ const items = state.templates.filter(item => item.id === id).sort((a,b) => b.revision-a.revision)
547
+ text('history-title', `${items[0].name} · 版本历史`)
548
+ $('history-body').replaceChildren(...items.map(item => { const row = node('div', undefined,'history-row'); row.append(node('span', `版本 ${item.revision} · ${item.protocols.join('/')}`)); const actions = node('div', undefined,'toolbar-actions'); actions.append(button('查看', () => { $('template-history').close(); editTemplate(item) }), button('与上一版比较', () => { const previous = items.find(other => other.revision === item.revision-1); $('history-diff').replaceChildren(...differences(previous ?? {}, item).map(change => node('p', `${change.action} ${change.path}`, 'hint'))) }), button('应用 / 回退', () => { $('template-history').close(); openBinding([], item) })); if (item.kind === 'custom') actions.append(button('删除', async () => { if (window.confirm('删除这个未被使用的版本?此操作不能撤销。')) await run('templates/remove', { id, revision: item.revision }, () => { $('template-history').close() }) },'danger')); row.append(actions); return row }))
549
+ $('history-diff').replaceChildren(); $('template-history').showModal()
550
+ }
551
+ function selectedTestConnection() { return state.connections.find(item => item.id === $('test-connection').value) }
552
+ function currentTestModel() { return testModelManual ? $('test-model').value.trim() : $('test-model-select').value }
553
+ function cancelTestModelQuery() {
554
+ const query = testModelQuery
555
+ testModelQuery = null
556
+ query?.controller.abort()
557
+ $('test-model-cancel').hidden = true
558
+ }
559
+ function testFormIdentity() {
560
+ return testRequestIdentity({ kind: $('test-kind').value, connectionId: $('test-connection').value, connectionRevision: selectedTestConnection()?.revision,
561
+ protocol: $('test-protocol').value, model: currentTestModel(), operation: $('test-operation').value, stream: $('test-stream').checked,
562
+ templateMode: $('test-template-source').value, templateId: $('test-template').value, revision: $('test-revision').value,
563
+ apiPath: $('test-api-path').value, request: { headers: $('test-headers').value, body: $('test-body').value } })
564
+ }
565
+ function markTestResult() {
566
+ const stale = Boolean(lastTestIdentity) && lastTestIdentity !== testFormIdentity()
567
+ $('test-result-stale').hidden = !stale || testResultRunning
568
+ $('test-result').dataset.stale = String(stale && !testResultRunning)
569
+ if(stale&&!testResultRunning&&!loading&&!pending)status('当前选项尚未测试,下方是上一次请求结果')
570
+ }
571
+ function planInputs() {
572
+ const source = $('test-template-source').value
573
+ const connection = selectedTestConnection() ?? { id: 'preview-only', name: '离线预览', baseURL: 'https://preview.invalid', protocol: $('test-protocol').value, apiPaths: {}, binding: { mode: 'auto', resolvedVersions: { responses: { id: 'builtin.codex', revision: 1 }, anthropic: { id: 'builtin.claude-code', revision: 1 } } } }
574
+ const input = { snapshot: state, connection, kind: $('test-kind').value, protocol: $('test-protocol').value, model: currentTestModel(), operation: $('test-operation').value,
575
+ stream: $('test-stream').checked, templateMode: source === 'auto' ? 'auto' : 'binding', apiPath: $('test-kind').value === 'check' ? undefined : $('test-api-path').value.trim() || undefined }
576
+ if (source === 'ref' && input.kind !== 'check') input.templateRef = { id: $('test-template').value, revision: Number($('test-revision').value) }
577
+ if (source === 'draft' && input.kind !== 'check') { if (!template) throw new Error('尚无模板草稿,请先选择已有版本或复制模板'); input.template = { ...templateDraft(), id: template.id ?? 'custom.preview-draft', revision: template.revision ?? 1 } }
578
+ if (source === 'none') input.template = null
579
+ if (input.kind !== 'preview' && !selectedTestConnection()) throw new Error('请选择要直测的连接')
580
+ return input
581
+ }
582
+ function updateTestPlan() {
583
+ testPlan = null
584
+ testPlanError = ''
585
+ try {
586
+ testPlan = createTestPlan(planInputs())
587
+ if ($('test-kind').value !== 'check') validateTestSample(jsonObject($('test-body').value, '请求正文'), testPlan)
588
+ text('test-plan-url', `${testPlan.method} ${testPlan.url}`)
589
+ text('test-plan-template', testPlan.templateId ? `模板:${testPlan.template?.name ?? testPlan.templateId} @ ${testPlan.revision}` : '模板:普通转发')
590
+ text('test-plan-note', [testPlan.resolution?.message, ...(testPlan.warnings ?? [])].filter(Boolean).join(' '))
591
+ text('test-plan-state', '准备就绪')
592
+ text('test-plan-error', '')
593
+ } catch (cause) {
594
+ testPlanError = cause.message
595
+ text('test-plan-url', testPlan ? `${testPlan.method} ${testPlan.url}` : '请选择连接与模型并核对本次协议')
596
+ text('test-plan-template', testPlan?.templateId ? `模板:${testPlan.template?.name ?? testPlan.templateId} @ ${testPlan.revision}` : '')
597
+ text('test-plan-note', '')
598
+ text('test-plan-state', '需要核对')
599
+ text('test-plan-error', testPlanError)
600
+ }
601
+ const connection = selectedTestConnection()
602
+ const model = currentTestModel()
603
+ const productionProtocol = connection && model ? protocolForModel(connection, model) : null
604
+ text('test-production-route', productionProtocol ? `已保存的生产上游:${productionProtocol}。${testPlan?.protocol !== productionProtocol ? `本次测试为 ${testPlan?.protocol ?? '待选择'},尚未保存到生产路由。` : '本次协议与生产路由一致。'}` : '')
605
+ text('test-model-reference', gatewayModelReference(connection, model))
606
+ const productionBinding = connection?.modelBindings?.[model] ?? connection?.binding
607
+ const templateDiffers = productionBinding?.mode === 'template' && testPlan && (productionBinding.id !== testPlan.templateId || productionBinding.revision !== testPlan.revision)
608
+ $('test-route-save').hidden = !connection || !testPlan || Boolean(testPlanError) || $('test-kind').value === 'check' || Boolean($('test-api-path').value.trim()) || (!templateDiffers && productionProtocol === testPlan.protocol) || $('test-template-source').value === 'draft'
609
+ $('test-route-save').disabled = loading || Boolean(pending)
610
+ $('test-plan').dataset.valid = String(!testPlanError)
611
+ markTestResult()
612
+ }
613
+ function updateTestControls() {
614
+ const busy = loading || Boolean(pending)
615
+ updateTestPlan()
616
+ $('test-model-fetch').disabled = busy || Boolean(testModelQuery) || !selectedTestConnection()
617
+ $('test-model-select').disabled = busy || testModelManual || !testModels.length
618
+ $('test-model-manual').disabled = busy
619
+ $('test-run').disabled = busy || Boolean(testPlanError) || ($('test-kind').value !== 'check' && (!currentTestModel() || Boolean(testModelQuery))) || ($('test-kind').value === 'check' && !selectedTestConnection())
620
+ }
621
+ function syncTestModel({ followProtocol = true, reset = false } = {}) {
622
+ const model = currentTestModel()
623
+ if (!testModelManual) $('test-model').value = model
624
+ if (followProtocol && !testProtocolExplicit && model) $('test-protocol').value = testModelProtocol(selectedTestConnection(), model, $('test-protocol').value)
625
+ const protocol = $('test-protocol').value
626
+ options($('test-operation'), OPERATIONS[protocol].map(value => [value,value]))
627
+ if (['count_tokens','countTokens'].includes($('test-operation').value)) $('test-stream').checked=false
628
+ else if (protocol === 'gemini' && $('test-stream').checked) $('test-operation').value = 'streamGenerateContent'
629
+ if (!$('test-body').value || $('test-body').value === testSampleBaseline) resetSample()
630
+ else {
631
+ dirtyTestSample = true
632
+ $('test-sample-notice').hidden = false
633
+ }
634
+ if (reset) text('test-error', '')
635
+ updateTestControls()
636
+ }
637
+ function renderTestModels(message) {
638
+ const current = selectTestModel(testModels, $('test-model-select').value)
639
+ options($('test-model-select'), testModels.length ? testModels.map(model => [model.id, model.name === model.id ? model.id : `${model.name} · ${model.id}`]) : [['', selectedTestConnection() ? '暂无可测试模型' : '请先选择连接']], current)
640
+ options($('test-model-list'), testModels.map(model => [model.id, model.name]))
641
+ const saved = selectedTestConnection()?.models?.length
642
+ text('test-model-status', message ?? (testModels.length ? testCatalog?.cached ? `已载入 ${testModels.length} 个可选模型 · ${CATALOG_SOURCES[testCatalog.source] ?? '已发现目录'};可刷新列表,不代表生成已验证。` : saved ? `已载入 ${testModels.length} 个模型,优先使用连接中已保存的模型。` : `已获取 ${testModels.length} 个可选模型;请确认后再发送测试。` : selectedTestConnection() ? '此连接暂无模型。可点击“刷新模型”,或使用高级选项明确输入模型 ID;离线预览不会自动联网。' : '请选择连接;仅离线预览也可手动输入模型 ID。'))
643
+ syncTestModel()
644
+ }
645
+ function syncTestConnection({ autoFetch = false, reset = false } = {}) {
646
+ const selected = selectedTestConnection()
647
+ const changed = testConnectionID !== (selected?.id ?? '') || testConnectionRevision !== selected?.revision
648
+ if (changed || reset) {
649
+ cancelTestModelQuery()
650
+ testConnectionID = selected?.id ?? ''
651
+ testConnectionRevision = selected?.revision
652
+ testProtocolExplicit = false
653
+ testModelManual = false
654
+ $('test-model-manual-field').hidden = true
655
+ $('test-model-manual').setAttribute('aria-expanded', 'false')
656
+ text('test-model-manual', '手动输入模型 ID')
657
+ $('test-model').value = ''
658
+ $('test-model-select').value = ''
659
+ testCatalog = catalogFor(state, selected?.id)
660
+ $('test-api-path').value = ''
661
+ if (!$('test-body').value || $('test-body').value === testSampleBaseline) testSampleBaseline = $('test-body').value = ''
662
+ else { dirtyTestSample = true; $('test-sample-notice').hidden = false }
663
+ } else {
664
+ const cached = catalogFor(state, selected?.id)
665
+ if (cached && (!testCatalog || Date.parse(cached.checkedAt) >= Date.parse(testCatalog.checkedAt))) testCatalog = cached
666
+ }
667
+ testModels = testModelOptions(selected, testCatalog)
668
+ renderTestModels()
669
+ if (autoFetch && $('test-kind').value === 'infer' && selected && !testModels.length && !queriedTestConnections.has(selected.id) && !testModelQuery && !loading && !pending) void fetchTestModels()
670
+ }
671
+ async function fetchTestModels() {
672
+ const selected = selectedTestConnection()
673
+ if (!selected || loading || pending || testModelQuery) return
674
+ const query = { controller: new AbortController(), id: selected.id, revision: selected.revision }
675
+ testModelQuery = query
676
+ queriedTestConnections.add(selected.id)
677
+ text('test-model-status', '正在读取此连接的模型列表…仅获取目录,不发送生成请求。')
678
+ $('test-model-cancel').hidden = false
679
+ updateTestControls()
680
+ try {
681
+ const response = await fetch('./api/pro/connections/models', { method: 'POST', headers: { 'content-type': 'application/json' }, signal: query.controller.signal,
682
+ body: JSON.stringify({ connection: selected, expectedRevision: selected.revision, confirmNetwork: true, operationId: crypto.randomUUID() }) })
683
+ const result = await response.json()
684
+ if (testModelQuery !== query || selectedTestConnection()?.id !== query.id || selectedTestConnection()?.revision !== query.revision) return
685
+ if (!response.ok) throw new Error(result.error || '模型列表读取失败')
686
+ if (!Array.isArray(result.models)) throw new Error('模型列表响应格式无效')
687
+ testCatalog = result
688
+ state.modelCatalogs ??= {}
689
+ state.modelCatalogs[selected.id] = result
690
+ testModels = testModelOptions(selected, result)
691
+ renderTestModels(result.cached ? `${modelCatalogStatus(result)} 请选择列表中的模型进行测试。` : testModels.length ? `已获取 ${testModels.length} 个可选模型,已自动选中真实模型。` : '上游返回空模型列表。可刷新重试,或使用高级选项手动输入已知模型 ID。')
692
+ } catch (error) {
693
+ if (testModelQuery === query) renderTestModels(query.controller.signal.aborted ? '已取消读取模型列表,未发送生成请求。' : `模型列表读取失败:${error.message}。${testModels.length ? '已有模型仍可选择。' : '可刷新重试,或使用高级选项输入已知模型 ID。'}`)
694
+ } finally {
695
+ if (testModelQuery === query) { testModelQuery = null; $('test-model-cancel').hidden = true; updateTestControls() }
696
+ }
697
+ }
698
+ function resetSample() {
699
+ const protocol = $('test-protocol').value
700
+ options($('test-operation'), OPERATIONS[protocol].map(value => [value,value]))
701
+ if (protocol === 'gemini' && $('test-stream').checked) $('test-operation').value = 'streamGenerateContent'
702
+ const model = currentTestModel()
703
+ const body = model ? createTestSample(protocol, model, $('test-stream').checked, $('test-operation').value) : null
704
+ $('test-body').value = body ? JSON.stringify(body, null, 2) : ''
705
+ testSampleBaseline = $('test-body').value
706
+ dirtyTestSample = $('test-headers').value !== testHeadersBaseline
707
+ $('test-sample-notice').hidden = true
708
+ }
709
+ function testFields() {
710
+ const kind = $('test-kind').value, source = $('test-template-source').value
711
+ $('test-template-fields').hidden = kind === 'check'; $('test-ref-fields').hidden = source !== 'ref'
712
+ for(const id of ['test-protocol-fields','test-model-fields','test-stream-field','test-sample'])$(id).hidden=kind==='check'
713
+ $('test-endpoint-options').hidden=kind==='check'
714
+ if(kind==='check')$('test-sample-notice').hidden=true
715
+ text('test-kind-hint', kind === 'preview' ? '离线转换,不发送网络请求。' : kind === 'check' ? '只读取所选连接的模型列表,不验证生成能力。' : '直接请求所选连接,不换 key、不自动回退;可能产生费用。')
716
+ text('test-run', kind === 'preview' ? '运行本地预览' : kind === 'check' ? '检查模型列表' : '发送实际测试')
717
+ updateTestControls()
718
+ }
719
+ function openTest(connectionId = '', draft = null) {
720
+ if (connectionId) $('test-connection').value = connectionId
721
+ $('test-template-source').value = draft ? 'draft' : 'auto'
722
+ $('test-kind').value = 'preview'
723
+ syncTestConnection({ reset: Boolean(connectionId) })
724
+ if (draft) { testProtocolExplicit = true; $('test-protocol').value = draft.protocols[0]; syncTestModel({ followProtocol: false }) }
725
+ testFields()
726
+ navigate('tests', true)
727
+ }
728
+ function renderTest(result, identity) {
729
+ testResultRunning = false
730
+ lastTestIdentity = identity ?? testFormIdentity()
731
+ $('test-result').replaceChildren(node('h2', result.kind === 'preview' ? '本地转换预览' : result.kind === 'check' ? '模型列表检查' : '直测此连接'))
732
+ $('test-result').append(node('p', result.status === 'passed' ? '通过' : '未通过', `result-state ${result.status === 'passed' ? '' : 'failed'}`), node('p', result.error ?? result.message ?? '这是本次请求的固定结果;改变表单后会标记为上一次测试。', 'hint'))
733
+ const summary = node('dl', undefined,'kv')
734
+ for (const [label,value] of [['连接',result.connectionName ?? result.connectionId ?? '离线预览'],['模型',result.model || '模型列表检查'],['测试时间',catalogTime(result.testedAt ?? new Date().toISOString())],['模板', result.templateId ? `${result.templateId} @ ${result.revision}` : '普通转发 / 未使用'],['上游协议',result.protocol ?? '模型列表'],['HTTP',result.httpStatus ?? (result.kind === 'preview' ? '未联网' : '未收到响应')],['耗时', `${result.durationMs ?? 0} ms`],['原始身份',result.callerIdentity ?? '未判断']]) { const row = node('div'); row.append(node('dt',label),node('dd',value)); summary.append(row) }
735
+ $('test-result').append(summary)
736
+ if (result.request?.url) $('test-result').append(node('p', `${result.request.method ?? 'POST'} ${result.request.url}`, 'test-plan-url'))
737
+ if (result.resolution?.message) $('test-result').append(node('p',result.resolution.message,'hint'))
738
+ if (result.diagnostic) {
739
+ const info=node('section',undefined,'test-diagnostic')
740
+ info.append(node('h3',result.diagnostic.title),node('p',result.diagnostic.message))
741
+ for(const step of result.diagnostic.suggestions ?? [])info.append(node('p',step,'hint'))
742
+ $('test-result').append(info)
743
+ }
744
+ for (const [title,value] of [['响应',result.responseText],['用量',result.usage],['模型列表',result.models],['出站请求(脱敏)',result.request],['变更规则',result.changes]]) if (value !== undefined) { const details = node('details',undefined,'more'); details.open = title==='响应'; details.append(node('summary',title),node('pre',typeof value === 'string' ? value : JSON.stringify(value,null,2),'code-block')); $('test-result').append(details) }
745
+ markTestResult()
746
+ }
747
+ async function executeTest() {
748
+ let payload
749
+ try {
750
+ const kind = $('test-kind').value
751
+ if (kind !== 'check' && !currentTestModel()) throw new Error('请选择列表中的模型,或在高级选项中输入已知模型 ID')
752
+ if (kind !== 'check' && testModelQuery) throw new Error('模型列表正在读取,请稍候或取消获取')
753
+ payload = { kind, connectionId: $('test-connection').value || undefined, protocol: $('test-protocol').value, model: currentTestModel(), operation: $('test-operation').value, stream: $('test-stream').checked }
754
+ if (kind !== 'check') payload.request = { headers: jsonObject($('test-headers').value,'请求头'), body: jsonObject($('test-body').value,'请求正文') }
755
+ if (kind !== 'check') {
756
+ const source = $('test-template-source').value
757
+ payload.templateMode = source === 'auto' ? 'auto' : 'binding'
758
+ if (source === 'ref') payload.templateRef = { id: $('test-template').value, revision: Number($('test-revision').value) }
759
+ if (source === 'draft') { if (!template) throw new Error('请先创建模板草稿'); payload.template = { ...templateDraft(), id: template.id ?? 'custom.preview-draft', revision: template.revision ?? 1 } }
760
+ if (source === 'none') payload.template = null
761
+ }
762
+ updateTestPlan()
763
+ if (testPlanError) throw new Error(testPlanError)
764
+ payload.apiPath = kind === 'check' ? undefined : $('test-api-path').value.trim() || undefined
765
+ payload.expectedGeneration = state.generation
766
+ payload.expectedConnectionRevision = selectedTestConnection()?.revision
767
+ const identity = testFormIdentity()
768
+ if (kind !== 'preview') {
769
+ if (!payload.connectionId) throw new Error('请选择要直测的连接')
770
+ text('test-confirm-text', kind === 'infer' ? '此操作将发送一次简短模型请求,可能产生费用;不自动重试或切换连接。测试选项不会改变保存的绑定。' : '此操作只读取模型列表,不发送生成请求。')
771
+ $('test-confirm-detail').replaceChildren(node('p', selectedTestConnection()?.name ?? ''), node('p', `${testPlan.method} ${testPlan.url}`, 'test-plan-url'), node('p', `模型:${kind === 'check' ? '目录检查' : testPlan.model} · 协议:${testPlan.protocol}`), node('p', testPlan.templateId ? `模板:${testPlan.template?.name ?? testPlan.templateId} @ ${testPlan.revision}` : '模板:普通转发'), node('p', testPlan.resolution?.message ?? '', 'hint'))
772
+ $('test-confirm').showModal()
773
+ const choice = await new Promise(resolve => $('test-confirm').addEventListener('close', () => resolve($('test-confirm').returnValue), { once: true }))
774
+ if (choice !== 'confirm') return
775
+ payload.confirmNetwork = true
776
+ }
777
+ text('test-error', '')
778
+ showError(null)
779
+ testResultRunning = true
780
+ $('test-result-stale').hidden = true
781
+ $('test-result').replaceChildren(node('h2', '正在测试当前选择'), node('p', `${testPlan.model || '模型列表'} · ${testPlan.protocol}`, 'hint'), node('p', `${testPlan.method} ${testPlan.url}`, 'test-plan-url'))
782
+ await run('test',payload,data => {
783
+ renderTest(data.result, identity)
784
+ status(data.result.status === 'passed' ? '测试完成:通过' : '测试完成:未通过,请查看本次原因')
785
+ })
786
+ if (!pending && testResultRunning) { testResultRunning = false; $('test-result').replaceChildren(node('h2', '本次测试未发送或准备失败'), node('p', '请查看配置提示。此处不是之前的请求结果。', 'hint')) }
787
+ } catch (cause) { text('test-error',cause.message); $('test-error').focus?.(); markTestResult() }
788
+ }
789
+
790
+ for (const link of document.querySelectorAll('[data-view-link]')) link.addEventListener('click', event => { event.preventDefault(); navigate(link.dataset.viewLink) })
791
+ for (const link of document.querySelectorAll('[data-leave]')) link.addEventListener('click',event => { if (!allowLeave()) event.preventDefault() })
792
+ for (const control of document.querySelectorAll('[data-action="connection-new"]')) control.addEventListener('click', () => editConnection())
793
+ $('host-sync').addEventListener('click', () => { if (allowLeave()) void run('connections/sync', {}) })
794
+ $('pro-reload').addEventListener('click', async () => { if (!allowLeave()) return; try { await refresh(false); status('已重新读取') } catch (error) { showError(error) } })
795
+ $('pro-recover').addEventListener('click', recover)
796
+ $('pro-close').addEventListener('click', async () => { if (!allowLeave()) return; await fetch('./api/cancel', { method:'POST',headers:{'content-type':'application/json'},body:'{}' }); document.body.replaceChildren(node('main','配置已关闭。此前保存的更改已保留。','empty')) })
797
+ $('connection-form').addEventListener('input', () => { try { dirtyConnection = JSON.stringify(connectionDraft()) !== connectionBaseline } catch { dirtyConnection = true } text('connection-form-state',dirtyConnection ? '未保存' : '无修改') })
798
+ $('conn-auth-mode').addEventListener('change',updateAuthFields)
799
+ $('conn-base').addEventListener('input',()=>{if(!$('connection-form').hidden)renderConnectionAccountOptions()})
800
+ $('conn-model-fetch').addEventListener('click', () => { void fetchConnectionModels() })
801
+ $('conn-model-cancel').addEventListener('click', () => resetModelQuery('已取消获取,手填内容保留。'))
802
+ $('conn-model-all').addEventListener('click', () => { for (const option of $('conn-model-options').options) option.selected = true })
803
+ $('conn-model-add').addEventListener('click', () => applyFetchedModels(false))
804
+ $('conn-model-replace').addEventListener('click', () => applyFetchedModels(true))
805
+ for (const eventName of ['input', 'change']) $('connection-form').addEventListener(eventName, event => {
806
+ if ((modelQuery || modelQueryReady || modelResultShown) && (['conn-base','conn-auth-mode','conn-key','conn-value','conn-protocol','conn-clear-secret'].includes(event.target.id) || event.target.hasAttribute('data-api-path'))) resetModelQuery('连接信息已改变,请重新获取模型。手填内容保留。')
807
+ })
808
+ $('connection-form').addEventListener('submit', async event => { event.preventDefault(); try { const current = connectionDraft(); if (!current.name || !current.baseURL) throw new Error('请填写连接名称和地址'); await run('connections/save',{connection:current,expectedRevision:connection?.revision,clearSecrets:$('conn-clear-secret').checked ? ['key','value'] : []},data=>{dirtyConnection=false;editConnection(data.connection)}) } catch(error){text('connection-form-error',error.message)} })
809
+ $('connection-cancel').addEventListener('click',()=>{if(allowLeave()){dirtyConnection=false;$('connection-panel').hidden=true}})
810
+ $('connection-remove').addEventListener('click',()=>{if(connection && window.confirm('删除这个连接?不会删除模板或站点账号。'))void run('connections/remove',{id:connection.id,expectedRevision:connection.revision},()=>{dirtyConnection=false;$('connection-panel').hidden=true})})
811
+ $('binding-open').addEventListener('click',()=>openBinding())
812
+ $('binding-template').addEventListener('change',()=>renderRevisions('binding'))
813
+ $('binding-mode').addEventListener('change',bindingFields); $('binding-scope').addEventListener('change',bindingFields)
814
+ $('binding-cancel').addEventListener('click',()=>{$('connection-panel').hidden=true})
815
+ $('binding-form').addEventListener('submit',event=>{event.preventDefault();const ids=[...$('binding-connections').querySelectorAll('input:checked')].map(input=>input.value);const mode=$('binding-mode').value;const binding=mode==='template'?{mode,id:$('binding-template').value,revision:Number($('binding-revision').value)}:{mode};if(!ids.length){text('binding-error','请选择连接');return}if(!window.confirm('应用后续新请求使用这个版本;正在进行的请求保持原版本。继续吗?'))return;void run('bindings/apply',{connectionIds:ids,binding,model:$('binding-scope').value==='model'?$('binding-model').value.trim():undefined,expectedGeneration:state.generation},()=>{$('connection-panel').hidden=true})})
816
+ $('template-import').addEventListener('click',()=>{if(allowLeave())$('template-import-file').click()})
817
+ $('template-import-file').addEventListener('change',async()=>{
818
+ const file=$('template-import-file').files[0];$('template-import-file').value='';if(!file)return
819
+ try{
820
+ if(file.size>192*1024)throw new Error('模板文件最多192 KiB')
821
+ const raw=jsonObject(await file.text(),'模板文件')
822
+ await run('templates/validate',{template:raw},data=>{const draft=forkTemplate(data.template,`${data.template.name} 导入副本`);if(!state.templates.some(item=>item.id===draft.basedOn.id&&item.revision===draft.basedOn.revision))delete draft.basedOn;editTemplate(draft,true)})
823
+ }catch(error){showError(error)}
824
+ })
825
+ $('tpl-export').addEventListener('click',()=>{
826
+ try{
827
+ if(!window.confirm('将导出规则副本。已知秘密字段会遮罩,自定义普通文本仍需你检查后再分享。继续吗?'))return
828
+ const value=safeTemplate({...templateDraft(),id:template.id??'custom.export-draft'})
829
+ const url=URL.createObjectURL(new Blob([JSON.stringify(value,null,2)],{type:'application/json'}))
830
+ const link=document.createElement('a');link.href=url;link.download='ua-pro-template.json';link.click();setTimeout(()=>URL.revokeObjectURL(url),1000)
831
+ }catch(error){showError(error)}
832
+ })
833
+ $('template-new').addEventListener('click',()=>beginFork())
834
+ $('template-base-cancel').addEventListener('click',()=>$('template-base-dialog').close())
835
+ $('template-base-form').addEventListener('submit',event=>{event.preventDefault();const base=state.templates.find(item=>keyOf(item)===$('template-base-select').value);const name=$('template-base-name').value.trim();if(!name){text('template-base-error','请填写模板名称');return}$('template-base-dialog').close();editTemplate(forkTemplate(base,name),true)})
836
+ $('template-show-archived').addEventListener('change',renderTemplates)
837
+ $('tpl-fork').addEventListener('click',()=>beginFork(template))
838
+ $('tpl-header-add').addEventListener('click',()=>{addHeader();updateDraft()})
839
+ $('template-editor').addEventListener('input',updateDraft);$('template-editor').addEventListener('change',updateDraft)
840
+ $('tpl-back').addEventListener('click',()=>{if(allowLeave()){dirtyTemplate=false;$('template-editor').hidden=true;$('template-library').hidden=false}})
841
+ $('tpl-discard').addEventListener('click',()=>{if(!dirtyTemplate||window.confirm('放弃尚未保存的模板修改?')){dirtyTemplate=false;editTemplate(template,true);dirtyTemplate=false;templateBaseline=JSON.stringify(templateDraft());updateDraft()}})
842
+ $('template-editor').addEventListener('submit',async event=>{event.preventDefault();try{const current=templateDraft();const versions=state.templates.filter(item=>item.id===current.id);await run('templates/save',{template:current,expectedRevision:versions.length?Math.max(...versions.map(item=>item.revision)):0},data=>{dirtyTemplate=false;editTemplate(data.template)})}catch(error){text('tpl-error',error.message)}})
843
+ $('tpl-preview').addEventListener('click',()=>{try{templateDraft();openTest('',template)}catch(error){text('tpl-error',error.message)}})
844
+ $('tpl-apply').addEventListener('click',()=>{if(template?.id&&!dirtyTemplate)openBinding([],template)})
845
+ $('history-close').addEventListener('click',()=>$('template-history').close())
846
+ $('test-template').addEventListener('change',()=>{renderRevisions('test');updateTestControls()})
847
+ $('test-revision').addEventListener('change',updateTestControls)
848
+ $('test-kind').addEventListener('change',()=>{testFields();syncTestConnection({autoFetch:true})});$('test-template-source').addEventListener('change',testFields)
849
+ $('test-connection').addEventListener('change',()=>{testSelectionInitialized=true;syncTestConnection({autoFetch:true,reset:true})})
850
+ $('test-model-select').addEventListener('change',()=>syncTestModel({reset:true}))
851
+ $('test-model').addEventListener('input',()=>syncTestModel())
852
+ $('test-model-fetch').addEventListener('click',()=>{void fetchTestModels()})
853
+ $('test-model-cancel').addEventListener('click',()=>{cancelTestModelQuery();renderTestModels('已取消读取模型列表,未发送生成请求。')})
854
+ $('test-model-manual').addEventListener('click',()=>{
855
+ testModelManual=!testModelManual
856
+ $('test-model-manual-field').hidden=!testModelManual
857
+ $('test-model-manual').setAttribute('aria-expanded',String(testModelManual))
858
+ text('test-model-manual',testModelManual?'返回列表选择':'手动输入模型 ID')
859
+ if(testModelManual){$('test-model').value=currentTestModel()||$('test-model-select').value;$('test-model').focus()}
860
+ syncTestModel()
861
+ })
862
+ $('test-protocol').addEventListener('change',()=>{testProtocolExplicit=true;syncTestModel({followProtocol:false,reset:true})});$('test-stream').addEventListener('change',()=>syncTestModel({followProtocol:false,reset:true}))
863
+ const useSimpleSample=()=>{if($('test-body').value!==testSampleBaseline&&!window.confirm('用当前协议的简短样例替换自定义正文?'))return;resetSample();text('test-error','');updateTestControls()}
864
+ $('test-sample-reset').addEventListener('click',useSimpleSample);$('test-sample-adapt').addEventListener('click',useSimpleSample)
865
+ $('test-operation').addEventListener('change',()=>{
866
+ if(['count_tokens','countTokens'].includes($('test-operation').value))$('test-stream').checked=false
867
+ else if($('test-protocol').value==='gemini')$('test-stream').checked=$('test-operation').value==='streamGenerateContent'
868
+ if($('test-body').value===testSampleBaseline)resetSample();else{$('test-sample-notice').hidden=false;dirtyTestSample=true}
869
+ updateTestControls()
870
+ })
871
+ $('test-api-path').addEventListener('input',updateTestControls)
872
+ $('test-route-save').addEventListener('click',async()=>{
873
+ updateTestPlan()
874
+ const connection=selectedTestConnection()
875
+ if(!connection||!testPlan||testPlanError||$('test-api-path').value.trim())return
876
+ if(!window.confirm(`将 ${testPlan.model} 的生产上游保存为 ${testPlan.protocol},模板 ${testPlan.template?.name ?? '普通转发'}?其他模型不变。保存后需在 Magpie 重新获取模型或重载插件。`))return
877
+ const binding=testPlan.templateId?{mode:'template',id:testPlan.templateId,revision:testPlan.revision}:{mode:'passthrough'}
878
+ const routedModel=testPlan.model
879
+ await run('routes/apply',{connectionId:connection.id,model:routedModel,protocol:testPlan.protocol,binding,expectedGeneration:state.generation},()=>{
880
+ syncTestConnection({reset:true})
881
+ if(testModels.some(item=>item.id===routedModel))$('test-model-select').value=routedModel
882
+ else {testModelManual=true;$('test-model').value=routedModel;$('test-model-manual-field').hidden=false;$('test-model-manual').setAttribute('aria-expanded','true');text('test-model-manual','返回列表选择')}
883
+ syncTestModel({reset:true})
884
+ status('模型路由已保存,请在 Magpie 重新获取模型或重载插件')
885
+ })
886
+ })
887
+ for(const id of ['test-body','test-headers'])$(id).addEventListener('input',()=>{dirtyTestSample=$('test-body').value!==testSampleBaseline||$('test-headers').value!==testHeadersBaseline;updateTestControls()})
888
+ $('test-form').addEventListener('change',()=>{text('test-error','');if(!loading&&!pending)showError(null);markTestResult()})
889
+ $('test-form').addEventListener('submit',event=>{event.preventDefault();void executeTest()})
890
+ $('test-cancel').addEventListener('click',async()=>{
891
+ if(operation?.action!=='test')return
892
+ $('test-cancel').disabled=true
893
+ text('test-cancel','取消中…');status('正在取消本次测试;若已到达上游,仍可能计费')
894
+ try{await fetch('./api/pro/cancel-test',{method:'POST',headers:{'content-type':'application/json'},body:JSON.stringify({operationId:operation.id})})}
895
+ catch{showError('未收到取消确认,请查询原操作,不要重复发送')}
896
+ finally{if(!loading){$('test-cancel').disabled=false;text('test-cancel','取消测试')}}
897
+ })
898
+ window.addEventListener('beforeunload',event=>{if(isDirty()||locked()){event.preventDefault();event.returnValue=''}})
899
+ try { await refresh();navigate(location.hash.slice(1)||'overview',true);testFields();resetSample();$('pro-shell').dataset.ready='true';status('配置已读取。保存版本与应用连接分别执行。') } catch(error){showError(error);status('无法读取 UA PRO 配置')}
900
+ }