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

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