790 lines
37 KiB
JavaScript
790 lines
37 KiB
JavaScript
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 = `<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 = `https://chat.xiteng.site/?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 = `https://chat.xiteng.site/?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();
|