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/LICENSE +23 -0
- package/NOTICE.md +9 -0
- package/README.md +200 -2
- package/assets/icon.svg +9 -0
- package/index.mjs +19 -0
- package/lib/parameters.mjs +108 -0
- package/lib/ua-protocol.mjs +51 -0
- package/lib/ua-test-plan.mjs +232 -0
- package/package.json +80 -4
- package/setup.mjs +39 -0
- package/src/account-profile.mjs +73 -0
- package/src/account-usage.mjs +33 -0
- package/src/accounts.mjs +354 -0
- package/src/connection.mjs +144 -0
- package/src/diagnostics.mjs +37 -0
- package/src/headers.mjs +60 -0
- package/src/key-usage.mjs +73 -0
- package/src/newapi.mjs +330 -0
- package/src/options.mjs +48 -0
- package/src/plugin.mjs +344 -0
- package/src/profile.mjs +217 -0
- package/src/runtime.mjs +39 -0
- package/src/schedule.mjs +88 -0
- package/src/scheduler.mjs +141 -0
- package/src/setup-server.mjs +478 -0
- package/src/store.mjs +58 -0
- package/src/transport.mjs +84 -0
- package/src/ua-model-catalog.mjs +218 -0
- package/src/ua-plugin.mjs +250 -0
- package/src/ua-runtime.mjs +229 -0
- package/src/ua-service.mjs +129 -0
- package/src/ua-store.mjs +660 -0
- package/src/ua-tests.mjs +153 -0
- package/src/ua-transform.mjs +478 -0
- package/src/ua-usage.mjs +64 -0
- package/ui/accounts.css +75 -0
- package/ui/accounts.html +67 -0
- package/ui/accounts.js +786 -0
- package/ui/pro.css +1683 -0
- package/ui/pro.html +347 -0
- package/ui/pro.js +900 -0
- package/ui/theme.js +39 -0
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
|
+
}
|