const state = {session: null, identity: null, providers: [], credentials: [], audit: [], busy: new Set()}; function element(tagName, className, text) { const node = document.createElement(tagName); if (className) node.className = className; if (text !== undefined) node.textContent = text; return node; } function formatTime(value) { if (!value) return "—"; return new Date(value).toLocaleString("zh-CN", {month: "2-digit", day: "2-digit", hour: "2-digit", minute: "2-digit"}); } function avatarInitials(profile) { const source = String(profile.name || profile.username || "U").trim() || "U"; const parts = source.split(/\s+/).filter(Boolean); return (parts.length > 1 ? `${parts[0][0]}${parts.at(-1)[0]}` : [...source].slice(0, 2).join("")).toUpperCase(); } function avatarPlaceholder(profile) { const initials = avatarInitials(profile); const source = String(profile.username || profile.name || initials); let hash = 0; for (const character of source) hash = ((hash << 5) - hash + character.codePointAt(0)) | 0; const hue = Math.abs(hash) % 360; const escaped = initials.replaceAll("&", "&").replaceAll("<", "<").replaceAll(">", ">"); const svg = `${escaped}`; return `data:image/svg+xml;charset=utf-8,${encodeURIComponent(svg)}`; } async function avatarHash(email) { const input = new TextEncoder().encode(String(email || "").trim().toLowerCase()); const digest = await crypto.subtle.digest("SHA-256", input); return [...new Uint8Array(digest)].map((byte) => byte.toString(16).padStart(2, "0")).join(""); } async function loadAccountAvatar(profile, refresh = false) { const avatar = document.getElementById("account-avatar"); const requestId = String(Number(avatar.dataset.requestId || "0") + 1); avatar.dataset.requestId = requestId; avatar.dataset.loading = "true"; avatar.src = avatarPlaceholder(profile); if (!String(profile.email || "").trim()) { avatar.dataset.loading = "false"; return; } const hash = await avatarHash(profile.email); if (avatar.dataset.requestId !== requestId) return; const cacheBuster = refresh ? `&v=${Date.now()}` : ""; const sources = [ `https://www.gravatar.com/avatar/${hash}?d=404&s=256${cacheBuster}`, `https://seccdn.libravatar.org/avatar/${hash}?d=404&s=256${cacheBuster}` ]; const trySource = (sourceIndex) => { if (avatar.dataset.requestId !== requestId) return; if (sourceIndex >= sources.length) { avatar.dataset.loading = "false"; avatar.avatarLoader = null; return; } const image = new Image(); image.referrerPolicy = "no-referrer"; avatar.avatarLoader = image; const timeout = window.setTimeout(() => { image.onload = null; image.onerror = null; trySource(sourceIndex + 1); }, 5000); image.onload = () => { window.clearTimeout(timeout); if (avatar.dataset.requestId !== requestId) return; avatar.src = image.src; avatar.dataset.loading = "false"; if (avatar.avatarLoader === image) avatar.avatarLoader = null; }; image.onerror = () => { window.clearTimeout(timeout); trySource(sourceIndex + 1); }; image.src = sources[sourceIndex]; }; trySource(0); } async function request(path, options = {}) { const response = await fetch(path, { cache: "no-store", ...options, headers: {"Accept": "application/json", ...(options.body ? {"Content-Type": "application/json"} : {})} }); const text = await response.text(); let payload = {}; try { payload = text ? JSON.parse(text) : {}; } catch { payload = {error: text}; } if (!response.ok) throw new Error(payload.error || `HTTP ${response.status}`); return payload; } function actionButton(label, handler, dangerous = false) { const button = element("button", `admin-action${dangerous ? " dangerous" : ""}`, label); button.type = "button"; button.addEventListener("click", handler); return button; } function pill(label, tone = "") { return element("span", `admin-pill${tone ? ` ${tone}` : ""}`, label); } async function withBusy(key, operation) { if (state.busy.has(key)) return; state.busy.add(key); try { await operation(); } catch (error) { window.alert(`操作失败:${error.message}`); } finally { state.busy.delete(key); } } async function loadSession() { const payload = await request("/api/account/session"); state.session = payload.identity; document.getElementById("account-session").textContent = `${payload.identity.username} · ${payload.identity.provider} · ${payload.identity.sub}`; document.getElementById("account-admin-link").hidden = !payload.identity.admin; } function securityRecord(title, detail, actions = []) { const record = element("article", "admin-record account-security-record"); const heading = element("div", "admin-record-heading"); heading.append(element("strong", "", title)); const buttons = element("div", "admin-actions"); actions.forEach((action) => buttons.append(action)); heading.append(buttons); record.append(heading, element("p", "admin-help", detail)); return record; } async function mutateAccount(path, body = {}) { return request(path, {method: "POST", body: JSON.stringify(body)}); } function renderSecurityList(id, entries, emptyText, renderer) { const container = document.getElementById(id); container.replaceChildren(); if (!entries.length) container.append(element("div", "empty-state", emptyText)); entries.forEach((entry) => container.append(renderer(entry))); } function renderAccount() { const {profile, security} = state.identity; const form = document.getElementById("account-profile-form"); form.elements.namedItem("name").value = profile.name || profile.username; form.elements.namedItem("email").value = profile.email || ""; document.getElementById("account-username").value = profile.username; document.getElementById("account-avatar-name").textContent = profile.name || profile.username; document.getElementById("account-avatar-source").textContent = "浏览器直连 Gravatar → Libravatar → 本地字母"; loadAccountAvatar(profile); renderSecurityList("account-totp", security.totp, "尚未配置 TOTP。", (device) => securityRecord( device.name, "Authenticator TOTP", [ actionButton("重命名", async () => { const name = window.prompt("TOTP 设备名称", device.name); if (name) await withBusy(`totp-${device.id}`, async () => { await mutateAccount(`/api/account/security/totp/${device.id}/rename`, {name}); await loadAccount(); }); }), actionButton("删除", async () => { if (window.confirm(`删除 TOTP 设备 ${device.name}?`)) await withBusy(`totp-${device.id}`, async () => { await mutateAccount(`/api/account/security/totp/${device.id}/delete`); await loadAccount(); }); }, true) ] )); renderSecurityList("account-passkeys", security.passkeys, "尚未注册 Passkey。", (device) => securityRecord( device.name, `${device.deviceType || "WebAuthn"} · 注册 ${formatTime(device.createdAt)} · ${device.aaguid || "AAGUID 未知"}`, [ actionButton("重命名", async () => { const name = window.prompt("Passkey 名称", device.name); if (name) await withBusy(`passkey-${device.id}`, async () => { await mutateAccount(`/api/account/security/passkeys/${device.id}/rename`, {name}); await loadAccount(); }); }), actionButton("删除", async () => { if (window.confirm(`删除 Passkey ${device.name}?请确认仍有密码或其他 Passkey 可用。`)) await withBusy(`passkey-${device.id}`, async () => { await mutateAccount(`/api/account/security/passkeys/${device.id}/delete`); await loadAccount(); }); }, true) ] )); renderSecurityList("account-sessions", security.sessions, "当前没有可管理的登录会话。", (session) => securityRecord( session.current ? "当前会话" : (session.userAgent || "登录会话"), `${session.ip || "IP 未知"} · 最近使用 ${formatTime(session.lastUsed)} · 到期 ${formatTime(session.expires)}`, [actionButton("注销", async () => { if (!window.confirm(session.current ? "注销当前会话?" : "注销此会话?")) return; await withBusy(`session-${session.id}`, async () => { await mutateAccount(`/api/account/security/sessions/${session.id}/delete`); if (session.current) window.location.reload(); else await loadAccount(); }); }, true)] )); } async function loadAccount() { state.identity = await request("/api/account/identity"); renderAccount(); } const featuredProviderGroups = [ {title: "SOTA", ids: ["openai", "anthropic"]}, {title: "Proxy", ids: ["openrouter", "rust.cat"]}, {title: "本地模型", ids: ["llama.cpp", "ollama"]}, {title: "其他常用", ids: ["deepseek", "minimax", "moonshot", "zai"]} ]; function fillProviderForm(provider) { const form = document.getElementById("account-provider-form"); const fields = form.elements; form.dataset.providerId = provider.id; fields.namedItem("id").value = provider.id; fields.namedItem("name").value = provider.name; fields.namedItem("api").value = provider.api; fields.namedItem("connectionType").value = provider.connection.type; fields.namedItem("baseUrl").value = provider.connection.baseUrl; fields.namedItem("authType").value = provider.auth.type; fields.namedItem("authHeader").value = provider.auth.header || ""; fields.namedItem("proxyType").value = provider.connection.proxy?.type || ""; fields.namedItem("proxyUrl").value = provider.connection.proxy?.url || ""; fields.namedItem("defaultModel").value = provider.defaultModel || ""; fields.namedItem("discoveryType").value = provider.discovery?.type || ""; fields.namedItem("discoveryUrl").value = provider.discovery?.url || ""; fields.namedItem("apiKey").value = ""; fields.namedItem("proxyUsername").value = ""; fields.namedItem("proxyPassword").value = ""; const hasCredential = provider.connection.type === "backend" && provider.credentials.some((credential) => credential.name === "default"); fields.namedItem("apiKey").placeholder = provider.connection.type === "frontend" ? "仅用于探测;Frontend Key 需在 Chat 中保存" : hasCredential ? "已保存;留空保持原凭据" : "可选"; syncProviderConnectionFields(); form.querySelector(".provider-advanced").open = true; form.scrollIntoView({behavior: "smooth", block: "start"}); fields.namedItem("name").focus({preventScroll: true}); } function providerGroup(title, providers) { const section = element("section", "provider-group"); section.append(element("h3", "provider-group-title", title)); const list = element("div", "admin-list"); providers.forEach((provider) => list.append(renderProvider(provider))); section.append(list); return section; } function providerHasCredential(provider, name = "default") { return provider.connection.type === "backend" && provider.credentials.some((credential) => credential.name === name); } function providerTestEndpoint(provider) { return provider.discovery.url; } function inferredProviderSettings(baseUrl, api = "", authType = "", authHeader = "") { const url = new URL(baseUrl); const hostname = url.hostname.toLowerCase(); const inferredApi = api || (hostname.includes("anthropic") ? "anthropic-messages" : hostname.includes("googleapis") ? "google-generative-ai" : "openai-completions"); const inferredAuth = authType || (inferredApi === "anthropic-messages" || inferredApi === "google-generative-ai" ? "header" : "bearer"); const inferredHeader = inferredAuth === "header" ? authHeader || (inferredApi === "google-generative-ai" ? "x-goog-api-key" : "x-api-key") : ""; const inferredId = hostname.replace(/^api\./, "").split(".")[0].replace(/[^a-z0-9._-]+/g, "-") || "custom-provider"; return {api: inferredApi, authType: inferredAuth, authHeader: inferredHeader, id: inferredId, name: hostname}; } function inferredProviderDiscovery(baseUrl, api, type = "", discoveryUrl = "") { const discoveryType = type || (api === "anthropic-messages" ? "anthropic-models-list" : api === "google-generative-ai" ? "google-models-list" : "openai-models-list"); const normalizedBaseUrl = baseUrl.replace(/\/+$/, ""); const url = discoveryUrl || (discoveryType === "anthropic-models-list" ? `${normalizedBaseUrl.endsWith("/v1") ? normalizedBaseUrl : `${normalizedBaseUrl}/v1`}/models?limit=200` : discoveryType === "google-models-list" ? `${normalizedBaseUrl}/models?pageSize=200` : `${normalizedBaseUrl}/models`); return {type: discoveryType, url}; } function providerAccountEndpoints(provider) { const baseUrl = provider.connection.baseUrl.replace(/\/+$/, ""); const url = new URL(baseUrl); if (url.hostname === "openrouter.ai") return ["https://openrouter.ai/api/v1/auth/key"]; if (url.hostname === "api.deepseek.com") return [`${url.origin}/user/balance`]; if (url.hostname === "api.moonshot.cn") return [`${baseUrl}/users/me/balance`]; if (url.hostname === "api.openai.com" || ["openai-completions", "openai-responses"].includes(provider.api)) return [`${baseUrl}/dashboard/billing/credit_grants`]; return []; } function providerHeaders(provider, secret) { const headers = new Headers({"Accept": "application/json"}); Object.entries(provider.headers || {}).forEach(([name, value]) => headers.set(name, value)); Object.entries(secret.provider?.headers || {}).forEach(([name, value]) => headers.set(name, value)); const apiKey = secret.provider?.apiKey || ""; if (provider.auth.type === "bearer" && apiKey) headers.set("Authorization", `Bearer ${apiKey}`); if (provider.auth.type === "header" && provider.auth.header && apiKey) headers.set(provider.auth.header, apiKey); if (provider.api === "anthropic-messages" && !headers.has("anthropic-version")) headers.set("anthropic-version", "2023-06-01"); return headers; } function modelResults(payload) { const source = Array.isArray(payload?.data) ? payload.data : Array.isArray(payload?.models) ? payload.models : []; return source.slice(0, 300).map((model) => { const rawId = model?.id || model?.name || model?.model; if (typeof rawId !== "string" || !rawId.trim()) return null; const id = rawId.replace(/^models\//, ""); const pricing = Object.fromEntries(Object.entries(model).filter(([key]) => /price|pricing|cost|rate|token/i.test(key))); return {id, name: model.displayName || model.name?.replace?.(/^models\//, "") || id, ...(Object.keys(pricing).length ? {pricing} : {})}; }).filter(Boolean); } function providerAccountMetadata(payload) { if (!payload || typeof payload !== "object" || Array.isArray(payload)) return null; const entries = Object.entries(payload).filter(([key]) => /balance|credit|quota|usage|limit|billing|currency/i.test(key)); return entries.length ? Object.fromEntries(entries) : null; } function providerRateLimits(response) { const entries = [...response.headers.entries()].filter(([name]) => /rate.?limit|retry-after|quota/i.test(name)); return entries.length ? Object.fromEntries(entries) : null; } function renderProviderTestResult(result) { const target = document.getElementById("account-provider-test-result"); target.replaceChildren(); const heading = element("div", "provider-test-heading"); heading.append( element("strong", "", `连接成功 · HTTP ${result.status} · ${result.latencyMs}ms`), element("code", "", result.endpoint) ); target.append(heading); const models = Array.isArray(result.models) ? result.models : []; const modelSection = element("section", "provider-test-section"); modelSection.append(element("h4", "", `发现模型(${result.modelCount ?? models.length})`)); if (!models.length) { modelSection.append(element("p", "admin-help", "Provider 未返回可识别的模型列表。")); } else { const modelRow = (model) => { const row = element("div", "provider-test-model"); const identity = element("div"); identity.append(element("strong", "", model.name || model.id), element("code", "", model.id)); if (model.pricing) identity.append(element("small", "", `费率:${JSON.stringify(model.pricing)}`)); row.append(identity, actionButton("设为默认", () => { document.getElementById("account-provider-form").elements.namedItem("defaultModel").value = model.id; })); return row; }; const displayedModels = models.slice(0, 100); const initialModels = displayedModels.slice(0, window.matchMedia("(max-width: 680px)").matches ? 5 : 12); const list = element("div", "provider-test-models"); initialModels.forEach((model) => list.append(modelRow(model))); modelSection.append(list); if (displayedModels.length > initialModels.length) { const more = element("details", "provider-test-more"); more.append(element("summary", "admin-action", `展开其余 ${displayedModels.length - initialModels.length} 个模型`)); const moreList = element("div", "provider-test-models"); displayedModels.slice(initialModels.length).forEach((model) => moreList.append(modelRow(model))); more.append(moreList); modelSection.append(more); } if (models.length > 100) modelSection.append(element("p", "admin-help", `仅展示前 100 个模型;共发现 ${models.length} 个。`)); } target.append(modelSection); const metadata = [ ["账户 / 余额", result.account], ["账户探测", result.accountProbe], ["限流信息", result.rateLimits] ].filter((entry) => entry[1]); if (metadata.length) { const details = element("section", "provider-test-section"); details.append(element("h4", "", "账户与费率信息")); metadata.forEach(([label, value]) => { const block = element("div", "provider-test-metadata"); block.append(element("strong", "", label), element("pre", "", JSON.stringify(value, null, 2))); details.append(block); }); target.append(details); } target.className = "provider-test-result success"; } function renderProviderTestError(error) { const target = document.getElementById("account-provider-test-result"); target.className = "provider-test-result error-state"; target.textContent = `测试失败:${error.message}`; } function frontendProviderFetch(provider, secret) { if (!provider.connection.proxy) return fetch; if (provider.connection.proxy.type !== "relay") throw new Error(`不支持的 Frontend Proxy:${provider.connection.proxy.type}`); return (url, options = {}) => fetch(provider.connection.proxy.url, { method: "POST", headers: { "Content-Type": "application/json", ...(secret.proxy?.token ? {"Authorization": `Bearer ${secret.proxy.token}`} : {}) }, body: JSON.stringify({ url: String(url), method: options.method || "GET", headers: Object.fromEntries(new Headers(options.headers).entries()), body: typeof options.body === "string" ? options.body : null }), signal: options.signal }); } async function testProviderConnection(provider, credentialName = "default", secretOverride = null) { if (provider.connection.type === "backend") { return request("/api/account/provider-test", { method: "POST", body: JSON.stringify({providerId: provider.id, credentialName}) }); } const secret = secretOverride || {}; if (provider.auth.type !== "none" && !secretOverride) throw new Error("请在 Chat 中配置此 Frontend Provider 的本地 Key"); const endpoint = providerTestEndpoint(provider); const providerFetch = frontendProviderFetch(provider, secret); const headers = providerHeaders(provider, secret); const startedAt = performance.now(); const response = await providerFetch(endpoint, { method: "GET", headers, signal: AbortSignal.timeout(15000) }); const latencyMs = Math.max(0, Math.round(performance.now() - startedAt)); const text = await response.text(); if (!response.ok) throw new Error(text.slice(0, 1000) || `Provider HTTP ${response.status}`); let payload = {}; try { payload = text ? JSON.parse(text) : {}; } catch { payload = {preview: text.slice(0, 1000)}; } const models = modelResults(payload); let accountProbe = null; for (const accountEndpoint of providerAccountEndpoints(provider)) { try { const accountResponse = await providerFetch(accountEndpoint, {method: "GET", headers, signal: AbortSignal.timeout(5000)}); if (!accountResponse.ok) continue; accountProbe = {endpoint: accountEndpoint, status: accountResponse.status, data: await accountResponse.json()}; break; } catch { // Account metadata is optional and must not fail a successful model probe. } } return { ok: true, status: response.status, latencyMs, endpoint, modelCount: models.length, models, account: providerAccountMetadata(payload), accountProbe, rateLimits: providerRateLimits(response) }; } function renderProvider(provider) { const record = element("article", "admin-record"); const heading = element("div", "admin-record-heading"); const identity = element("div"); identity.append(element("h3", "", provider.name), element("code", "", provider.id)); const badges = element("div", "admin-component-status"); const credentialLabel = provider.connection.type === "frontend" ? provider.auth.type === "none" ? "无需 Key" : "由 Chat 管理 Key" : providerHasCredential(provider) ? "凭据已设置" : "无凭据"; badges.append( pill(provider.builtin ? "内置" : "Custom", provider.builtin ? "" : "monitor-up"), pill(provider.connection.type === "frontend" ? "Frontend" : "Backend"), pill(credentialLabel, provider.connection.type === "frontend" || providerHasCredential(provider) ? "monitor-up" : ""), pill(provider.api) ); heading.append(identity, badges); const proxy = provider.connection.proxy ? `${provider.connection.proxy.type} · ${provider.connection.proxy.url}` : "无"; const details = element("p", "admin-help", `${provider.connection.baseUrl} · Proxy ${proxy} · 默认模型 ${provider.defaultModel || "未设置"}`); const actions = element("div", "admin-actions"); actions.append(actionButton(provider.builtin ? "复制并配置" : "编辑 Provider 与凭据", () => fillProviderForm(provider))); actions.append(actionButton("测试连通性", () => withBusy(`provider-test-${provider.id}`, async () => { try { renderProviderTestResult(await testProviderConnection(provider)); document.getElementById("account-provider-test-result").scrollIntoView({behavior: "smooth", block: "nearest"}); } catch (error) { renderProviderTestError(error); throw error; } }))); if (provider.connection.type === "frontend") { actions.append(actionButton(provider.auth.type === "none" ? "在 Chat 中配置端点" : "在 Chat 中配置本地 Key(需 CORS / Relay)", () => { window.location.href = `/chat/?configure=${encodeURIComponent(provider.id)}`; })); } if (!provider.builtin) { actions.append(actionButton("删除 Custom", async () => { if (!window.confirm(`删除 Custom Provider ${provider.id}?`)) return; await withBusy(`provider-${provider.id}`, async () => { await request(`/api/account/vault/providers/${provider.id}/delete`, {method: "POST", body: "{}"}); await loadVault(); }); }, true)); } record.append(heading, details, actions); return record; } function renderCredential(credential) { const record = element("article", "admin-record"); const heading = element("div", "admin-record-heading"); const identity = element("div"); identity.append(element("h3", "", credential.providerId), element("code", "", credential.id)); const badges = element("div", "admin-component-status"); badges.append(pill(`sha256:${credential.fingerprint}`)); heading.append(identity, badges); const meta = element("p", "admin-help", `创建 ${formatTime(credential.createdAt)} · 更新 ${formatTime(credential.updatedAt)} · 最近读取 ${formatTime(credential.lastAccessedAt)}`); const actions = element("div", "admin-actions"); actions.append(actionButton("校验加密完整性", () => runCredentialAction(credential, "verify"))); actions.append(actionButton("编辑 Provider 与凭据", () => { const provider = state.providers.find((item) => item.id === credential.providerId); if (provider) fillProviderForm(provider); })); actions.append(actionButton("删除", async () => { if (window.confirm(`永久删除 ${credential.providerId} 的凭据?`)) await runCredentialAction(credential, "delete"); }, true)); record.append(heading, meta, actions); return record; } function renderVault() { const providers = document.getElementById("account-providers"); providers.replaceChildren(); const customProviders = state.providers.filter((provider) => !provider.builtin); if (customProviders.length) providers.append(providerGroup("我的 Custom Provider", customProviders)); const featuredIds = new Set(featuredProviderGroups.flatMap((group) => group.ids)); for (const group of featuredProviderGroups) { const entries = group.ids.map((id) => state.providers.find((provider) => provider.builtin && provider.id === id)).filter(Boolean); if (entries.length) providers.append(providerGroup(group.title, entries)); } const moreProviders = state.providers.filter((provider) => provider.builtin && !featuredIds.has(provider.id)); if (moreProviders.length) { const more = element("details", "provider-more"); more.append(element("summary", "admin-action", `更多内置 Provider(${moreProviders.length})`)); const list = element("div", "admin-list provider-more-list"); moreProviders.forEach((provider) => list.append(renderProvider(provider))); more.append(list); providers.append(more); } const credentials = document.getElementById("account-credentials"); credentials.replaceChildren(); if (state.credentials.length) state.credentials.forEach((credential) => credentials.append(renderCredential(credential))); else credentials.append(element("div", "empty-state", "尚未保存 Backend Credential。")); const audit = document.getElementById("account-audit"); audit.replaceChildren(); if (!state.audit.length) audit.append(element("div", "empty-state", "暂无审计事件。")); for (const event of state.audit) { const row = element("div", "audit-row"); row.append(element("time", "", formatTime(event.createdAt)), element("code", "", event.actorUsername), element("strong", "", event.action), element("span", "", event.detail || event.targetId || "—"), pill(event.result, event.result === "success" ? "monitor-up" : "monitor-down")); audit.append(row); } } async function loadVault() { try { const [providers, credentials, audit] = await Promise.all([ request("/api/account/vault/providers"), request("/api/account/vault/credentials"), request("/api/account/vault/audit") ]); state.providers = providers.providers || []; state.credentials = credentials.credentials || []; state.audit = audit.events || []; renderVault(); } catch (error) { document.getElementById("account-providers").replaceChildren(element("div", "error-state", `Key Vault 暂时不可用:${error.message}`)); } } function buildSecret(apiKey, proxyUsername, proxyPassword, connectionType = "backend") { return { provider: {apiKey}, ...(proxyUsername || proxyPassword ? { proxy: connectionType === "frontend" ? {token: proxyPassword} : {username: proxyUsername, password: proxyPassword} } : {}) }; } function providerFormValue(form) { const data = new FormData(form); const connectionType = String(data.get("connectionType") || "backend"); const proxyType = String(data.get("proxyType") || ""); const baseUrl = String(data.get("baseUrl") || "").trim(); const inferred = inferredProviderSettings(baseUrl, String(data.get("api") || ""), String(data.get("authType") || ""), String(data.get("authHeader") || "")); const discovery = inferredProviderDiscovery(baseUrl, inferred.api, String(data.get("discoveryType") || ""), String(data.get("discoveryUrl") || "")); const providerId = String(data.get("id") || inferred.id).trim().toLowerCase(); const apiKey = String(data.get("apiKey") || ""); const proxyUsername = proxyType ? String(data.get("proxyUsername") || "") : ""; const proxyPassword = proxyType ? String(data.get("proxyPassword") || "") : ""; return { providerId, connectionType, apiKey, proxyUsername, proxyPassword, hasSecretInput: Boolean(apiKey || proxyUsername || proxyPassword), secret: buildSecret(apiKey, proxyUsername, proxyPassword, connectionType), provider: { id: providerId, name: String(data.get("name") || inferred.name).trim(), api: inferred.api, connection: { type: connectionType, baseUrl, proxy: proxyType ? {type: proxyType, url: data.get("proxyUrl")} : null }, auth: { type: inferred.authType, ...(inferred.authType === "header" ? {header: inferred.authHeader} : {}) }, headers: {}, defaultModel: String(data.get("defaultModel") || "probe-model"), discovery, builtin: false, credentials: [], credentialState: connectionType === "frontend" ? "local" : "missing" } }; } function applyProviderDetection(result) { const form = document.getElementById("account-provider-form"); const fields = form.elements; const detected = result.detected || {}; if (!fields.namedItem("id").value) fields.namedItem("id").value = detected.id || ""; if (!fields.namedItem("name").value) fields.namedItem("name").value = detected.name || ""; if (!fields.namedItem("api").value) fields.namedItem("api").value = detected.api || ""; if (!fields.namedItem("authType").value) fields.namedItem("authType").value = detected.auth?.type || ""; if (!fields.namedItem("authHeader").value) fields.namedItem("authHeader").value = detected.auth?.header || ""; if (!fields.namedItem("discoveryType").value) fields.namedItem("discoveryType").value = detected.discovery?.type || ""; if (!fields.namedItem("discoveryUrl").value) fields.namedItem("discoveryUrl").value = detected.discovery?.url || ""; if (!fields.namedItem("defaultModel").value && result.models?.[0]?.id) fields.namedItem("defaultModel").value = result.models[0].id; syncProviderConnectionFields(); } document.getElementById("account-provider-test").addEventListener("click", async () => { const form = document.getElementById("account-provider-form"); const baseUrl = form.elements.namedItem("baseUrl"); if (!baseUrl.reportValidity()) return; const value = providerFormValue(form); await withBusy("provider-draft-test", async () => { try { const result = value.connectionType === "backend" ? await request("/api/account/provider-test", { method: "POST", body: JSON.stringify({provider: value.provider, secret: value.secret}) }) : await testProviderConnection(value.provider, "default", value.hasSecretInput ? value.secret : null); result.detected ||= { id: value.provider.id, name: value.provider.name, api: value.provider.api, auth: value.provider.auth, connection: value.provider.connection, discovery: value.provider.discovery }; applyProviderDetection(result); renderProviderTestResult(result); } catch (error) { renderProviderTestError(error); throw error; } }); }); async function runCredentialAction(credential, action, body = {}) { await withBusy(`credential-${credential.id}`, async () => { const payload = await request(`/api/account/vault/credentials/${credential.id}/${action}`, {method: "POST", body: JSON.stringify(body)}); if (action === "verify") window.alert(payload.verified ? "密文完整。" : "密文校验失败,请立即替换或删除。"); await loadVault(); }); } document.getElementById("account-refresh").addEventListener("click", loadVault); document.getElementById("account-provider-form").addEventListener("submit", async (event) => { event.preventDefault(); const form = event.currentTarget; const value = providerFormValue(form); const { providerId, connectionType, apiKey, proxyUsername, proxyPassword, hasSecretInput, provider: payload } = value; await withBusy("provider-save", async () => { await request("/api/account/vault/providers", {method: "POST", body: JSON.stringify(payload)}); if (hasSecretInput && connectionType === "backend") { const existing = state.credentials.find((credential) => credential.providerId === providerId && credential.name === "default") || state.credentials.find((credential) => credential.providerId === providerId); if (existing) { await request(`/api/account/vault/credentials/${existing.id}/replace`, { method: "POST", body: JSON.stringify({secret: buildSecret(apiKey, proxyUsername, proxyPassword, connectionType)}) }); } else { await request("/api/account/vault/credentials", { method: "POST", body: JSON.stringify({providerId, name: "default", secret: buildSecret(apiKey, proxyUsername, proxyPassword, connectionType)}) }); } } else if (connectionType === "frontend") { window.location.href = `/chat/?configure=${encodeURIComponent(providerId)}`; return; } await loadVault(); form.reset(); form.querySelector(".provider-advanced").open = false; syncProviderConnectionFields(); delete form.dataset.providerId; }); }); function syncProviderConnectionFields() { const form = document.getElementById("account-provider-form"); const fields = form.elements; const frontend = fields.namedItem("connectionType").value === "frontend"; const proxyType = fields.namedItem("proxyType"); for (const option of proxyType.options) { option.hidden = Boolean(option.value) && (frontend ? option.value !== "relay" : option.value === "relay"); } if (proxyType.selectedOptions[0]?.hidden) proxyType.value = ""; const proxyConfigured = Boolean(proxyType.value); fields.namedItem("proxyUrl").required = proxyConfigured; fields.namedItem("proxyUrl").disabled = !proxyConfigured; fields.namedItem("proxyUsername").disabled = !proxyConfigured || frontend; const headerAuth = fields.namedItem("authType").value === "header"; fields.namedItem("authHeader").disabled = !headerAuth; fields.namedItem("authHeader").required = headerAuth; fields.namedItem("proxyPassword").disabled = !proxyConfigured; fields.namedItem("proxyPassword").placeholder = frontend ? "Relay Token(可选)" : "Proxy 密码(可选)"; } const providerForm = document.getElementById("account-provider-form"); providerForm.elements.namedItem("connectionType").addEventListener("change", syncProviderConnectionFields); providerForm.elements.namedItem("proxyType").addEventListener("change", syncProviderConnectionFields); providerForm.elements.namedItem("authType").addEventListener("change", syncProviderConnectionFields); syncProviderConnectionFields(); document.getElementById("account-security-refresh").addEventListener("click", () => withBusy("account-refresh", loadAccount)); document.getElementById("account-profile-form").addEventListener("submit", async (event) => { event.preventDefault(); const data = new FormData(event.currentTarget); await withBusy("profile", async () => { await mutateAccount("/api/account/profile", {name: data.get("name"), email: data.get("email")}); await loadAccount(); }); }); document.getElementById("account-avatar-refresh").addEventListener("click", () => { loadAccountAvatar(state.identity.profile, true); }); document.getElementById("account-password").addEventListener("click", () => withBusy("password", async () => { const payload = await mutateAccount("/api/account/security/password"); window.prompt("15 分钟内有效的密码设置链接", payload.result.link); })); document.getElementById("account-add-totp").addEventListener("click", () => window.location.assign(state.identity.security.totpSetupUrl)); document.getElementById("account-add-passkey").addEventListener("click", () => window.location.assign(state.identity.security.passkeySetupUrl)); document.getElementById("account-logout-all").addEventListener("click", async () => { if (!window.confirm("注销全部 Authentik 会话?当前页面也会退出。")) return; await withBusy("sessions-all", async () => { await mutateAccount("/api/account/security/sessions/all"); window.location.reload(); }); }); async function initialize() { try { await loadSession(); await Promise.all([loadAccount(), loadVault()]); } catch (error) { document.getElementById("account-session").textContent = `会话不可用:${error.message}`; } } document.getElementById("account-avatar").src = avatarPlaceholder({name: "U", username: "user"}); initialize();