Files

790 lines
37 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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("&", "&amp;").replaceAll("<", "&lt;").replaceAll(">", "&gt;");
const svg = `<svg xmlns="http://www.w3.org/2000/svg" width="256" height="256" viewBox="0 0 256 256"><rect width="256" height="256" rx="32" fill="hsl(${hue} 58% 48%)"/><text x="128" y="145" text-anchor="middle" font-family="system-ui,sans-serif" font-size="82" font-weight="800" fill="white">${escaped}</text></svg>`;
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();