feat: rebuild xiteng.site homelab platform

This commit is contained in:
2026-08-12 09:48:25 +08:00
parent 557b0eca33
commit 5b84988789
128 changed files with 14979 additions and 292 deletions
+789
View File
@@ -0,0 +1,789 @@
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 = `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();