1466 lines
65 KiB
JavaScript
1466 lines
65 KiB
JavaScript
window.__ModuleLoader__.load({
|
||
id: "@axiaohungry/dsh-llm-workbuddy",
|
||
factory: (require) => {
|
||
const ROUTE = "/dsh-llm-workbuddy/auth";
|
||
const MARKER = "data-workbuddy-auth-switch";
|
||
const AUTH_STATE_EVENT = "dsh-llm-workbuddy:auth-state";
|
||
const PENDING_MARKER = "data-workbuddy-new-session-selector";
|
||
const WORKBUDDY_PROVIDER_PATTERN = /(?:^|-)(?:work-?buddy|code-?buddy)(?:-|$)/;
|
||
const React = require("react");
|
||
const { createElement, useEffect, useRef, useState } = React;
|
||
|
||
function isWorkBuddyProvider(value) {
|
||
const normalized = String(value ?? "")
|
||
.trim()
|
||
.toLowerCase()
|
||
.replace(/[^a-z0-9]+/g, "-")
|
||
.replace(/^-+|-+$/g, "");
|
||
return normalized.length > 0 && WORKBUDDY_PROVIDER_PATTERN.test(normalized);
|
||
}
|
||
|
||
function isModLensWorkBuddyProvider(value) {
|
||
const normalized = String(value ?? "")
|
||
.trim()
|
||
.toLowerCase()
|
||
.replace(/[^a-z0-9]+/g, "-")
|
||
.replace(/^-+|-+$/g, "");
|
||
return /^modlens-(?:work-?buddy|code-?buddy)(?:-|$)/.test(normalized);
|
||
}
|
||
|
||
function button(text) {
|
||
const element = document.createElement("button");
|
||
element.type = "button";
|
||
element.textContent = text;
|
||
Object.assign(element.style, {
|
||
minHeight: "44px",
|
||
padding: "0 12px",
|
||
border: "1px solid var(--dsw-border-subtle, #d0d5dd)",
|
||
borderRadius: "8px",
|
||
background: "var(--dsw-surface-subtle, transparent)",
|
||
color: "inherit",
|
||
cursor: "pointer",
|
||
whiteSpace: "nowrap",
|
||
boxSizing: "border-box",
|
||
font: "inherit",
|
||
fontSize: "13px",
|
||
fontWeight: "500",
|
||
transition: "background-color 120ms ease, border-color 120ms ease, opacity 120ms ease",
|
||
});
|
||
return element;
|
||
}
|
||
|
||
function accountPicker() {
|
||
const element = document.createElement("select");
|
||
element.setAttribute("aria-label", "WorkBuddy 令牌账号");
|
||
Object.assign(element.style, {
|
||
minHeight: "44px",
|
||
minWidth: "180px",
|
||
maxWidth: "260px",
|
||
flex: "1 1 220px",
|
||
boxSizing: "border-box",
|
||
padding: "0 10px",
|
||
border: "1px solid var(--dsw-border-subtle, #d0d5dd)",
|
||
borderRadius: "8px",
|
||
background: "var(--dsw-surface-subtle, transparent)",
|
||
color: "inherit",
|
||
font: "inherit",
|
||
fontSize: "13px",
|
||
});
|
||
return element;
|
||
}
|
||
|
||
function textInput(type, placeholder, ariaLabel) {
|
||
const element = document.createElement("input");
|
||
element.type = type;
|
||
element.placeholder = placeholder;
|
||
element.setAttribute("aria-label", ariaLabel);
|
||
element.autocomplete = type === "password" ? "new-password" : "off";
|
||
Object.assign(element.style, {
|
||
minHeight: "44px",
|
||
minWidth: "0",
|
||
flex: "1 1 200px",
|
||
boxSizing: "border-box",
|
||
padding: "0 12px",
|
||
border: "1px solid var(--dsw-border-subtle, #d0d5dd)",
|
||
borderRadius: "8px",
|
||
background: "var(--dsw-surface-subtle, transparent)",
|
||
color: "inherit",
|
||
font: "inherit",
|
||
fontSize: "13px",
|
||
});
|
||
return element;
|
||
}
|
||
|
||
function fieldLabel(text) {
|
||
const element = document.createElement("span");
|
||
element.textContent = text;
|
||
Object.assign(element.style, {
|
||
flex: "0 0 auto",
|
||
minWidth: "92px",
|
||
fontSize: "13px",
|
||
lineHeight: "20px",
|
||
color: "var(--dsw-text-secondary, #667085)",
|
||
});
|
||
return element;
|
||
}
|
||
|
||
function row() {
|
||
const element = document.createElement("div");
|
||
Object.assign(element.style, {
|
||
display: "flex",
|
||
alignItems: "center",
|
||
gap: "8px",
|
||
flexWrap: "wrap",
|
||
width: "100%",
|
||
});
|
||
return element;
|
||
}
|
||
|
||
function section() {
|
||
const element = document.createElement("div");
|
||
Object.assign(element.style, {
|
||
display: "flex",
|
||
flexDirection: "column",
|
||
gap: "8px",
|
||
width: "100%",
|
||
boxSizing: "border-box",
|
||
padding: "10px 12px",
|
||
border: "1px solid var(--dsw-border-subtle, #d0d5dd)",
|
||
borderRadius: "10px",
|
||
background: "var(--dsw-surface-subtle, rgba(0, 0, 0, 0.02))",
|
||
});
|
||
return element;
|
||
}
|
||
|
||
function setVisible(element, visible, display = "flex") {
|
||
element.hidden = !visible;
|
||
element.style.setProperty("display", visible ? display : "none", "important");
|
||
}
|
||
|
||
function isWorkBuddy(input) {
|
||
const editor = input.parentElement?.parentElement;
|
||
if (!editor) return false;
|
||
if (isWorkBuddyProvider(editor.textContent)) return true;
|
||
const provider = editor.parentElement?.querySelector('select[aria-label="提供方"]')?.value;
|
||
return isWorkBuddyProvider(provider);
|
||
}
|
||
|
||
function accountText(account) {
|
||
const label = account?.accountName || account?.label || account?.account?.displayName || account?.account?.email || account?.account?.userId || "未命名账号";
|
||
const userId = account?.userId || account?.account?.userId;
|
||
return userId && userId !== label ? `${label} · ${String(userId).slice(0, 8)}` : label;
|
||
}
|
||
|
||
function accountLabel(account) {
|
||
return account?.accountName || account?.label || account?.account?.displayName || account?.account?.email || account?.account?.userId || "未命名账号";
|
||
}
|
||
|
||
function apiKeyText(key) {
|
||
const label = key?.label || (key?.kind === "environment" ? `环境变量 ${key.ref}` : "DSH 保存的 API Key");
|
||
const suffix = key?.masked ? ` · ${key.masked}` : "";
|
||
return key?.configured === false ? `${label}(不可用)` : `${label}${suffix}`;
|
||
}
|
||
|
||
function apiKeyLabel(key) {
|
||
return key?.label || (key?.kind === "environment" ? `环境变量 ${key.ref}` : "DSH 保存的 API Key");
|
||
}
|
||
|
||
function formatCredits(value) {
|
||
const number = Number(value);
|
||
return Number.isFinite(number) ? number.toLocaleString("zh-CN", { maximumFractionDigits: 2 }) : "—";
|
||
}
|
||
|
||
async function authRequest(path, body, sessionId) {
|
||
const options = {
|
||
method: body === undefined ? "GET" : "POST",
|
||
cache: "no-store",
|
||
};
|
||
if (body !== undefined) {
|
||
options.headers = { "content-type": "application/json" };
|
||
options.body = JSON.stringify({ ...body, ...(sessionId && body.sessionId === undefined ? { sessionId: String(sessionId) } : {}) });
|
||
}
|
||
const query = sessionId && body === undefined ? `?sessionId=${encodeURIComponent(String(sessionId))}` : "";
|
||
const response = await fetch(`${ROUTE}/${path}${query}`, options);
|
||
const text = await response.text();
|
||
let result = {};
|
||
try {
|
||
result = text ? JSON.parse(text) : {};
|
||
} catch {
|
||
result = {};
|
||
}
|
||
if (!response.ok || result.ok === false) {
|
||
const error = new Error(result.message || `请求失败(${response.status})`);
|
||
error.status = response.status;
|
||
throw error;
|
||
}
|
||
return result;
|
||
}
|
||
|
||
function notifyAuthState() {
|
||
if (typeof window !== "undefined") window.dispatchEvent(new Event(AUTH_STATE_EVENT));
|
||
if (typeof document !== "undefined") {
|
||
for (const panel of document.querySelectorAll(`[${PENDING_MARKER}]`)) {
|
||
delete panel.dataset.loaded;
|
||
mountPendingSelector();
|
||
}
|
||
}
|
||
}
|
||
|
||
function selectedProvider(selection) {
|
||
return selection?.next?.provider ?? selection?.lastUsed?.provider ?? selection?.provider;
|
||
}
|
||
|
||
function pendingCredentialOptions(status, mode) {
|
||
return mode === "token"
|
||
? (Array.isArray(status.accounts) ? status.accounts : []).map((account) => ({ id: account.id, label: accountText(account) }))
|
||
: (Array.isArray(status.apiKeys) ? status.apiKeys : []).map((key) => ({ id: key.id, label: apiKeyText(key), disabled: key.configured === false }));
|
||
}
|
||
|
||
function createPendingSelector() {
|
||
const panel = document.createElement("div");
|
||
panel.setAttribute(PENDING_MARKER, "");
|
||
panel.setAttribute("role", "group");
|
||
panel.setAttribute("aria-label", "WorkBuddy 新会话凭证");
|
||
Object.assign(panel.style, {
|
||
boxSizing: "border-box",
|
||
display: "flex",
|
||
alignItems: "center",
|
||
justifyContent: "flex-end",
|
||
flexWrap: "wrap",
|
||
gap: "6px",
|
||
width: "100%",
|
||
maxWidth: "var(--dsh-composer-card-max-width, 720px)",
|
||
margin: "0 auto 8px",
|
||
padding: "0 8px",
|
||
color: "var(--dsw-text-tertiary, #98a2b3)",
|
||
fontSize: "12px",
|
||
lineHeight: "20px",
|
||
});
|
||
const label = document.createElement("span");
|
||
label.textContent = "新会话默认凭证";
|
||
const mode = document.createElement("select");
|
||
mode.setAttribute("aria-label", "新会话认证模式");
|
||
const credential = document.createElement("select");
|
||
credential.setAttribute("aria-label", "新会话 WorkBuddy 凭证");
|
||
for (const element of [mode, credential]) Object.assign(element.style, {
|
||
boxSizing: "border-box",
|
||
minWidth: "0",
|
||
maxWidth: "min(240px, 100%)",
|
||
height: "28px",
|
||
padding: "0 6px",
|
||
border: "1px solid var(--dsw-border-subtle, #d0d5dd)",
|
||
borderRadius: "7px",
|
||
background: "var(--dsw-surface-subtle, transparent)",
|
||
color: "inherit",
|
||
font: "inherit",
|
||
fontSize: "12px",
|
||
});
|
||
const hint = document.createElement("span");
|
||
hint.textContent = "发送时自动绑定所选凭证";
|
||
hint.style.color = "var(--dsw-text-tertiary, #98a2b3)";
|
||
panel.append(label, mode, credential, hint);
|
||
panel._workbuddyMode = mode;
|
||
panel._workbuddyCredential = credential;
|
||
panel._workbuddyHint = hint;
|
||
const apply = async () => {
|
||
if (panel.dataset.busy === "1") return;
|
||
const value = credential.value;
|
||
if (!value || credential.selectedOptions[0]?.disabled) return;
|
||
panel.dataset.busy = "1";
|
||
mode.disabled = true;
|
||
credential.disabled = true;
|
||
hint.textContent = "正在保存新会话默认凭证…";
|
||
try {
|
||
const next = mode.value === "token"
|
||
? await authRequest("token", { accountId: value })
|
||
: await authRequest("api-key", { keyId: value });
|
||
renderPendingSelector(panel, next);
|
||
notifyAuthState();
|
||
} catch (error) {
|
||
hint.textContent = error instanceof Error ? error.message : "保存新会话凭证失败";
|
||
hint.style.color = "var(--dsw-text-danger, #c62828)";
|
||
} finally {
|
||
delete panel.dataset.busy;
|
||
mode.disabled = false;
|
||
credential.disabled = false;
|
||
}
|
||
};
|
||
mode.addEventListener("change", () => {
|
||
renderPendingOptions(panel, panel._workbuddyStatus, mode.value);
|
||
void apply();
|
||
});
|
||
credential.addEventListener("change", () => void apply());
|
||
return panel;
|
||
}
|
||
|
||
function renderPendingOptions(panel, status, preferredMode) {
|
||
if (!status) return;
|
||
const tokenOptions = pendingCredentialOptions(status, "token");
|
||
const apiOptions = pendingCredentialOptions(status, "api-key");
|
||
const tokenAvailable = tokenOptions.length > 0;
|
||
const apiAvailable = apiOptions.some((option) => !option.disabled);
|
||
if (!status.routingEnabled || (!tokenAvailable && !apiAvailable)) {
|
||
panel.hidden = true;
|
||
panel.style.setProperty("display", "none", "important");
|
||
return;
|
||
}
|
||
panel.hidden = false;
|
||
panel.style.setProperty("display", "flex", "important");
|
||
const suggestedMode = status.suggestedBinding?.mode;
|
||
const modeAvailable = (value) => value === "token" ? tokenAvailable : value === "api-key" && apiAvailable;
|
||
const mode = modeAvailable(preferredMode)
|
||
? preferredMode
|
||
: modeAvailable(suggestedMode)
|
||
? suggestedMode
|
||
: status.mode === "token" && tokenAvailable ? "token" : "api-key";
|
||
const modeSelect = panel._workbuddyMode;
|
||
const credentialSelect = panel._workbuddyCredential;
|
||
modeSelect.replaceChildren();
|
||
for (const [value, label, disabled] of [["token", "令牌", !tokenAvailable], ["api-key", "API Key", !apiAvailable]]) {
|
||
const option = document.createElement("option");
|
||
option.value = value;
|
||
option.textContent = label;
|
||
option.disabled = disabled;
|
||
modeSelect.append(option);
|
||
}
|
||
modeSelect.value = mode;
|
||
const options = mode === "token" ? tokenOptions : apiOptions;
|
||
credentialSelect.replaceChildren(...options.map((entry) => {
|
||
const option = document.createElement("option");
|
||
option.value = entry.id;
|
||
option.textContent = entry.label;
|
||
option.disabled = entry.disabled === true;
|
||
return option;
|
||
}));
|
||
const activeId = mode === "token"
|
||
? status.suggestedAccountId ?? status.activeAccountId
|
||
: status.suggestedApiKeyId ?? status.activeApiKeyId;
|
||
if (activeId && options.some((entry) => entry.id === activeId && !entry.disabled)) credentialSelect.value = activeId;
|
||
panel._workbuddyHint.textContent = "发送时自动绑定所选凭证";
|
||
panel._workbuddyHint.style.color = "var(--dsw-text-tertiary, #98a2b3)";
|
||
}
|
||
|
||
function renderPendingSelector(panel, status) {
|
||
panel._workbuddyStatus = status;
|
||
renderPendingOptions(panel, status, panel._workbuddyMode.value);
|
||
}
|
||
|
||
function mountPendingSelector() {
|
||
if (typeof document === "undefined") return;
|
||
// [自用定制 2026-10-10] 移除「新会话默认凭证」选择器:先摘掉已挂载的面板,再直接返回。
|
||
for (const panel of document.querySelectorAll(`[${PENDING_MARKER}]`)) panel.remove();
|
||
return;
|
||
const seats = Array.from(document.querySelectorAll("[data-composer-seat]"));
|
||
const seat = seats.find((candidate) => candidate.closest('[data-phase="hero"]'));
|
||
const panels = Array.from(document.querySelectorAll(`[${PENDING_MARKER}]`));
|
||
if (!seat) {
|
||
for (const panel of panels) panel.remove();
|
||
return;
|
||
}
|
||
for (const panel of panels) {
|
||
if (panel.parentElement !== seat) panel.remove();
|
||
}
|
||
const panel = seat.querySelector(`[${PENDING_MARKER}]`) ?? createPendingSelector();
|
||
if (!panel.parentElement) seat.append(panel);
|
||
if (panel.dataset.loading === "1" || panel.dataset.loaded === "1") return;
|
||
panel.dataset.loading = "1";
|
||
authRequest("status")
|
||
.then((status) => {
|
||
panel.dataset.loaded = "1";
|
||
renderPendingSelector(panel, status);
|
||
})
|
||
.catch(() => {
|
||
panel.hidden = true;
|
||
panel.style.setProperty("display", "none", "important");
|
||
})
|
||
.finally(() => {
|
||
delete panel.dataset.loading;
|
||
});
|
||
}
|
||
|
||
function WorkBuddyCreditsDock({ useProjection, sessionId }) {
|
||
let selection;
|
||
try {
|
||
selection = typeof useProjection === "function" ? useProjection("modelSelection") : undefined;
|
||
} catch {
|
||
selection = undefined;
|
||
}
|
||
const provider = selectedProvider(selection);
|
||
const selected = isWorkBuddyProvider(provider);
|
||
const modlens = isModLensWorkBuddyProvider(provider);
|
||
const [state, setState] = useState(null);
|
||
|
||
useEffect(() => {
|
||
let disposed = false;
|
||
let requestId = 0;
|
||
const load = async () => {
|
||
const currentRequestId = ++requestId;
|
||
if (!selected) {
|
||
setState(null);
|
||
return;
|
||
}
|
||
setState({ mode: "token", creditLoading: true, credits: undefined, todayUsage: null, creditError: null, todayUsageError: null });
|
||
let status;
|
||
try {
|
||
status = await authRequest("status", undefined, sessionId);
|
||
} catch {
|
||
if (!disposed && currentRequestId === requestId) setState(null);
|
||
return;
|
||
}
|
||
if (disposed || currentRequestId !== requestId) return;
|
||
// [自用定制 2026-10-10] 原来在 API Key 模式下直接跳过积分查询;现在继续查。
|
||
if (status.mode === "token" && !status.activeAccountId) {
|
||
setState({ ...status, creditLoading: false });
|
||
return;
|
||
}
|
||
try {
|
||
const result = await authRequest("credits", { accountId: status.activeAccountId }, sessionId);
|
||
if (!disposed && currentRequestId === requestId) setState({ ...status, ...result, creditLoading: false });
|
||
} catch (error) {
|
||
if (!disposed && currentRequestId === requestId) {
|
||
const message = error instanceof Error ? error.message : "查询 WorkBuddy 积分失败";
|
||
setState({
|
||
...status,
|
||
credits: null,
|
||
creditLoading: false,
|
||
creditError: message,
|
||
todayUsage: null,
|
||
todayUsageError: "查询 WorkBuddy 今日请求量失败",
|
||
});
|
||
}
|
||
}
|
||
};
|
||
load();
|
||
const onAuthState = () => load();
|
||
window.addEventListener(AUTH_STATE_EVENT, onAuthState);
|
||
const timer = window.setInterval(load, 60_000);
|
||
return () => {
|
||
disposed = true;
|
||
window.removeEventListener(AUTH_STATE_EVENT, onAuthState);
|
||
window.clearInterval(timer);
|
||
};
|
||
}, [provider, selected, sessionId]);
|
||
|
||
// [自用定制 2026-10-10] A+ 方案:积分项自带悬浮卡片,不接管官方统计行。
|
||
const [cardOpen, setCardOpen] = useState(false);
|
||
const cardRootRef = useRef(null);
|
||
useEffect(() => {
|
||
if (!cardOpen || typeof document === "undefined") return undefined;
|
||
const onDocPointerDown = (event) => {
|
||
if (cardRootRef.current && !cardRootRef.current.contains(event.target)) setCardOpen(false);
|
||
};
|
||
const onKeyDown = (event) => {
|
||
if (event.key === "Escape") setCardOpen(false);
|
||
};
|
||
document.addEventListener("pointerdown", onDocPointerDown);
|
||
document.addEventListener("keydown", onKeyDown);
|
||
return () => {
|
||
document.removeEventListener("pointerdown", onDocPointerDown);
|
||
document.removeEventListener("keydown", onKeyDown);
|
||
};
|
||
}, [cardOpen]);
|
||
|
||
const hasTokenAccount = state?.mode === "token" && Boolean(state.activeAccountId);
|
||
// [自用定制 2026-10-10] 放宽门槛:API Key 模式下同样渲染积分行。
|
||
const hasApiKey = state?.mode !== "token" && Boolean(state?.activeApiKeyId || state?.apiKeyConfigured);
|
||
if (!selected || !state || (!state.routingEnabled && !hasTokenAccount && !hasApiKey) || (!sessionId && !hasTokenAccount && !hasApiKey)) return null;
|
||
const creditsText = state.creditLoading
|
||
? "剩余积分:读取中…"
|
||
: state.unlimited
|
||
? "剩余积分:不限量"
|
||
: typeof state.credits === "number" && Number.isFinite(state.credits)
|
||
? `剩余积分:${formatCredits(state.credits)}`
|
||
: state.creditError
|
||
? "剩余积分:暂不可用"
|
||
: "剩余积分:—";
|
||
const today = state.todayUsage;
|
||
const usageText = today?.synced === true
|
||
? `今日请求:${Number.isFinite(Number(today.count)) ? Number(today.count) : 0} 次 · 用量 ${formatCredits(today.used)} 积分`
|
||
: state.creditLoading
|
||
? "今日请求:读取中…"
|
||
: state.todayUsageError
|
||
? "今日请求:暂不可用"
|
||
: "今日请求:—";
|
||
const modlensHint = modlens ? createElement("span", {
|
||
"data-workbuddy-modlens-hint": true,
|
||
title: "ModLens 会先处理会话中的历史图片,首次响应可能较慢;若超时、无响应或返回 429,请检查视觉引擎登录、网络和额度。纯文本任务可切换 WorkBuddy 直连。",
|
||
style: {
|
||
flex: "1 1 100%",
|
||
minWidth: 0,
|
||
color: "var(--dsw-text-tertiary, #98a2b3)",
|
||
fontSize: "11px",
|
||
lineHeight: "16px",
|
||
textAlign: "right",
|
||
},
|
||
}, "ModLens:历史图片会先处理,响应较慢或出现 429 时请检查视觉引擎与额度;纯文本可切换 WorkBuddy 直连") : null;
|
||
const activeAccount = Array.isArray(state.accounts) ? state.accounts.find((account) => account.id === state.activeAccountId) : undefined;
|
||
const credentialOptions = state.mode === "token"
|
||
? (Array.isArray(state.accounts) ? state.accounts.map((account) => createElement("option", { key: account.id, value: account.id }, accountText(account))) : [])
|
||
: (Array.isArray(state.apiKeys) ? state.apiKeys.map((key) => createElement("option", { key: key.id, value: key.id, disabled: key.configured === false }, apiKeyText(key))) : []);
|
||
const confirmBindingChange = (message) => typeof window === "undefined" || window.confirm(message);
|
||
const applyBindingResult = async (result) => {
|
||
setState(result);
|
||
if (result.mode === "token" && result.activeAccountId) {
|
||
try {
|
||
const credits = await authRequest("credits", { accountId: result.activeAccountId }, sessionId);
|
||
setState({ ...result, ...credits, creditLoading: false });
|
||
} catch (error) {
|
||
setState({
|
||
...result,
|
||
credits: null,
|
||
creditLoading: false,
|
||
creditError: error instanceof Error ? error.message : "查询 WorkBuddy 积分失败",
|
||
todayUsage: null,
|
||
todayUsageError: "查询 WorkBuddy 今日请求量失败",
|
||
});
|
||
}
|
||
}
|
||
};
|
||
const bindCredential = async (mode, value, failureMessage) => {
|
||
try {
|
||
const result = mode === "token"
|
||
? await authRequest("token", { accountId: value }, sessionId)
|
||
: await authRequest("api-key", { keyId: value }, sessionId);
|
||
await applyBindingResult(result);
|
||
} catch (error) {
|
||
setState({ ...state, creditError: error instanceof Error ? error.message : failureMessage });
|
||
}
|
||
};
|
||
const onSessionCredentialChange = async (event) => {
|
||
if (!state.routingEnabled || !sessionId) return;
|
||
const value = event.target.value;
|
||
if (!confirmBindingChange("切换后,当前已发出的请求继续使用原凭证;后续模型调用将使用新凭证。确定切换吗?")) {
|
||
event.target.value = state.mode === "token" ? state.activeAccountId ?? "" : state.activeApiKeyId ?? "";
|
||
return;
|
||
}
|
||
await bindCredential(state.mode, value, "切换会话凭证失败");
|
||
};
|
||
const onSessionModeChange = async (event) => {
|
||
if (!state.routingEnabled || !sessionId) return;
|
||
const mode = event.target.value;
|
||
const value = mode === "token" ? state.accounts?.[0]?.id : state.apiKeys?.find((key) => key.configured)?.id;
|
||
if (!value) return;
|
||
if (!confirmBindingChange("切换后,当前已发出的请求继续使用原凭证;后续模型调用将使用新凭证。确定切换认证模式吗?")) {
|
||
event.target.value = state.mode;
|
||
return;
|
||
}
|
||
await bindCredential(mode, value, "切换会话认证模式失败");
|
||
};
|
||
const onUnbind = async () => {
|
||
if (!state.routingEnabled || !sessionId || !state.sessionBinding) return;
|
||
if (!confirmBindingChange("解除后,当前会话将在下一次模型调用时绑定当前默认凭证。确定解除吗?")) return;
|
||
try {
|
||
await applyBindingResult(await authRequest("unbind", {}, sessionId));
|
||
} catch (error) {
|
||
setState({ ...state, creditError: error instanceof Error ? error.message : "解除会话凭证失败" });
|
||
}
|
||
};
|
||
const actionStyle = { border: "0", padding: "0 2px", background: "transparent", color: "inherit", font: "inherit", fontSize: "12px", cursor: "pointer", textDecoration: "underline" };
|
||
const sessionControls = state.routingEnabled && sessionId ? createElement(
|
||
"span",
|
||
{ "data-workbuddy-session-controls": true, style: { display: "inline-flex", alignItems: "center", justifyContent: "flex-end", flex: "1 1 100%", flexWrap: "wrap", gap: "4px", minWidth: 0, maxWidth: "100%" } },
|
||
createElement("span", { title: state.sessionBinding ? "当前会话已使用独立凭证" : "当前会话未绑定,下次发送将固化当前默认凭证" }, state.sessionBinding ? "已绑定" : "未绑定"),
|
||
createElement("select", { value: state.mode, onChange: onSessionModeChange, "aria-label": "当前会话认证模式", style: { border: "0", background: "transparent", color: "inherit", font: "inherit", fontSize: "12px", minWidth: 0, maxWidth: "110px" } },
|
||
createElement("option", { value: "token" }, "令牌"),
|
||
createElement("option", { value: "api-key" }, "API Key"),
|
||
),
|
||
createElement("select", { value: state.mode === "token" ? state.activeAccountId ?? "" : state.activeApiKeyId ?? "", onChange: onSessionCredentialChange, "aria-label": "当前会话凭证", style: { border: "0", background: "transparent", color: "inherit", font: "inherit", fontSize: "12px", minWidth: 0, maxWidth: "190px" } }, credentialOptions),
|
||
state.sessionBinding ? createElement("button", { type: "button", onClick: onUnbind, title: "解除当前绑定,下次发送时重新绑定默认凭证", style: actionStyle }, "解除绑定") : null,
|
||
createElement("span", { "aria-hidden": true, style: { opacity: 0.55, padding: "0 2px" } }, "·"),
|
||
) : null;
|
||
// [自用定制 2026-10-10] A+ 方案:药丸式积分项 + 点击弹出信息卡片;官方统计行保持原样。
|
||
const creditValue = state.creditLoading
|
||
? "读取中…"
|
||
: state.unlimited
|
||
? "不限量"
|
||
: typeof state.credits === "number" && Number.isFinite(state.credits)
|
||
? formatCredits(state.credits)
|
||
: state.creditError ? "暂不可用" : "—";
|
||
const formatStamp = (value) => {
|
||
const matched = String(value ?? "").match(/^(\d{4})-(\d{2})-(\d{2})\s+(\d{2}):(\d{2})/);
|
||
return matched ? `${matched[1]}-${matched[2]}-${matched[3]} ${matched[4]}:${matched[5]}` : String(value ?? "");
|
||
};
|
||
const cardRows = [
|
||
["剩余积分", creditValue],
|
||
state.planName ? ["套餐", state.planName] : null,
|
||
state.planCycleEnd ? ["下次重置", formatStamp(state.planCycleEnd)] : null,
|
||
].filter(Boolean);
|
||
return createElement(
|
||
"div",
|
||
{
|
||
ref: cardRootRef,
|
||
className: "dsh-workbuddy-credits",
|
||
"data-workbuddy-credits": true,
|
||
"data-workbuddy-session-aware": state.routingEnabled ? true : undefined,
|
||
role: "status",
|
||
"aria-live": "polite",
|
||
style: {
|
||
boxSizing: "border-box",
|
||
position: "relative",
|
||
minWidth: 0,
|
||
width: "100%",
|
||
maxWidth: "100%",
|
||
minHeight: "20px",
|
||
padding: "0",
|
||
display: "inline-flex",
|
||
flexWrap: "wrap",
|
||
gap: "2px 8px",
|
||
flex: "0 1 auto",
|
||
justifyContent: "flex-end",
|
||
alignItems: "center",
|
||
color: "var(--dsw-text-tertiary, #98a2b3)",
|
||
fontSize: "12px",
|
||
lineHeight: "18px",
|
||
fontVariantNumeric: "tabular-nums",
|
||
whiteSpace: "normal",
|
||
overflow: "visible",
|
||
textOverflow: "clip",
|
||
},
|
||
},
|
||
createElement(
|
||
"button",
|
||
{
|
||
type: "button",
|
||
"data-workbuddy-credit-pill": true,
|
||
"aria-expanded": cardOpen ? "true" : "false",
|
||
title: [state.creditError, state.todayUsageError].filter(Boolean).join(";") || "点击查看 WorkBuddy 用量",
|
||
onClick: (event) => {
|
||
event.stopPropagation();
|
||
setCardOpen((open) => !open);
|
||
},
|
||
style: {
|
||
border: "0",
|
||
margin: "0",
|
||
padding: "1px 4px",
|
||
background: "transparent",
|
||
color: "inherit",
|
||
font: "inherit",
|
||
fontSize: "12px",
|
||
lineHeight: "18px",
|
||
fontVariantNumeric: "tabular-nums",
|
||
whiteSpace: "nowrap",
|
||
cursor: "pointer",
|
||
borderRadius: "4px",
|
||
},
|
||
},
|
||
`剩余积分 ${creditValue}`,
|
||
),
|
||
cardOpen && cardRows.length > 0
|
||
? createElement(
|
||
"div",
|
||
{
|
||
"data-workbuddy-credit-card": true,
|
||
role: "dialog",
|
||
style: {
|
||
position: "absolute",
|
||
right: "0",
|
||
bottom: "calc(100% + 8px)",
|
||
zIndex: 80,
|
||
minWidth: "216px",
|
||
padding: "10px 12px",
|
||
borderRadius: "10px",
|
||
background: "var(--dsw-surface-primary, #ffffff)",
|
||
border: "1px solid var(--dsw-border-tertiary, rgba(0,0,0,.12))",
|
||
boxShadow: "0 10px 28px rgba(0,0,0,.16)",
|
||
color: "var(--dsw-text-primary, #1f2329)",
|
||
fontSize: "12px",
|
||
lineHeight: "20px",
|
||
textAlign: "left",
|
||
whiteSpace: "normal",
|
||
},
|
||
},
|
||
createElement("div", { style: { fontWeight: 600, marginBottom: "6px" } }, "WorkBuddy"),
|
||
...cardRows.map(([label, value]) => createElement(
|
||
"div",
|
||
{ style: { display: "flex", justifyContent: "space-between", gap: "16px" } },
|
||
createElement("span", { style: { color: "var(--dsw-text-tertiary, #98a2b3)" } }, label),
|
||
createElement("span", { style: { fontVariantNumeric: "tabular-nums" } }, value),
|
||
)),
|
||
)
|
||
: null,
|
||
modlensHint,
|
||
);
|
||
}
|
||
|
||
function installComposerDockLayout() {
|
||
if (typeof document === "undefined" || document.querySelector('style[data-plugin-css="dsh-llm-workbuddy-composer-dock"]')) return;
|
||
const style = document.createElement("style");
|
||
style.dataset.plugin = "@axiaohungry/dsh-llm-workbuddy";
|
||
style.dataset.pluginCss = "dsh-llm-workbuddy-composer-dock";
|
||
style.textContent = `
|
||
[data-slot="conversation.composer.dock"]:has(> [data-composer-stats]),
|
||
[data-slot="conversation.composer.dock"]:has(> [data-workbuddy-credits]) {
|
||
box-sizing: border-box;
|
||
width: 100%;
|
||
min-width: 0;
|
||
display: grid !important;
|
||
grid-template-columns: minmax(0, 1fr) auto minmax(0, 1fr);
|
||
align-items: center;
|
||
gap: 12px;
|
||
padding: 0 4px 4px;
|
||
overflow: visible;
|
||
}
|
||
[data-slot="conversation.composer.dock"] > [data-composer-stats] {
|
||
grid-column: 2;
|
||
width: auto !important;
|
||
max-width: 100%;
|
||
min-width: 0;
|
||
margin: 0 !important;
|
||
}
|
||
[data-slot="conversation.composer.dock"] > [data-workbuddy-credits] {
|
||
grid-column: 3;
|
||
justify-self: end;
|
||
width: 100% !important;
|
||
max-width: 100%;
|
||
min-width: 0;
|
||
margin: 0 !important;
|
||
}
|
||
[data-slot="conversation.composer.dock"] > [data-workbuddy-credits] > span {
|
||
min-width: 0;
|
||
}
|
||
[data-workbuddy-credit-pill] {
|
||
transition: background-color 0.12s ease;
|
||
}
|
||
[data-workbuddy-credit-pill]:hover,
|
||
[data-workbuddy-credit-pill]:focus-visible {
|
||
background: var(--dsw-surface-subtle, rgba(127, 127, 127, 0.14)) !important;
|
||
}
|
||
[data-workbuddy-credit-card] {
|
||
pointer-events: auto;
|
||
}
|
||
[data-slot="conversation.composer.dock"] > [data-workbuddy-credits] > [data-workbuddy-session-controls] {
|
||
flex-basis: 100%;
|
||
width: 100%;
|
||
}
|
||
@media (max-width: 760px) {
|
||
[data-slot="conversation.composer.dock"]:has(> [data-composer-stats]),
|
||
[data-slot="conversation.composer.dock"]:has(> [data-workbuddy-credits]) {
|
||
display: flex !important;
|
||
flex-wrap: wrap;
|
||
justify-content: center;
|
||
}
|
||
[data-slot="conversation.composer.dock"] > [data-composer-stats],
|
||
[data-slot="conversation.composer.dock"] > [data-workbuddy-credits] {
|
||
flex: 0 1 auto;
|
||
}
|
||
}
|
||
`;
|
||
document.head.appendChild(style);
|
||
}
|
||
|
||
function applyCreditStatus(stats, status, activeAccount) {
|
||
const visible = status.mode === "token" && Boolean(activeAccount);
|
||
setVisible(stats, visible);
|
||
if (!visible) return;
|
||
const credit = stats.querySelector('[data-workbuddy-stat="credits"]');
|
||
const usage = stats.querySelector('[data-workbuddy-stat="usage"]');
|
||
credit.title = status.creditError || "";
|
||
usage.title = status.todayUsageError || "";
|
||
if (status.creditLoading) credit.textContent = "剩余积分:读取中…";
|
||
else if (status.unlimited) credit.textContent = "剩余积分:不限量";
|
||
else if (typeof status.credits === "number" && Number.isFinite(status.credits)) credit.textContent = `剩余积分:${formatCredits(status.credits)}`;
|
||
else credit.textContent = status.creditError ? "剩余积分:暂不可用" : "剩余积分:—";
|
||
const today = status.todayUsage;
|
||
if (today && today.synced === true) {
|
||
usage.textContent = `今日请求:${Number.isFinite(Number(today.count)) ? Number(today.count) : 0} 次 · 用量 ${formatCredits(today.used)} 积分`;
|
||
} else {
|
||
usage.textContent = status.creditLoading ? "今日请求:读取中…" : status.todayUsageError ? "今日请求:暂不可用" : "今日请求:—";
|
||
}
|
||
}
|
||
|
||
function applyMode(input, keyButton, tokenButton, keySection, keySourceRow, keySelect, keyHint, newKeyInput, newKeyLabelInput, saveKeyButton, removeKeyButton, tokenSection, accountRow, accountSelect, accountName, addButton, removeButton, accountStats, routingRow, routingToggle, message, status) {
|
||
const token = status.mode === "token";
|
||
const apiKeys = Array.isArray(status.apiKeys) ? status.apiKeys : [];
|
||
const activeApiKeyId = status.activeApiKeyId ?? apiKeys[0]?.id;
|
||
const activeApiKey = apiKeys.find((key) => key.id === activeApiKeyId);
|
||
const accounts = Array.isArray(status.accounts) ? status.accounts : [];
|
||
const activeAccountId = status.activeAccountId ?? accounts[0]?.id;
|
||
input.disabled = token;
|
||
input.placeholder = token ? "当前使用 WorkBuddy 账号令牌" : "输入新的 API Key(保存到 DSH)";
|
||
newKeyInput.disabled = token;
|
||
newKeyLabelInput.disabled = token;
|
||
keyButton.setAttribute("aria-pressed", String(!token));
|
||
tokenButton.setAttribute("aria-pressed", String(token));
|
||
keyButton.style.background = !token ? "var(--dsw-accent-subtle, #eef4ff)" : "var(--dsw-surface-subtle, transparent)";
|
||
tokenButton.style.background = token ? "var(--dsw-accent-subtle, #eef4ff)" : "var(--dsw-surface-subtle, transparent)";
|
||
setVisible(keySection, !token);
|
||
// [自用定制 2026-10-10] 自用只保留一个 Key:隐藏「当前 API Key」多 Key 来源下拉
|
||
setVisible(keySourceRow, false);
|
||
keySelect.replaceChildren(...apiKeys.map((key) => {
|
||
const option = document.createElement("option");
|
||
option.value = key.id;
|
||
option.textContent = apiKeyText(key);
|
||
option.title = apiKeyLabel(key);
|
||
return option;
|
||
}));
|
||
if (activeApiKeyId) keySelect.value = activeApiKeyId;
|
||
// [自用定制 2026-10-10] 不回显 Key 内容,但明确区分「已配置可更换 / 未配置可添加」
|
||
keyHint.textContent = token
|
||
? ""
|
||
: activeApiKey
|
||
? "已配置。要更换:粘贴新的 API Key 后点「添加并使用」"
|
||
: "尚未配置。粘贴你的 WorkBuddy API Key,然后点「添加并使用」";
|
||
keyHint.title = "";
|
||
saveKeyButton.textContent = "添加并使用";
|
||
removeKeyButton.hidden = !activeApiKey || activeApiKey.kind !== "dsh";
|
||
setVisible(tokenSection, token);
|
||
tokenButton.textContent = token ? "令牌模式" : "令牌登录";
|
||
addButton.textContent = accounts.length ? "添加账号" : "登录 WorkBuddy";
|
||
setVisible(accountRow, token && accounts.length > 0);
|
||
accountSelect.replaceChildren(...accounts.map((account) => {
|
||
const option = document.createElement("option");
|
||
option.value = account.id;
|
||
option.textContent = accountText(account);
|
||
option.title = accountLabel(account);
|
||
return option;
|
||
}));
|
||
if (activeAccountId) accountSelect.value = activeAccountId;
|
||
const activeAccount = accounts.find((account) => account.id === activeAccountId);
|
||
accountName.textContent = activeAccount ? `当前账号:${accountText(activeAccount)}` : "";
|
||
accountName.title = activeAccount ? accountLabel(activeAccount) : "";
|
||
removeButton.hidden = !token || !activeAccount;
|
||
setVisible(accountStats, token && Boolean(activeAccount));
|
||
applyCreditStatus(accountStats, status, activeAccount);
|
||
routingToggle.checked = status.routingEnabled === true;
|
||
routingToggle.disabled = false;
|
||
routingRow.title = status.routingEnabled ? "已启用:每个会话可单独选择 WorkBuddy 账号或 API Key" : "关闭时使用原有的全局账号或 API Key";
|
||
message.textContent = token
|
||
? status.authenticated ? `令牌已登录:${activeAccount ? accountText(activeAccount) : "当前账号"}` : "令牌缺失,请重新登录"
|
||
: status.apiKeyConfigured ? `API Key 已就绪:${activeApiKey ? apiKeyText(activeApiKey) : "当前来源"}` : "未配置 API Key,请输入后保存";
|
||
message.style.color = token
|
||
? status.authenticated ? "var(--dsw-text-success, #2e7d32)" : "var(--dsw-text-danger, #c62828)"
|
||
: status.apiKeyConfigured ? "var(--dsw-text-success, #2e7d32)" : "var(--dsw-text-danger, #c62828)";
|
||
}
|
||
|
||
async function request(path, body) {
|
||
const options = { method: "POST" };
|
||
if (body !== undefined) {
|
||
options.headers = { "content-type": "application/json" };
|
||
options.body = JSON.stringify(body);
|
||
}
|
||
const response = await fetch(`${ROUTE}/${path}`, options);
|
||
const result = await response.json();
|
||
if (!response.ok || !result.ok) {
|
||
const error = new Error(result.message || `请求失败(${response.status})`);
|
||
error.status = response.status;
|
||
throw error;
|
||
}
|
||
return result;
|
||
}
|
||
|
||
function mount(input) {
|
||
const field = input.parentElement;
|
||
if (!field || field.querySelector(`[${MARKER}]`)) return;
|
||
field.setAttribute("data-workbuddy-auth-field", "");
|
||
input.dataset.workbuddyPlaceholder = input.placeholder;
|
||
for (const nativeNode of field.children) nativeNode.hidden = true;
|
||
Object.assign(field.style, { display: "block", width: "100%", boxSizing: "border-box" });
|
||
const controls = document.createElement("div");
|
||
controls.setAttribute(MARKER, "");
|
||
controls.setAttribute("role", "group");
|
||
controls.setAttribute("aria-label", "WorkBuddy 认证方式");
|
||
Object.assign(controls.style, {
|
||
display: "flex",
|
||
flexDirection: "column",
|
||
alignItems: "stretch",
|
||
gap: "8px",
|
||
width: "100%",
|
||
maxWidth: "100%",
|
||
paddingTop: "4px",
|
||
boxSizing: "border-box",
|
||
color: "var(--dsw-text-primary, inherit)",
|
||
});
|
||
const modeRow = row();
|
||
const keySection = section();
|
||
const keySourceRow = row();
|
||
const keyAddRow = row();
|
||
const tokenSection = section();
|
||
const accountRow = row();
|
||
const tokenActionRow = row();
|
||
const keyButton = button("API Key");
|
||
const tokenButton = button("令牌登录");
|
||
const keySelect = accountPicker();
|
||
keySelect.setAttribute("aria-label", "WorkBuddy API Key 来源");
|
||
const keySourceLabel = fieldLabel("当前 API Key");
|
||
const newKeyLabel = fieldLabel("新增 API Key");
|
||
const newKeyInput = textInput("password", "粘贴新的 API Key", "新增 WorkBuddy API Key");
|
||
const newKeyLabelInput = textInput("text", "名称(可选)", "API Key 名称");
|
||
const keyHint = document.createElement("span");
|
||
Object.assign(keyHint.style, {
|
||
display: "block",
|
||
width: "100%",
|
||
minWidth: "0",
|
||
overflowWrap: "anywhere",
|
||
whiteSpace: "normal",
|
||
fontSize: "12px",
|
||
lineHeight: "18px",
|
||
color: "var(--dsw-text-secondary, #667085)",
|
||
});
|
||
const saveKeyButton = button("添加并使用");
|
||
const removeKeyButton = button("删除");
|
||
const accountSelect = accountPicker();
|
||
const accountName = document.createElement("span");
|
||
Object.assign(accountName.style, {
|
||
flex: "1 1 220px",
|
||
minWidth: "0",
|
||
overflowWrap: "anywhere",
|
||
whiteSpace: "normal",
|
||
fontSize: "13px",
|
||
color: "var(--dsw-text-secondary, #667085)",
|
||
});
|
||
const addButton = button("令牌登录");
|
||
const removeButton = button("删除账号");
|
||
const accountLabel = fieldLabel("令牌账号");
|
||
const accountStats = document.createElement("div");
|
||
accountStats.setAttribute("role", "status");
|
||
accountStats.setAttribute("aria-live", "polite");
|
||
Object.assign(accountStats.style, {
|
||
display: "flex",
|
||
alignItems: "center",
|
||
gap: "8px",
|
||
flexWrap: "wrap",
|
||
width: "100%",
|
||
minHeight: "24px",
|
||
fontSize: "13px",
|
||
color: "var(--dsw-text-secondary, #667085)",
|
||
});
|
||
const tokenHint = document.createElement("span");
|
||
tokenHint.textContent = "令牌登录后可切换账号,并查看积分与今日请求量。";
|
||
Object.assign(tokenHint.style, {
|
||
display: "block",
|
||
width: "100%",
|
||
fontSize: "12px",
|
||
lineHeight: "18px",
|
||
color: "var(--dsw-text-secondary, #667085)",
|
||
});
|
||
const creditStat = document.createElement("span");
|
||
creditStat.dataset.workbuddyStat = "credits";
|
||
const usageStat = document.createElement("span");
|
||
usageStat.dataset.workbuddyStat = "usage";
|
||
accountStats.append(creditStat, usageStat);
|
||
const routingRow = row();
|
||
const routingToggle = document.createElement("input");
|
||
routingToggle.type = "checkbox";
|
||
routingToggle.setAttribute("aria-label", "启用会话级账号/API Key");
|
||
routingToggle.style.accentColor = "var(--dsw-accent, #2563eb)";
|
||
const routingLabel = document.createElement("label");
|
||
routingLabel.textContent = "会话级账号/API Key";
|
||
routingLabel.style.fontSize = "13px";
|
||
routingLabel.style.color = "var(--dsw-text-secondary, #667085)";
|
||
const routingHint = document.createElement("span");
|
||
routingHint.textContent = "关闭时保持原有全局认证方式";
|
||
routingHint.style.fontSize = "12px";
|
||
routingHint.style.color = "var(--dsw-text-tertiary, #98a2b3)";
|
||
routingRow.append(routingToggle, routingLabel, routingHint);
|
||
const message = document.createElement("span");
|
||
message.setAttribute("role", "status");
|
||
message.setAttribute("aria-live", "polite");
|
||
Object.assign(message.style, { fontSize: "12px", minHeight: "18px", lineHeight: "18px" });
|
||
modeRow.append(keyButton, tokenButton);
|
||
keySourceRow.append(keySourceLabel, keySelect, removeKeyButton);
|
||
keyAddRow.append(newKeyLabel, newKeyInput, newKeyLabelInput, saveKeyButton);
|
||
keySection.append(keySourceRow, keyAddRow, keyHint);
|
||
accountRow.append(accountLabel, accountSelect, accountName, removeButton);
|
||
tokenActionRow.append(addButton);
|
||
tokenSection.append(tokenHint, accountRow, accountStats, tokenActionRow);
|
||
controls.append(modeRow, routingRow, keySection, tokenSection, message);
|
||
field.append(controls);
|
||
let current = { mode: "api-key", authenticated: false, accounts: [], apiKeys: [], credits: undefined, creditLoading: false, creditError: null, todayUsage: null, todayUsageError: null };
|
||
const render = (status) => {
|
||
const previousMode = current.mode;
|
||
const previousAccountId = current.activeAccountId;
|
||
const previousApiKeyId = current.activeApiKeyId;
|
||
const previousAccounts = current.accounts;
|
||
const previousApiKeys = current.apiKeys;
|
||
const previousRouting = current.routingEnabled;
|
||
current = { ...current, ...status };
|
||
applyMode(input, keyButton, tokenButton, keySection, keySourceRow, keySelect, keyHint, newKeyInput, newKeyLabelInput, saveKeyButton, removeKeyButton, tokenSection, accountRow, accountSelect, accountName, addButton, removeButton, accountStats, routingRow, routingToggle, message, current);
|
||
if (previousMode !== current.mode || previousAccountId !== current.activeAccountId || previousApiKeyId !== current.activeApiKeyId || previousAccounts !== current.accounts || previousApiKeys !== current.apiKeys || previousRouting !== current.routingEnabled) notifyAuthState();
|
||
};
|
||
render(current);
|
||
let creditRequestId = 0;
|
||
const loadCredits = async (accountId) => {
|
||
const requestId = ++creditRequestId;
|
||
if (current.mode !== "token" || !accountId) {
|
||
render({ credits: undefined, creditLoading: false, creditError: null, todayUsage: null, todayUsageError: null });
|
||
return;
|
||
}
|
||
render({ credits: undefined, creditLoading: true, creditError: null, todayUsage: null, todayUsageError: null });
|
||
try {
|
||
const result = await request("credits", { accountId });
|
||
if (requestId !== creditRequestId || current.activeAccountId !== accountId) return;
|
||
render({ ...result, creditLoading: false });
|
||
} catch (error) {
|
||
if (requestId !== creditRequestId || current.activeAccountId !== accountId) return;
|
||
render({ credits: null, creditLoading: false, creditError: error instanceof Error ? error.message : "查询 WorkBuddy 积分失败", todayUsage: null, todayUsageError: "查询 WorkBuddy 今日请求量失败" });
|
||
}
|
||
};
|
||
|
||
const setBusy = (busy) => {
|
||
keyButton.disabled = busy;
|
||
tokenButton.disabled = busy;
|
||
keySelect.disabled = busy;
|
||
newKeyInput.disabled = busy || current.mode === "token";
|
||
newKeyLabelInput.disabled = busy || current.mode === "token";
|
||
saveKeyButton.disabled = busy;
|
||
removeKeyButton.disabled = busy;
|
||
accountSelect.disabled = busy;
|
||
addButton.disabled = busy;
|
||
removeButton.disabled = busy;
|
||
routingToggle.disabled = busy;
|
||
keyButton.style.cursor = busy ? "progress" : "pointer";
|
||
tokenButton.style.cursor = busy ? "progress" : "pointer";
|
||
saveKeyButton.style.cursor = busy ? "progress" : "pointer";
|
||
removeKeyButton.style.cursor = busy ? "progress" : "pointer";
|
||
addButton.style.cursor = busy ? "progress" : "pointer";
|
||
removeButton.style.cursor = busy ? "progress" : "pointer";
|
||
};
|
||
keyButton.addEventListener("click", async () => {
|
||
setBusy(true);
|
||
message.textContent = "正在切换…";
|
||
try {
|
||
render(await request("api-key", current.activeApiKeyId ? { keyId: current.activeApiKeyId } : undefined));
|
||
newKeyInput.focus();
|
||
} catch (error) {
|
||
message.textContent = error instanceof Error ? error.message : "切换失败";
|
||
message.style.color = "var(--dsw-text-danger, #c62828)";
|
||
} finally {
|
||
setBusy(false);
|
||
}
|
||
});
|
||
keySelect.addEventListener("change", async () => {
|
||
setBusy(true);
|
||
message.textContent = "正在切换 API Key…";
|
||
try {
|
||
render(await request("api-key", { keyId: keySelect.value }));
|
||
} catch (error) {
|
||
message.textContent = error instanceof Error ? error.message : "切换失败";
|
||
message.style.color = "var(--dsw-text-danger, #c62828)";
|
||
} finally {
|
||
setBusy(false);
|
||
}
|
||
});
|
||
saveKeyButton.addEventListener("click", async () => {
|
||
const value = newKeyInput.value.trim();
|
||
if (!value) {
|
||
message.textContent = "请输入新的 API Key";
|
||
message.style.color = "var(--dsw-text-danger, #c62828)";
|
||
newKeyInput.focus();
|
||
return;
|
||
}
|
||
setBusy(true);
|
||
saveKeyButton.textContent = "添加中…";
|
||
message.textContent = "正在保存 API Key…";
|
||
try {
|
||
const label = newKeyLabelInput.value.trim();
|
||
const next = await request("api-key/add", { key: value, ...(label ? { label } : {}) });
|
||
newKeyInput.value = "";
|
||
newKeyLabelInput.value = "";
|
||
render(next);
|
||
} catch (error) {
|
||
message.textContent = error instanceof Error ? error.message : "保存失败";
|
||
message.style.color = "var(--dsw-text-danger, #c62828)";
|
||
} finally {
|
||
setBusy(false);
|
||
}
|
||
});
|
||
newKeyInput.addEventListener("keydown", (event) => {
|
||
if (event.key === "Enter" && !saveKeyButton.disabled) {
|
||
event.preventDefault();
|
||
saveKeyButton.click();
|
||
}
|
||
});
|
||
removeKeyButton.addEventListener("click", async () => {
|
||
if (!current.activeApiKeyId || !window.confirm("确定删除当前 DSH 保存的 API Key 吗?")) return;
|
||
setBusy(true);
|
||
message.textContent = "正在删除 API Key…";
|
||
try {
|
||
render(await request("api-key/remove", { keyId: current.activeApiKeyId }));
|
||
} catch (error) {
|
||
message.textContent = error instanceof Error ? error.message : "删除失败";
|
||
message.style.color = "var(--dsw-text-danger, #c62828)";
|
||
} finally {
|
||
setBusy(false);
|
||
}
|
||
});
|
||
tokenButton.addEventListener("click", async () => {
|
||
setBusy(true);
|
||
tokenButton.textContent = "切换中…";
|
||
message.textContent = current.accounts?.length ? "正在切换令牌账号…" : "请在浏览器中完成 WorkBuddy 中国站登录";
|
||
try {
|
||
const next = await request(current.accounts?.length ? "token" : "login");
|
||
render(next);
|
||
await loadCredits(next.activeAccountId);
|
||
} catch (error) {
|
||
message.textContent = error instanceof Error ? error.message : "登录失败";
|
||
message.style.color = "var(--dsw-text-danger, #c62828)";
|
||
} finally {
|
||
setBusy(false);
|
||
}
|
||
});
|
||
addButton.addEventListener("click", async () => {
|
||
setBusy(true);
|
||
addButton.textContent = "等待浏览器登录…";
|
||
message.textContent = "请在浏览器中完成 WorkBuddy 中国站登录";
|
||
try {
|
||
const next = await request("login");
|
||
render(next);
|
||
await loadCredits(next.activeAccountId);
|
||
} catch (error) {
|
||
message.textContent = error instanceof Error ? error.message : "登录失败";
|
||
message.style.color = "var(--dsw-text-danger, #c62828)";
|
||
} finally {
|
||
setBusy(false);
|
||
}
|
||
});
|
||
accountSelect.addEventListener("change", async () => {
|
||
setBusy(true);
|
||
message.textContent = "正在切换令牌账号…";
|
||
try {
|
||
const next = await request("token", { accountId: accountSelect.value });
|
||
render(next);
|
||
await loadCredits(next.activeAccountId);
|
||
} catch (error) {
|
||
message.textContent = error instanceof Error ? error.message : "切换失败";
|
||
message.style.color = "var(--dsw-text-danger, #c62828)";
|
||
} finally {
|
||
setBusy(false);
|
||
}
|
||
});
|
||
removeButton.addEventListener("click", async () => {
|
||
if (!current.activeAccountId || !window.confirm("确定删除这个 WorkBuddy 登录账号吗?令牌将从 DSH 凭据中移除。")) return;
|
||
setBusy(true);
|
||
message.textContent = "正在删除账号…";
|
||
try {
|
||
const next = await request("remove", { accountId: current.activeAccountId });
|
||
render(next);
|
||
await loadCredits(next.activeAccountId);
|
||
} catch (error) {
|
||
message.textContent = error instanceof Error ? error.message : "删除失败";
|
||
message.style.color = "var(--dsw-text-danger, #c62828)";
|
||
} finally {
|
||
setBusy(false);
|
||
}
|
||
});
|
||
routingToggle.addEventListener("change", async () => {
|
||
setBusy(true);
|
||
message.textContent = routingToggle.checked ? "正在启用会话级认证…" : "正在关闭会话级认证…";
|
||
try {
|
||
render(await authRequest("routing", { enabled: routingToggle.checked }));
|
||
} catch (error) {
|
||
routingToggle.checked = !routingToggle.checked;
|
||
message.textContent = error instanceof Error ? error.message : "切换会话级认证失败";
|
||
message.style.color = "var(--dsw-text-danger, #c62828)";
|
||
} finally {
|
||
setBusy(false);
|
||
}
|
||
});
|
||
fetch(`${ROUTE}/status`, { cache: "no-store" })
|
||
.then((response) => response.json())
|
||
.then((status) => {
|
||
render(status);
|
||
return loadCredits(status.activeAccountId);
|
||
})
|
||
.catch(() => {
|
||
message.textContent = "认证状态读取失败";
|
||
message.style.color = "var(--dsw-text-danger, #c62828)";
|
||
});
|
||
}
|
||
|
||
function mountModelEditor(host) {
|
||
// [自用定制 2026-10-10] 「模型目录」改为可折叠,默认收起,避免列表过长。
|
||
let collapsed = true;
|
||
const toggle = document.createElement("button");
|
||
toggle.type = "button";
|
||
toggle.setAttribute("aria-expanded", "false");
|
||
Object.assign(toggle.style, {
|
||
display: "flex",
|
||
alignItems: "center",
|
||
gap: "6px",
|
||
width: "100%",
|
||
padding: "2px 0",
|
||
border: "0",
|
||
background: "transparent",
|
||
color: "inherit",
|
||
font: "inherit",
|
||
fontWeight: "600",
|
||
fontSize: "13px",
|
||
textAlign: "left",
|
||
cursor: "pointer",
|
||
});
|
||
const caret = document.createElement("span");
|
||
caret.setAttribute("aria-hidden", "true");
|
||
caret.style.width = "12px";
|
||
caret.textContent = "▸";
|
||
const heading = document.createElement("span");
|
||
heading.textContent = "模型目录";
|
||
toggle.append(caret, heading);
|
||
const actions = row();
|
||
const fetchButton = button("获取可用模型");
|
||
const addButton = button("添加模型");
|
||
const resetButton = button("恢复在线目录");
|
||
const saveButton = button("保存模型");
|
||
const list = document.createElement("div");
|
||
const candidates = document.createElement("div");
|
||
const message = document.createElement("span");
|
||
message.setAttribute("role", "status");
|
||
message.setAttribute("aria-live", "polite");
|
||
Object.assign(host.style, { display: "flex", flexDirection: "column", gap: "10px", width: "100%" });
|
||
Object.assign(list.style, { display: "flex", flexDirection: "column", gap: "8px" });
|
||
Object.assign(message.style, { fontSize: "12px", minHeight: "18px" });
|
||
actions.append(fetchButton, addButton, resetButton, saveButton);
|
||
const listWrap = document.createElement("div");
|
||
Object.assign(listWrap.style, { display: "none", flexDirection: "column", gap: "8px", width: "100%" });
|
||
listWrap.append(list, candidates);
|
||
toggle.addEventListener("click", () => {
|
||
collapsed = !collapsed;
|
||
caret.textContent = collapsed ? "▸" : "▾";
|
||
toggle.setAttribute("aria-expanded", collapsed ? "false" : "true");
|
||
listWrap.style.display = collapsed ? "none" : "flex";
|
||
});
|
||
host.append(toggle, actions, listWrap, message);
|
||
let models = null;
|
||
let revision;
|
||
let busy = false;
|
||
const invalidReasoning = new Set();
|
||
const say = (value, error = false) => {
|
||
message.textContent = value;
|
||
message.style.color = error ? "var(--dsw-text-danger, #c62828)" : "var(--dsw-text-secondary, #667085)";
|
||
};
|
||
const setBusy = (value) => {
|
||
busy = value;
|
||
for (const control of [fetchButton, addButton, resetButton, saveButton]) control.disabled = value;
|
||
};
|
||
const makeInput = (value, label, onChange, type = "text") => {
|
||
const input = textInput(type, label, label);
|
||
input.value = value ?? "";
|
||
input.title = label;
|
||
input.addEventListener("input", () => onChange(input.value));
|
||
return input;
|
||
};
|
||
const render = () => {
|
||
list.replaceChildren();
|
||
if (models === null) {
|
||
const hint = document.createElement("span");
|
||
hint.textContent = "使用当前账号的在线模型目录;获取后可选择模型进行自定义。";
|
||
list.append(hint);
|
||
return;
|
||
}
|
||
for (const [index, model] of models.entries()) {
|
||
const item = section();
|
||
const fields = row();
|
||
const id = makeInput(model.id, "模型 ID", (value) => { model.id = value.trim(); });
|
||
const name = makeInput(model.name, "显示名称", (value) => { model.name = value; });
|
||
const context = makeInput(model.contextWindow, "上下文窗口", (value) => {
|
||
if (value) model.contextWindow = Number(value);
|
||
else delete model.contextWindow;
|
||
}, "number");
|
||
const output = makeInput(model.maxTokens, "最大输出 Token", (value) => {
|
||
if (value) model.maxTokens = Number(value);
|
||
else delete model.maxTokens;
|
||
}, "number");
|
||
for (const input of [id, name, context, output]) {
|
||
input.style.flex = "1 1 140px";
|
||
fields.append(input);
|
||
}
|
||
const imageLabel = document.createElement("label");
|
||
const images = document.createElement("input");
|
||
images.type = "checkbox";
|
||
images.checked = model.input?.includes("image") === true;
|
||
images.addEventListener("change", () => { model.input = images.checked ? ["text", "image"] : ["text"]; });
|
||
imageLabel.append(images, " 图片输入");
|
||
const remove = button("删除");
|
||
remove.addEventListener("click", () => { invalidReasoning.delete(model); models.splice(index, 1); render(); });
|
||
fields.append(imageLabel, remove);
|
||
item.append(fields);
|
||
if (model.reasoningEfforts !== undefined) {
|
||
const thinking = document.createElement("details");
|
||
const summary = document.createElement("summary");
|
||
summary.textContent = "模型思考档位";
|
||
const editor = document.createElement("textarea");
|
||
editor.setAttribute("aria-label", `${model.id} 思考档位 JSON`);
|
||
editor.value = JSON.stringify(model.reasoningEfforts);
|
||
editor.style.width = "100%";
|
||
editor.addEventListener("change", () => {
|
||
try {
|
||
const value = JSON.parse(editor.value);
|
||
if (value !== false && (!value || typeof value !== "object" || Array.isArray(value))) throw new Error();
|
||
model.reasoningEfforts = value;
|
||
invalidReasoning.delete(model);
|
||
say("");
|
||
} catch { invalidReasoning.add(model); say("思考档位 JSON 格式无效", true); }
|
||
});
|
||
thinking.append(summary, editor);
|
||
item.append(thinking);
|
||
}
|
||
list.append(item);
|
||
}
|
||
};
|
||
fetch(`${ROUTE}/models`, { cache: "no-store" })
|
||
.then((response) => response.json())
|
||
.then((state) => {
|
||
if (!state.ok) throw new Error(state.message);
|
||
models = state.models?.map((model) => ({ ...model })) ?? null;
|
||
revision = state.revision;
|
||
render();
|
||
})
|
||
.catch((error) => say(error instanceof Error ? error.message : "读取模型失败", true));
|
||
addButton.addEventListener("click", () => {
|
||
models ??= [];
|
||
models.push({ id: "", name: "" });
|
||
render();
|
||
list.lastElementChild?.querySelector('input[aria-label="模型 ID"]')?.focus();
|
||
});
|
||
fetchButton.addEventListener("click", async () => {
|
||
if (busy) return;
|
||
setBusy(true);
|
||
say("正在获取模型…");
|
||
try {
|
||
const result = await request("models", { action: "discover" });
|
||
candidates.replaceChildren();
|
||
const choices = result.models.filter((model) => !models?.some((existing) => existing.id === model.id));
|
||
if (!choices.length) return say("没有新的可用模型");
|
||
const selected = new Set();
|
||
for (const model of choices) {
|
||
const label = document.createElement("label");
|
||
const checkbox = document.createElement("input");
|
||
checkbox.type = "checkbox";
|
||
checkbox.addEventListener("change", () => checkbox.checked ? selected.add(model.id) : selected.delete(model.id));
|
||
label.append(checkbox, ` ${model.name || model.id} (${model.id})`);
|
||
Object.assign(label.style, { display: "block", fontSize: "13px" });
|
||
candidates.append(label);
|
||
}
|
||
const addSelected = button("添加选中模型");
|
||
addSelected.addEventListener("click", () => {
|
||
models ??= [];
|
||
models.push(...choices.filter((model) => selected.has(model.id)).map((model) => ({ ...model })));
|
||
candidates.replaceChildren();
|
||
render();
|
||
});
|
||
candidates.append(addSelected);
|
||
say(`获取到 ${result.models.length} 个模型,请选择后保存`);
|
||
} catch (error) { say(error instanceof Error ? error.message : "获取模型失败", true); }
|
||
finally { setBusy(false); }
|
||
});
|
||
saveButton.addEventListener("click", async () => {
|
||
if (busy || models === null) return;
|
||
if (invalidReasoning.size) return say("请先修正模型思考档位 JSON", true);
|
||
if (models.some((model) => !model.id || !Number.isSafeInteger(model.contextWindow ?? 1)
|
||
|| !Number.isSafeInteger(model.maxTokens ?? 1))) return say("请检查模型 ID 和 Token 数值", true);
|
||
setBusy(true);
|
||
try {
|
||
const result = await request("models", { action: "save", models, revision });
|
||
revision = result.revision;
|
||
say("模型已保存");
|
||
} catch (error) { say(error instanceof Error ? error.message : "保存模型失败", true); }
|
||
finally { setBusy(false); }
|
||
});
|
||
resetButton.addEventListener("click", async () => {
|
||
if (busy || !window.confirm("恢复在线模型目录?自定义模型列表会被移除。")) return;
|
||
setBusy(true);
|
||
try {
|
||
const result = await request("models", { action: "reset", revision });
|
||
models = null;
|
||
revision = result.revision;
|
||
candidates.replaceChildren();
|
||
render();
|
||
say("已恢复在线模型目录");
|
||
} catch (error) { say(error instanceof Error ? error.message : "恢复失败", true); }
|
||
finally { setBusy(false); }
|
||
});
|
||
render();
|
||
}
|
||
|
||
function WorkBuddySettingsCard({ provider, embedded = false }) {
|
||
const root = useRef(null);
|
||
useEffect(() => {
|
||
if (provider?.provider !== "workbuddy-cn" || !root.current) return;
|
||
const host = root.current;
|
||
const field = section();
|
||
const input = document.createElement("input");
|
||
input.setAttribute("aria-label", "API 密钥");
|
||
field.append(input);
|
||
host.append(field);
|
||
mount(input);
|
||
const models = section();
|
||
host.append(models);
|
||
mountModelEditor(models);
|
||
const scope = embedded ? null : host.closest("li") ?? host.closest('[role="tabpanel"]') ?? host.closest('[class*="_addPanel"]');
|
||
const hidden = new Map();
|
||
const hideNative = () => {
|
||
if (!scope) return;
|
||
for (const node of scope.querySelectorAll('[class*="_editor"], [class*="_rowActions"]')) {
|
||
if (node.contains(host) || host.contains(node) || hidden.has(node)) continue;
|
||
hidden.set(node, {
|
||
hidden: node.hidden,
|
||
display: node.style.getPropertyValue("display"),
|
||
priority: node.style.getPropertyPriority("display"),
|
||
});
|
||
node.hidden = true;
|
||
node.style.setProperty("display", "none", "important");
|
||
}
|
||
};
|
||
const observer = scope ? new MutationObserver(hideNative) : undefined;
|
||
observer?.observe(scope, { childList: true, subtree: true });
|
||
hideNative();
|
||
return () => {
|
||
observer?.disconnect();
|
||
for (const [node, previous] of hidden) {
|
||
node.hidden = previous.hidden;
|
||
if (previous.display) node.style.setProperty("display", previous.display, previous.priority);
|
||
else node.style.removeProperty("display");
|
||
}
|
||
};
|
||
}, [provider?.provider]);
|
||
return provider?.provider === "workbuddy-cn"
|
||
? createElement("div", { ref: root, "data-workbuddy-settings-card": "" })
|
||
: null;
|
||
}
|
||
|
||
function WorkBuddyPluginDetails({ subject }) {
|
||
return subject?.kind === "bundle" && subject.pkg?.name === "@axiaohungry/dsh-llm-workbuddy"
|
||
? createElement(WorkBuddySettingsCard, { provider: { provider: "workbuddy-cn" }, embedded: true })
|
||
: null;
|
||
}
|
||
|
||
function enhance() {
|
||
for (const input of document.querySelectorAll('input[aria-label="API 密钥"]')) {
|
||
if (isWorkBuddy(input)) mount(input);
|
||
}
|
||
mountPendingSelector();
|
||
}
|
||
|
||
function apply(ctx) {
|
||
installComposerDockLayout();
|
||
ctx.slots.inject("settings.models.provider-card", () => ctx.slots.register({
|
||
name: "settings.models.provider-card",
|
||
key: "llm-workbuddy",
|
||
}, WorkBuddySettingsCard));
|
||
// [自用定制 2026-10-10] 按用户要求移除「插件详情页」里的内置面板。
|
||
// 需要还原:见同目录 client.js.bak-20261010
|
||
// ctx.slots.inject("plugins.detail.section", () => ctx.slots.register({
|
||
// name: "plugins.detail.section",
|
||
// id: "llm-workbuddy",
|
||
// }, WorkBuddyPluginDetails));
|
||
ctx.slots.inject("conversation.composer.dock", () => ctx.slots.register({
|
||
name: "conversation.composer.dock",
|
||
id: "workbuddy-credits",
|
||
order: 100,
|
||
label: "WorkBuddy 用量",
|
||
}, WorkBuddyCreditsDock));
|
||
ctx.effect(() => {
|
||
const observer = new MutationObserver(enhance);
|
||
observer.observe(document.body, { childList: true, subtree: true });
|
||
document.addEventListener("change", enhance, true);
|
||
enhance();
|
||
return () => {
|
||
observer.disconnect();
|
||
document.removeEventListener("change", enhance, true);
|
||
};
|
||
}, "llm-workbuddy: auth switch");
|
||
}
|
||
|
||
return { name: "dsh-llm-workbuddy-client", inject: ["slots"], apply };
|
||
},
|
||
});
|