mirror of
https://wget.la/https://github.com/leookun/cursor-byok
synced 2026-08-17 19:47:10 +08:00
- Updated README.md to provide clearer project information, features, and quick start instructions. - Added a function to mask the user's account identifier in CursorAccountCard.vue for enhanced privacy. - Adjusted localization files to remove outdated entries and improve clarity across multiple languages. - Refactored MainLayout.vue to streamline author information handling and improve user experience.
1423 lines
44 KiB
JavaScript
1423 lines
44 KiB
JavaScript
import { computed, reactive, watchSyncEffect } from "vue";
|
|
import { Events } from "@wailsio/runtime";
|
|
import dayjs from "dayjs";
|
|
import {
|
|
checkForUpdates,
|
|
getAppVersion,
|
|
getHomeMetricsSummary,
|
|
getModelAdapterTestResults,
|
|
installReadyUpdate,
|
|
getProxyState,
|
|
openConfigWindow as openConfig,
|
|
loadUserConfig,
|
|
openLogsDirectory,
|
|
openModelConfig,
|
|
saveUserConfig,
|
|
startProxyService,
|
|
stopProxyService,
|
|
testModelAdapter,
|
|
fetchModelAdapterModels,
|
|
} from "@/services/clientApi";
|
|
|
|
const APP_STATE_STORAGE_KEY = "cursor-client:runtime-state:v2";
|
|
const GENERIC_SERVICE_ERROR = "服务错误";
|
|
const SUPPORTED_MODEL_ADAPTER_TYPES = new Set(["openai", "anthropic"]);
|
|
const SUPPORTED_REASONING_EFFORTS = new Set(["low", "medium", "high", "xhigh", "max"]);
|
|
const SUPPORTED_ANTHROPIC_THINKING_EFFORTS = new Set(["low", "medium", "high", "xhigh", "max"]);
|
|
export const ANTHROPIC_THINKING_EFFORT_DEFAULT = "xhigh";
|
|
export const OPENAI_ENDPOINT_RESPONSES = "/v1/responses";
|
|
export const OPENAI_ENDPOINT_CHAT_COMPLETIONS = "/v1/chat/completions";
|
|
export const OPENAI_ENDPOINT_CUSTOM = "/custom";
|
|
export const OPENAI_EXTRA_PARAMS_DEFAULT_JSON = `{
|
|
"service_tier": "priority"
|
|
}`;
|
|
export const EXTRA_PARAMS_DEFAULT_JSON = `{
|
|
}`;
|
|
export const CUSTOM_HEADERS_DEFAULT_JSON = `{
|
|
}`;
|
|
const SUPPORTED_OPENAI_ENDPOINTS = new Set([OPENAI_ENDPOINT_RESPONSES, OPENAI_ENDPOINT_CHAT_COMPLETIONS, OPENAI_ENDPOINT_CUSTOM]);
|
|
const PROXY_STATE_EVENT = "proxy:state";
|
|
const USER_CONFIG_CHANGED_EVENT = "user-config:changed";
|
|
const UPDATE_STATE_EVENT = "update:state";
|
|
const UPDATE_PROGRESS_EVENT = "update:progress";
|
|
const UPDATE_READY_EVENT = "update:ready";
|
|
const UPDATE_ERROR_EVENT = "update:error";
|
|
const MODEL_ADAPTER_TEST_UPDATED_EVENT = "model-adapter-test:updated";
|
|
const SUPPORTED_MODEL_ADAPTER_TEST_STATUSES = new Set(["idle", "running", "success", "error"]);
|
|
const HOME_METRICS_MIN_LOADING_MS = 600;
|
|
|
|
function asString(value) {
|
|
if (typeof value === "string") {
|
|
return value.trim();
|
|
}
|
|
if (value instanceof String) {
|
|
return value.toString().trim();
|
|
}
|
|
if (typeof value === "number" || typeof value === "boolean") {
|
|
return String(value);
|
|
}
|
|
return "";
|
|
}
|
|
|
|
function asBoolean(value, fallback = false) {
|
|
if (typeof value === "boolean") {
|
|
return value;
|
|
}
|
|
if (typeof value === "number") {
|
|
return value !== 0;
|
|
}
|
|
const normalized = asString(value).toLowerCase();
|
|
if (!normalized) {
|
|
return fallback;
|
|
}
|
|
return normalized === "true" || normalized === "1" || normalized === "yes";
|
|
}
|
|
|
|
function asArray(value) {
|
|
return Array.isArray(value) ? value : [];
|
|
}
|
|
|
|
function asPositiveIntegerString(value) {
|
|
const text = asString(value);
|
|
if (!text) {
|
|
return "";
|
|
}
|
|
if (!/^\d+$/.test(text)) {
|
|
return "";
|
|
}
|
|
return Number(text) > 0 ? text : "";
|
|
}
|
|
|
|
function asPositiveInteger(value) {
|
|
const text = asPositiveIntegerString(value);
|
|
if (!text) {
|
|
return 0;
|
|
}
|
|
return Number(text);
|
|
}
|
|
|
|
function asNumber(value, fallback = 0) {
|
|
if (typeof value === "number" && Number.isFinite(value)) {
|
|
return value;
|
|
}
|
|
const text = asString(value);
|
|
if (!text) {
|
|
return fallback;
|
|
}
|
|
const parsed = Number(text);
|
|
return Number.isFinite(parsed) ? parsed : fallback;
|
|
}
|
|
|
|
function formatReleaseDate(value) {
|
|
const text = asString(value);
|
|
if (!text) {
|
|
return "未知";
|
|
}
|
|
const parsed = dayjs(text);
|
|
if (!parsed.isValid()) {
|
|
return text;
|
|
}
|
|
return parsed.format("YYYY-MM-DD HH:mm");
|
|
}
|
|
|
|
function normalizeBaseURL(value) {
|
|
const text = asString(value);
|
|
if (!text) {
|
|
return "";
|
|
}
|
|
try {
|
|
const parsed = new URL(text);
|
|
if (parsed.protocol !== "http:" && parsed.protocol !== "https:") {
|
|
return "";
|
|
}
|
|
parsed.protocol = parsed.protocol.toLowerCase();
|
|
parsed.hostname = parsed.hostname.toLowerCase();
|
|
const normalized = parsed.toString().replace(/\/+$/, "");
|
|
return normalized || parsed.toString();
|
|
} catch (_error) {
|
|
return text;
|
|
}
|
|
}
|
|
|
|
function buildModelAdapterIdentityKey(adapter) {
|
|
return [
|
|
normalizeBaseURL(adapter.baseURL),
|
|
asString(adapter.modelID),
|
|
asString(adapter.apiKey),
|
|
asString(adapter.displayName),
|
|
adapter.type === "openai" ? normalizeOpenAIEndpoint(adapter.openAIEndpoint) : "",
|
|
].join("\n");
|
|
}
|
|
|
|
function hashStringFNV32a(value) {
|
|
let hash = 0x811c9dc5;
|
|
for (let index = 0; index < value.length; index += 1) {
|
|
hash ^= value.charCodeAt(index);
|
|
hash = Math.imul(hash, 0x01000193) >>> 0;
|
|
}
|
|
return hash.toString(16).padStart(8, "0");
|
|
}
|
|
|
|
export function buildModelAdapterTestRequestHash(source) {
|
|
const adapter = normalizeModelAdapter(source);
|
|
return hashStringFNV32a([
|
|
asString(adapter.type),
|
|
normalizeBaseURL(adapter.baseURL),
|
|
asString(adapter.apiKey),
|
|
asString(adapter.modelID),
|
|
adapter.type === "openai" ? asString(adapter.reasoningEffort || "medium") : "",
|
|
adapter.type === "openai" ? normalizeOpenAIEndpoint(adapter.openAIEndpoint) : "",
|
|
adapter.type === "openai" ? String(Boolean(adapter.openAIExtraParamsEnabled)) : "false",
|
|
adapter.type === "openai" && adapter.openAIExtraParamsEnabled ? asString(adapter.openAIExtraParamsJSON) : "",
|
|
String(Boolean(adapter.customHeadersEnabled)),
|
|
adapter.customHeadersEnabled ? asString(adapter.customHeadersJSON) : "",
|
|
adapter.type === "anthropic" ? String(Boolean(adapter.anthropicExtraParamsEnabled)) : "false",
|
|
adapter.type === "anthropic" && adapter.anthropicExtraParamsEnabled ? asString(adapter.anthropicExtraParamsJSON) : "",
|
|
String(asPositiveInteger(adapter.contextWindowTokens)),
|
|
String(asPositiveInteger(adapter.maxCompletionTokens)),
|
|
String(asPositiveInteger(adapter.anthropicMaxTokens)),
|
|
adapter.type === "anthropic" ? asString(adapter.anthropicThinkingEffort || ANTHROPIC_THINKING_EFFORT_DEFAULT) : "",
|
|
].join("\n"));
|
|
}
|
|
|
|
export function formatDuration(value) {
|
|
const durationMS = Math.max(0, Math.round(asNumber(value)));
|
|
if (durationMS < 1000) {
|
|
return `${durationMS} ms`;
|
|
}
|
|
return `${(durationMS / 1000).toFixed(1)} s`;
|
|
}
|
|
|
|
function normalizeModelAdapterTestStatus(value) {
|
|
const text = asString(value).toLowerCase();
|
|
return SUPPORTED_MODEL_ADAPTER_TEST_STATUSES.has(text) ? text : "idle";
|
|
}
|
|
|
|
export function formatModelAdapterTestSummary(source) {
|
|
const result = source && typeof source === "object" ? source : {};
|
|
const status = normalizeModelAdapterTestStatus(result.status);
|
|
if (status === "running") {
|
|
return "测试中...";
|
|
}
|
|
if (status === "error") {
|
|
return asString(result.error) || "模型测试失败";
|
|
}
|
|
if (status !== "success") {
|
|
return "";
|
|
}
|
|
const roundedTPS = Math.max(0, Math.round(asNumber(result.tokensPerSecond)));
|
|
return `${roundedTPS} t/s | 首字 ${formatDuration(result.firstTextTokenMS)}`;
|
|
}
|
|
|
|
function normalizeModelAdapterTestResult(source) {
|
|
const raw = source && typeof source === "object" ? source : {};
|
|
const status = normalizeModelAdapterTestStatus(raw.status);
|
|
const normalized = {
|
|
adapterID: asString(raw.adapterID),
|
|
requestHash: asString(raw.requestHash),
|
|
status,
|
|
tokensPerSecond: Math.max(0, asNumber(raw.tokensPerSecond)),
|
|
firstTextTokenMS: Math.max(0, Math.round(asNumber(raw.firstTextTokenMS))),
|
|
totalDurationMS: Math.max(0, Math.round(asNumber(raw.totalDurationMS))),
|
|
outputTokens: Math.max(0, Math.round(asNumber(raw.outputTokens))),
|
|
tokensEstimated: asBoolean(raw.tokensEstimated),
|
|
summaryText: asString(raw.summaryText),
|
|
error: asString(raw.error),
|
|
rawResponse: asString(raw.rawResponse),
|
|
testedAt: asString(raw.testedAt),
|
|
};
|
|
if (status === "running" || status === "success") {
|
|
normalized.summaryText = formatModelAdapterTestSummary(normalized);
|
|
}
|
|
if (status === "error" && !normalized.summaryText) {
|
|
normalized.summaryText = normalized.error || "模型测试失败";
|
|
}
|
|
return normalized;
|
|
}
|
|
|
|
function normalizeModelAdapterTestResults(source) {
|
|
const raw = source && typeof source === "object" && !Array.isArray(source)
|
|
? source.results
|
|
: source;
|
|
return asArray(raw)
|
|
.map((item) => normalizeModelAdapterTestResult(item))
|
|
.filter((item) => item.adapterID);
|
|
}
|
|
|
|
export function createEmptyModelAdapter() {
|
|
return {
|
|
id: "",
|
|
sort: 0,
|
|
displayName: "",
|
|
type: "openai",
|
|
baseURL: "",
|
|
apiKey: "",
|
|
tooltipData: "备注",
|
|
modelID: "",
|
|
reasoningEffort: "medium",
|
|
openAIEndpoint: OPENAI_ENDPOINT_RESPONSES,
|
|
openAIExtraParamsEnabled: false,
|
|
openAIExtraParamsJSON: OPENAI_EXTRA_PARAMS_DEFAULT_JSON,
|
|
customHeadersEnabled: false,
|
|
customHeadersJSON: CUSTOM_HEADERS_DEFAULT_JSON,
|
|
anthropicExtraParamsEnabled: false,
|
|
anthropicExtraParamsJSON: EXTRA_PARAMS_DEFAULT_JSON,
|
|
contextWindowTokens: 0,
|
|
maxCompletionTokens: 0,
|
|
anthropicMaxTokens: 0,
|
|
anthropicThinkingEffort: ANTHROPIC_THINKING_EFFORT_DEFAULT,
|
|
thinkingBudgetTokens: 0,
|
|
};
|
|
}
|
|
|
|
// normalizeOpenAIEndpoint 归一化 endpoint 路径。
|
|
// 支持三个预设值:/v1/responses、/v1/chat/completions、/custom(自定义路径)。
|
|
// 选 /custom 时,用户需在接口地址栏填写完整请求 URL。
|
|
function normalizeOpenAIEndpoint(value) {
|
|
const text = asString(value).toLowerCase();
|
|
if (!text) {
|
|
return OPENAI_ENDPOINT_RESPONSES;
|
|
}
|
|
return SUPPORTED_OPENAI_ENDPOINTS.has(text) ? text : "";
|
|
}
|
|
|
|
function isValidOpenAIEndpoint(value) {
|
|
return normalizeOpenAIEndpoint(value) !== "";
|
|
}
|
|
|
|
function validateJSONObject(value, label) {
|
|
const text = asString(value);
|
|
if (!text) {
|
|
return `${label}不能为空`;
|
|
}
|
|
try {
|
|
const parsed = JSON.parse(text);
|
|
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
|
|
return `${label}必须是 JSON 对象`;
|
|
}
|
|
} catch (_error) {
|
|
return `${label}必须是合法 JSON 对象`;
|
|
}
|
|
return "";
|
|
}
|
|
|
|
function validateHeadersJSON(value) {
|
|
const objectError = validateJSONObject(value, "自定义请求头 JSON");
|
|
if (objectError) {
|
|
return objectError;
|
|
}
|
|
const parsed = JSON.parse(asString(value));
|
|
for (const [key, item] of Object.entries(parsed)) {
|
|
if (!asString(key)) {
|
|
return "自定义请求头名称不能为空";
|
|
}
|
|
if (typeof item !== "string") {
|
|
return `自定义请求头 ${key} 的值必须是字符串`;
|
|
}
|
|
}
|
|
return "";
|
|
}
|
|
|
|
function validateOpenAIExtraParamsJSON(value) {
|
|
return validateJSONObject(value, "额外参数 JSON");
|
|
}
|
|
|
|
function validateAnthropicExtraParamsJSON(value) {
|
|
return validateJSONObject(value, "Anthropic 额外参数 JSON");
|
|
}
|
|
|
|
export function normalizeModelAdapter(source) {
|
|
const raw = source && typeof source === "object" ? source : {};
|
|
const normalizedType = asString(raw.type).toLowerCase();
|
|
const normalizedReasoningEffort = asString(raw.reasoningEffort || raw.reasoning_effort).toLowerCase();
|
|
const normalizedAnthropicThinkingEffort = asString(
|
|
raw.anthropicThinkingEffort
|
|
?? raw.anthropic_thinking_effort
|
|
?? raw.outputConfigEffort
|
|
?? raw.output_config_effort,
|
|
).toLowerCase();
|
|
const normalizedOpenAIEndpoint = normalizeOpenAIEndpoint(
|
|
raw.openAIEndpoint ?? raw.openaiEndpoint ?? raw.open_ai_endpoint ?? raw.endpoint,
|
|
);
|
|
const openAIExtraParamsEnabled = normalizedType === "openai"
|
|
? asBoolean(raw.openAIExtraParamsEnabled ?? raw.openaiExtraParamsEnabled ?? raw.open_ai_extra_params_enabled)
|
|
: false;
|
|
const openAIExtraParamsJSON = normalizedType === "openai"
|
|
? asString(raw.openAIExtraParamsJSON ?? raw.openaiExtraParamsJSON ?? raw.open_ai_extra_params_json) || OPENAI_EXTRA_PARAMS_DEFAULT_JSON
|
|
: "";
|
|
const customHeadersEnabled = asBoolean(raw.customHeadersEnabled ?? raw.custom_headers_enabled);
|
|
const customHeadersJSON = asString(raw.customHeadersJSON ?? raw.custom_headers_json) || CUSTOM_HEADERS_DEFAULT_JSON;
|
|
const anthropicExtraParamsEnabled = normalizedType === "anthropic"
|
|
? asBoolean(raw.anthropicExtraParamsEnabled ?? raw.anthropic_extra_params_enabled)
|
|
: false;
|
|
const anthropicExtraParamsJSON = normalizedType === "anthropic"
|
|
? asString(raw.anthropicExtraParamsJSON ?? raw.anthropic_extra_params_json) || EXTRA_PARAMS_DEFAULT_JSON
|
|
: "";
|
|
return {
|
|
id: asString(raw.id),
|
|
sort: asPositiveInteger(raw.sort),
|
|
displayName: asString(raw.displayName || raw.name),
|
|
type: SUPPORTED_MODEL_ADAPTER_TYPES.has(normalizedType) ? normalizedType : "",
|
|
baseURL: normalizeBaseURL(raw.baseURL || raw.url),
|
|
apiKey: asString(raw.apiKey || raw.key),
|
|
tooltipData: asString(raw.tooltipData),
|
|
modelID: asString(raw.modelID),
|
|
reasoningEffort: SUPPORTED_REASONING_EFFORTS.has(normalizedReasoningEffort)
|
|
? normalizedReasoningEffort
|
|
: "medium",
|
|
openAIEndpoint: normalizedType === "openai" ? normalizedOpenAIEndpoint : "",
|
|
openAIExtraParamsEnabled,
|
|
openAIExtraParamsJSON,
|
|
customHeadersEnabled,
|
|
customHeadersJSON,
|
|
anthropicExtraParamsEnabled,
|
|
anthropicExtraParamsJSON,
|
|
contextWindowTokens: asPositiveInteger(
|
|
raw.contextWindowTokens ?? raw.context_window_tokens ?? raw.maxInputTokens ?? raw.max_input_tokens,
|
|
),
|
|
maxCompletionTokens: asPositiveInteger(
|
|
raw.maxCompletionTokens ?? raw.max_completion_tokens ?? raw.max_tokens ?? raw.max_token,
|
|
),
|
|
anthropicMaxTokens: asPositiveInteger(
|
|
raw.anthropicMaxTokens ?? raw.anthropic_max_tokens ?? raw.max_tokens,
|
|
),
|
|
anthropicThinkingEffort: normalizedType === "anthropic"
|
|
? (SUPPORTED_ANTHROPIC_THINKING_EFFORTS.has(normalizedAnthropicThinkingEffort)
|
|
? normalizedAnthropicThinkingEffort
|
|
: ANTHROPIC_THINKING_EFFORT_DEFAULT)
|
|
: "",
|
|
thinkingBudgetTokens: asPositiveInteger(
|
|
raw.thinkingBudgetTokens ?? raw.thinking_budget_tokens,
|
|
),
|
|
};
|
|
}
|
|
|
|
export function normalizeModelAdapters(source) {
|
|
return asArray(source)
|
|
.map((item, sourceIndex) => ({
|
|
adapter: normalizeModelAdapter(item),
|
|
sourceIndex,
|
|
}))
|
|
.sort((left, right) => {
|
|
const leftSort = left.adapter.sort;
|
|
const rightSort = right.adapter.sort;
|
|
if (leftSort <= 0 && rightSort <= 0) {
|
|
return left.sourceIndex - right.sourceIndex;
|
|
}
|
|
if (leftSort <= 0) {
|
|
return 1;
|
|
}
|
|
if (rightSort <= 0) {
|
|
return -1;
|
|
}
|
|
return leftSort - rightSort || left.sourceIndex - right.sourceIndex;
|
|
})
|
|
.map(({ adapter }, index) => ({
|
|
...adapter,
|
|
sort: index + 1,
|
|
}));
|
|
}
|
|
|
|
export function validateModelAdapters(source) {
|
|
const adapters = normalizeModelAdapters(source);
|
|
const seenIdentityKeys = new Set();
|
|
for (const [index, adapter] of adapters.entries()) {
|
|
const prefix = `模型 ${index + 1}`;
|
|
if (!SUPPORTED_MODEL_ADAPTER_TYPES.has(adapter.type)) {
|
|
return `${prefix} 的类型仅支持 OpenAI 或 Anthropic`;
|
|
}
|
|
if (!adapter.baseURL) {
|
|
return `${prefix} 的接口地址不能为空`;
|
|
}
|
|
if (!adapter.apiKey) {
|
|
return `${prefix} 的访问密钥不能为空`;
|
|
}
|
|
if (!adapter.displayName) {
|
|
return `${prefix} 的显示名称不能为空`;
|
|
}
|
|
if (!adapter.modelID) {
|
|
return `${prefix} 的模型标识不能为空`;
|
|
}
|
|
if (adapter.contextWindowTokens && (!Number.isInteger(adapter.contextWindowTokens) || adapter.contextWindowTokens <= 0)) {
|
|
return `${prefix} 的上下文窗口必须为正整数`;
|
|
}
|
|
if (adapter.type === "openai" && !SUPPORTED_REASONING_EFFORTS.has(adapter.reasoningEffort)) {
|
|
return `${prefix} 的推理强度仅支持 low、medium、high、xhigh、max`;
|
|
}
|
|
if (adapter.type === "anthropic" && adapter.anthropicMaxTokens && (!Number.isInteger(adapter.anthropicMaxTokens) || adapter.anthropicMaxTokens <= 0)) {
|
|
return `${prefix} 的最大输出 Token 必须为正整数`;
|
|
}
|
|
if (adapter.type === "anthropic" && !SUPPORTED_ANTHROPIC_THINKING_EFFORTS.has(adapter.anthropicThinkingEffort)) {
|
|
return `${prefix} 的 Anthropic 思考强度仅支持 low、medium、high、xhigh、max`;
|
|
}
|
|
if (adapter.type === "openai" && adapter.maxCompletionTokens && (!Number.isInteger(adapter.maxCompletionTokens) || adapter.maxCompletionTokens <= 0)) {
|
|
return `${prefix} 的最大输出 Token 必须为正整数`;
|
|
}
|
|
if (adapter.type === "openai" && !isValidOpenAIEndpoint(adapter.openAIEndpoint)) {
|
|
return `${prefix} 的 OpenAI 端点仅支持 /v1/responses、/v1/chat/completions 或以 / 开头的自定义路径`;
|
|
}
|
|
if (adapter.type === "openai" && adapter.openAIExtraParamsEnabled) {
|
|
const extraParamsError = validateOpenAIExtraParamsJSON(adapter.openAIExtraParamsJSON);
|
|
if (extraParamsError) {
|
|
return `${prefix} 的 ${extraParamsError}`;
|
|
}
|
|
}
|
|
if (adapter.type === "anthropic" && adapter.anthropicExtraParamsEnabled) {
|
|
const extraParamsError = validateAnthropicExtraParamsJSON(adapter.anthropicExtraParamsJSON);
|
|
if (extraParamsError) {
|
|
return `${prefix} 的 ${extraParamsError}`;
|
|
}
|
|
}
|
|
if (adapter.customHeadersEnabled) {
|
|
const customHeadersError = validateHeadersJSON(adapter.customHeadersJSON);
|
|
if (customHeadersError) {
|
|
return `${prefix} 的 ${customHeadersError}`;
|
|
}
|
|
}
|
|
if (!adapter.tooltipData) {
|
|
return `${prefix} 的悬停提示不能为空`;
|
|
}
|
|
if (adapter.thinkingBudgetTokens && (!Number.isInteger(adapter.thinkingBudgetTokens) || adapter.thinkingBudgetTokens <= 0)) {
|
|
return `${prefix} 的思考预算 Token 必须为正整数`;
|
|
}
|
|
const dedupeKey = buildModelAdapterIdentityKey(adapter);
|
|
if (seenIdentityKeys.has(dedupeKey)) {
|
|
return `模型渠道重复,请检查 url、modelID、apiKey、displayName、endpoint 组合`;
|
|
}
|
|
seenIdentityKeys.add(dedupeKey);
|
|
}
|
|
return "";
|
|
}
|
|
|
|
function canUseLocalStorage() {
|
|
return typeof window !== "undefined" && typeof window.localStorage !== "undefined";
|
|
}
|
|
|
|
function delay(ms) {
|
|
return new Promise((resolve) => {
|
|
window.setTimeout(resolve, Math.max(0, ms));
|
|
});
|
|
}
|
|
|
|
function createEmptyHomeMetrics() {
|
|
return {
|
|
turnsTotal: 0,
|
|
validTurnsTotal: 0,
|
|
invalidTurnsTotal: 0,
|
|
requestTokensTotal: 0,
|
|
promptTokensTotal: 0,
|
|
cacheReadTokens: 0,
|
|
cacheWriteTokens: 0,
|
|
cacheHitRate: null,
|
|
};
|
|
}
|
|
|
|
function loadCachedState() {
|
|
if (!canUseLocalStorage()) {
|
|
return {};
|
|
}
|
|
|
|
try {
|
|
const raw = window.localStorage.getItem(APP_STATE_STORAGE_KEY);
|
|
if (!raw) {
|
|
return {};
|
|
}
|
|
const parsed = JSON.parse(raw);
|
|
if (!parsed || typeof parsed !== "object") {
|
|
return {};
|
|
}
|
|
return parsed;
|
|
} catch (_error) {
|
|
return {};
|
|
}
|
|
}
|
|
|
|
function normalizeConfig(source) {
|
|
const raw = source && typeof source === "object" ? source : {};
|
|
const homeMetrics = raw.homeMetrics && typeof raw.homeMetrics === "object" ? raw.homeMetrics : {};
|
|
return {
|
|
log: asBoolean(raw.log),
|
|
providerStreamIdleTimeout: asPositiveInteger(raw.providerStreamIdleTimeout),
|
|
backendListenAddr: asString(raw.configBackendListenAddr) || asString(raw.backendListenAddr),
|
|
proxyListenAddr: asString(raw.configProxyListenAddr) || asString(raw.proxyListenAddr),
|
|
modelAdapters: normalizeModelAdapters(raw.modelAdapters),
|
|
homeMetrics: {
|
|
includeCacheWriteInHitRate: asBoolean(homeMetrics.includeCacheWriteInHitRate),
|
|
},
|
|
lastAgentModelHash: asString(raw.lastAgentModelHash),
|
|
};
|
|
}
|
|
|
|
function asNullableRate(value) {
|
|
if (typeof value === "number" && Number.isFinite(value)) {
|
|
return value;
|
|
}
|
|
return null;
|
|
}
|
|
|
|
function normalizeHomeMetrics(source) {
|
|
const raw = source && typeof source === "object" ? source : {};
|
|
const providerCallsTotal = asPositiveInteger(raw.providerCallsTotal ?? raw.turnsTotal);
|
|
return {
|
|
turnsTotal: providerCallsTotal,
|
|
validTurnsTotal: providerCallsTotal,
|
|
invalidTurnsTotal: 0,
|
|
requestTokensTotal: asPositiveInteger(raw.requestTokensTotal),
|
|
promptTokensTotal: asPositiveInteger(raw.promptTokensTotal),
|
|
cacheReadTokens: asPositiveInteger(raw.cacheReadTokens),
|
|
cacheWriteTokens: asPositiveInteger(raw.cacheWriteTokens),
|
|
cacheHitRate: asNullableRate(raw.cacheHitRate),
|
|
};
|
|
}
|
|
|
|
function applyHomeMetrics(raw) {
|
|
appState.homeMetrics = normalizeHomeMetrics(raw);
|
|
appState.homeMetricsError = "";
|
|
}
|
|
|
|
function buildConfigPayload(source = appState) {
|
|
const normalized = normalizeConfig(source);
|
|
return {
|
|
log: normalized.log,
|
|
providerStreamIdleTimeout: normalized.providerStreamIdleTimeout,
|
|
backendListenAddr: normalized.backendListenAddr,
|
|
proxyListenAddr: normalized.proxyListenAddr,
|
|
modelAdapters: normalized.modelAdapters.map(({ id, ...adapter }) => adapter),
|
|
homeMetrics: normalized.homeMetrics,
|
|
lastAgentModelHash: normalized.lastAgentModelHash,
|
|
};
|
|
}
|
|
|
|
function applyConfigToState(config, { modelAdaptersOnly = false } = {}) {
|
|
const normalized = normalizeConfig(config);
|
|
if (modelAdaptersOnly) {
|
|
appState.modelAdapters = normalized.modelAdapters;
|
|
return normalized;
|
|
}
|
|
appState.modelAdapters = normalized.modelAdapters;
|
|
appState.configBackendListenAddr = normalized.backendListenAddr;
|
|
appState.configProxyListenAddr = normalized.proxyListenAddr;
|
|
appState.includeCacheWriteInHitRate = normalized.homeMetrics.includeCacheWriteInHitRate;
|
|
return normalized;
|
|
}
|
|
|
|
async function loadPersistedUserConfig() {
|
|
return normalizeConfig(await loadUserConfig());
|
|
}
|
|
|
|
async function persistConfigPayload(config, { modelAdaptersOnly = false } = {}) {
|
|
const payload = buildConfigPayload(config);
|
|
const validationError = validateModelAdapters(payload.modelAdapters);
|
|
if (validationError) {
|
|
return {
|
|
ok: false,
|
|
error: validationError,
|
|
};
|
|
}
|
|
|
|
appState.configSaving = true;
|
|
try {
|
|
await saveUserConfig(payload);
|
|
const persisted = await loadPersistedUserConfig();
|
|
applyConfigToState(persisted, { modelAdaptersOnly });
|
|
return {
|
|
ok: true,
|
|
error: "",
|
|
};
|
|
} catch (error) {
|
|
return {
|
|
ok: false,
|
|
error: toUserError(error),
|
|
};
|
|
} finally {
|
|
appState.configSaving = false;
|
|
}
|
|
}
|
|
|
|
function applyProxyState(raw) {
|
|
const state = raw && typeof raw === "object" ? raw : {};
|
|
appState.backendRunning = asBoolean(state.backendRunning);
|
|
appState.proxyRunning = asBoolean(state.proxyRunning ?? state.running);
|
|
appState.serviceRunning = appState.proxyRunning;
|
|
appState.serviceLastError = asString(state.lastError);
|
|
appState.backendListenAddr = asString(state.backendListenAddr);
|
|
appState.proxyListenAddr = asString(state.proxyListenAddr || state.listenAddr);
|
|
appState.serviceListenAddr = appState.proxyListenAddr;
|
|
appState.cursorSettingsApplied = asBoolean(state.cursorSettingsApplied);
|
|
appState.netProxySource = asString(state.netProxySource);
|
|
appState.netProxyActive = asBoolean(state.netProxyActive);
|
|
appState.netProxyUsingSystem = asBoolean(state.netProxyUsingSystem);
|
|
appState.netProxyUsingEnv = asBoolean(state.netProxyUsingEnv);
|
|
appState.netProxyHttp = asString(state.netProxyHttp);
|
|
appState.netProxyHttps = asString(state.netProxyHttps);
|
|
appState.netProxyPacIgnored = asBoolean(state.netProxyPacIgnored);
|
|
appState.netProxyDescription = asString(state.netProxyDescription);
|
|
}
|
|
|
|
function handleProxyStateEvent(event) {
|
|
if (event?.data && typeof event.data === "object") {
|
|
applyProxyState(event.data);
|
|
return;
|
|
}
|
|
void syncServiceState().catch(() => {});
|
|
}
|
|
|
|
function handleUserConfigChangedEvent(event) {
|
|
if (event?.data && typeof event.data === "object") {
|
|
applyConfigToState(event.data);
|
|
return;
|
|
}
|
|
void reloadUserConfig().catch(() => {});
|
|
}
|
|
|
|
function applyModelAdapterTestResults(source) {
|
|
const next = {};
|
|
for (const result of normalizeModelAdapterTestResults(source)) {
|
|
next[result.adapterID] = result;
|
|
}
|
|
appState.modelAdapterTestResults = next;
|
|
return next;
|
|
}
|
|
|
|
function handleModelAdapterTestUpdatedEvent(event) {
|
|
if (event?.data) {
|
|
applyModelAdapterTestResults(event.data);
|
|
return;
|
|
}
|
|
void refreshModelAdapterTestResults().catch(() => {});
|
|
}
|
|
|
|
function normalizeUpdateState(value) {
|
|
const text = asString(value).toLowerCase();
|
|
if (["idle", "checking", "downloading", "ready", "installing", "error"].includes(text)) {
|
|
return text;
|
|
}
|
|
return "idle";
|
|
}
|
|
|
|
function applyUpdateSnapshot(raw) {
|
|
const data = raw && typeof raw === "object" ? raw : {};
|
|
const nextState = normalizeUpdateState(data.state ?? appState.updateState);
|
|
appState.updateState = nextState;
|
|
|
|
const version = asString(data.version);
|
|
if (version) {
|
|
appState.updateVersion = version;
|
|
} else if (nextState === "idle") {
|
|
appState.updateVersion = "";
|
|
}
|
|
|
|
const releaseDate = asString(data.releaseDate);
|
|
if (releaseDate) {
|
|
appState.updateReleaseDate = releaseDate;
|
|
} else if (nextState === "idle") {
|
|
appState.updateReleaseDate = "";
|
|
}
|
|
|
|
if (typeof data.releaseNotes === "string") {
|
|
appState.updateReleaseNotes = data.releaseNotes.replace(/\r\n/g, "\n");
|
|
} else if (nextState === "idle") {
|
|
appState.updateReleaseNotes = "";
|
|
}
|
|
|
|
if (typeof data.error === "string") {
|
|
appState.updateError = data.error.trim();
|
|
} else if (nextState !== "error") {
|
|
appState.updateError = "";
|
|
}
|
|
|
|
if (typeof data.message === "string") {
|
|
appState.updateMessage = data.message.trim();
|
|
} else if (!data.prompt) {
|
|
appState.updateMessage = "";
|
|
}
|
|
|
|
if (typeof data.downloaded === "number") {
|
|
appState.updateProgressDownloaded = data.downloaded;
|
|
} else if (nextState !== "downloading") {
|
|
appState.updateProgressDownloaded = 0;
|
|
}
|
|
|
|
if (typeof data.total === "number") {
|
|
appState.updateProgressTotal = data.total;
|
|
} else if (nextState !== "downloading") {
|
|
appState.updateProgressTotal = 0;
|
|
}
|
|
|
|
if (typeof data.percentage === "number") {
|
|
appState.updateProgressPercent = Math.max(0, Math.min(100, data.percentage));
|
|
} else if (nextState !== "downloading") {
|
|
appState.updateProgressPercent = 0;
|
|
}
|
|
}
|
|
|
|
function openUpdatePrompt(kind, payload = {}) {
|
|
appState.updatePromptKind = asString(kind) || "idle";
|
|
appState.updatePromptVisible = true;
|
|
appState.updatePromptBusy = false;
|
|
if (typeof payload.message === "string") {
|
|
appState.updateMessage = payload.message.trim();
|
|
}
|
|
if (typeof payload.error === "string") {
|
|
appState.updateError = payload.error.trim();
|
|
}
|
|
}
|
|
|
|
function handleUpdateStateEvent(event) {
|
|
const data = event?.data && typeof event.data === "object" ? event.data : {};
|
|
applyUpdateSnapshot(data);
|
|
if (asBoolean(data.prompt)) {
|
|
openUpdatePrompt(asString(data.promptKind) || "idle", data);
|
|
}
|
|
}
|
|
|
|
function handleUpdateProgressEvent(event) {
|
|
const data = event?.data && typeof event.data === "object" ? event.data : {};
|
|
applyUpdateSnapshot({
|
|
...data,
|
|
state: "downloading",
|
|
});
|
|
}
|
|
|
|
function handleUpdateReadyEvent(event) {
|
|
const data = event?.data && typeof event.data === "object" ? event.data : {};
|
|
applyUpdateSnapshot({
|
|
...data,
|
|
state: "ready",
|
|
});
|
|
if (data.prompt !== false) {
|
|
openUpdatePrompt("ready", data);
|
|
}
|
|
}
|
|
|
|
function handleUpdateErrorEvent(event) {
|
|
const data = event?.data && typeof event.data === "object" ? event.data : {};
|
|
applyUpdateSnapshot({
|
|
...data,
|
|
state: "error",
|
|
});
|
|
if (asBoolean(data.prompt)) {
|
|
openUpdatePrompt("error", data);
|
|
}
|
|
}
|
|
|
|
function extractErrorMessage(error) {
|
|
if (typeof error === "string") {
|
|
return error.trim();
|
|
}
|
|
if (error && typeof error === "object") {
|
|
return asString(error.message) || asString(error.error);
|
|
}
|
|
return "";
|
|
}
|
|
|
|
const cachedState = loadCachedState();
|
|
const cachedConfig = normalizeConfig(cachedState);
|
|
|
|
export const appState = reactive({
|
|
appVersion: "",
|
|
modelAdapters: cachedConfig.modelAdapters,
|
|
modelAdapterTestResults: {},
|
|
configBackendListenAddr: cachedConfig.backendListenAddr,
|
|
configProxyListenAddr: cachedConfig.proxyListenAddr,
|
|
includeCacheWriteInHitRate: cachedConfig.homeMetrics.includeCacheWriteInHitRate,
|
|
|
|
serviceRunning: asBoolean(cachedState.serviceRunning),
|
|
backendRunning: asBoolean(cachedState.backendRunning),
|
|
proxyRunning: asBoolean(cachedState.proxyRunning),
|
|
serviceBusy: false,
|
|
serviceLastError: asString(cachedState.serviceLastError),
|
|
serviceListenAddr: asString(cachedState.serviceListenAddr),
|
|
backendListenAddr: asString(cachedState.backendListenAddr),
|
|
proxyListenAddr: asString(cachedState.proxyListenAddr),
|
|
cursorSettingsApplied: asBoolean(cachedState.cursorSettingsApplied),
|
|
netProxySource: asString(cachedState.netProxySource),
|
|
netProxyActive: asBoolean(cachedState.netProxyActive),
|
|
netProxyUsingSystem: asBoolean(cachedState.netProxyUsingSystem),
|
|
netProxyUsingEnv: asBoolean(cachedState.netProxyUsingEnv),
|
|
netProxyHttp: asString(cachedState.netProxyHttp),
|
|
netProxyHttps: asString(cachedState.netProxyHttps),
|
|
netProxyPacIgnored: asBoolean(cachedState.netProxyPacIgnored),
|
|
netProxyDescription: asString(cachedState.netProxyDescription),
|
|
|
|
configSaving: false,
|
|
homeMetrics: createEmptyHomeMetrics(),
|
|
homeMetricsLoading: false,
|
|
homeMetricsError: "",
|
|
|
|
updateState: "idle",
|
|
updateVersion: "",
|
|
updateReleaseDate: "",
|
|
updateReleaseNotes: "",
|
|
updateProgressDownloaded: 0,
|
|
updateProgressTotal: 0,
|
|
updateProgressPercent: 0,
|
|
updateError: "",
|
|
updateMessage: "",
|
|
updatePromptVisible: false,
|
|
updatePromptKind: "idle",
|
|
updatePromptBusy: false,
|
|
});
|
|
|
|
watchSyncEffect(() => {
|
|
if (!canUseLocalStorage()) {
|
|
return;
|
|
}
|
|
try {
|
|
window.localStorage.setItem(
|
|
APP_STATE_STORAGE_KEY,
|
|
JSON.stringify({
|
|
...buildConfigPayload(),
|
|
serviceRunning: appState.serviceRunning,
|
|
backendRunning: appState.backendRunning,
|
|
proxyRunning: appState.proxyRunning,
|
|
serviceLastError: appState.serviceLastError,
|
|
serviceListenAddr: appState.serviceListenAddr,
|
|
configBackendListenAddr: appState.configBackendListenAddr,
|
|
configProxyListenAddr: appState.configProxyListenAddr,
|
|
backendListenAddr: appState.backendListenAddr,
|
|
proxyListenAddr: appState.proxyListenAddr,
|
|
cursorSettingsApplied: appState.cursorSettingsApplied,
|
|
netProxySource: appState.netProxySource,
|
|
netProxyActive: appState.netProxyActive,
|
|
netProxyUsingSystem: appState.netProxyUsingSystem,
|
|
netProxyUsingEnv: appState.netProxyUsingEnv,
|
|
netProxyHttp: appState.netProxyHttp,
|
|
netProxyHttps: appState.netProxyHttps,
|
|
netProxyPacIgnored: appState.netProxyPacIgnored,
|
|
netProxyDescription: appState.netProxyDescription,
|
|
}),
|
|
);
|
|
} catch (_error) {
|
|
// ignore local persistence failures
|
|
}
|
|
});
|
|
|
|
watchSyncEffect((onCleanup) => {
|
|
if (typeof window === "undefined") {
|
|
return;
|
|
}
|
|
const unsubscribe = Events.On(PROXY_STATE_EVENT, handleProxyStateEvent);
|
|
onCleanup(() => {
|
|
unsubscribe();
|
|
});
|
|
});
|
|
|
|
watchSyncEffect((onCleanup) => {
|
|
if (typeof window === "undefined") {
|
|
return;
|
|
}
|
|
const unsubscribe = Events.On(USER_CONFIG_CHANGED_EVENT, handleUserConfigChangedEvent);
|
|
onCleanup(() => {
|
|
unsubscribe();
|
|
});
|
|
});
|
|
|
|
watchSyncEffect((onCleanup) => {
|
|
if (typeof window === "undefined") {
|
|
return;
|
|
}
|
|
const unsubscribe = Events.On(MODEL_ADAPTER_TEST_UPDATED_EVENT, handleModelAdapterTestUpdatedEvent);
|
|
onCleanup(() => {
|
|
unsubscribe();
|
|
});
|
|
});
|
|
|
|
watchSyncEffect((onCleanup) => {
|
|
if (typeof window === "undefined") {
|
|
return;
|
|
}
|
|
const unsubscribe = Events.On(UPDATE_STATE_EVENT, handleUpdateStateEvent);
|
|
onCleanup(() => {
|
|
unsubscribe();
|
|
});
|
|
});
|
|
|
|
watchSyncEffect((onCleanup) => {
|
|
if (typeof window === "undefined") {
|
|
return;
|
|
}
|
|
const unsubscribe = Events.On(UPDATE_PROGRESS_EVENT, handleUpdateProgressEvent);
|
|
onCleanup(() => {
|
|
unsubscribe();
|
|
});
|
|
});
|
|
|
|
watchSyncEffect((onCleanup) => {
|
|
if (typeof window === "undefined") {
|
|
return;
|
|
}
|
|
const unsubscribe = Events.On(UPDATE_READY_EVENT, handleUpdateReadyEvent);
|
|
onCleanup(() => {
|
|
unsubscribe();
|
|
});
|
|
});
|
|
|
|
watchSyncEffect((onCleanup) => {
|
|
if (typeof window === "undefined") {
|
|
return;
|
|
}
|
|
const unsubscribe = Events.On(UPDATE_ERROR_EVENT, handleUpdateErrorEvent);
|
|
onCleanup(() => {
|
|
unsubscribe();
|
|
});
|
|
});
|
|
|
|
export const appViewState = reactive({
|
|
serviceStatusText: computed(() => {
|
|
if (appState.proxyRunning && appState.backendRunning) {
|
|
return "服务运行中";
|
|
}
|
|
if (appState.backendRunning) {
|
|
return "后端已启动,代理未启动";
|
|
}
|
|
return "服务未启动";
|
|
}),
|
|
serviceStatusClass: computed(() =>
|
|
appState.serviceRunning ? "text-[#22c55e]" : "text-[#f59e0b]",
|
|
),
|
|
serviceButtonText: computed(() => {
|
|
if (appState.serviceBusy) {
|
|
return appState.serviceRunning ? "关闭中..." : "启动中...";
|
|
}
|
|
return appState.serviceRunning ? "关闭服务" : "启动服务";
|
|
}),
|
|
});
|
|
|
|
function localizeUpdateMessage(msg) {
|
|
if (!msg) return "";
|
|
if (/当前已是最新版本/.test(msg)) {
|
|
const match = msg.match(/v?([0-9]+\.[0-9]+\.[0-9]+)/);
|
|
const version = match ? match[1] : appState.appVersion || "...";
|
|
return `当前已是最新版本(v${version})。`;
|
|
}
|
|
return msg;
|
|
}
|
|
|
|
function localizeReadyContent() {
|
|
const version = appState.updateVersion || appState.appVersion || "...";
|
|
const date = formatReleaseDate(appState.updateReleaseDate);
|
|
const notes = appState.updateReleaseNotes || "";
|
|
|
|
return [
|
|
`版本:v${version}`,
|
|
`发布时间:${date}`,
|
|
"",
|
|
notes || "无更新说明",
|
|
].join("\n");
|
|
}
|
|
|
|
export const updateViewState = reactive({
|
|
footerDownloading: computed(() => appState.updateState === "downloading"),
|
|
footerBusy: computed(() => ["checking", "installing"].includes(appState.updateState)),
|
|
footerVersionLabel: computed(() => `v${appState.appVersion || "..."}`),
|
|
footerProgressText: computed(() => `${Math.round(appState.updateProgressPercent || 0)}%`),
|
|
footerProgressStyle: computed(() => ({
|
|
width: `${Math.max(0, Math.min(100, appState.updateProgressPercent || 0))}%`,
|
|
})),
|
|
promptTitle: computed(() => {
|
|
switch (appState.updatePromptKind) {
|
|
case "ready":
|
|
return "发现新版本";
|
|
case "error":
|
|
return "更新失败";
|
|
default:
|
|
return "检查更新";
|
|
}
|
|
}),
|
|
promptContent: computed(() => {
|
|
switch (appState.updatePromptKind) {
|
|
case "ready":
|
|
return localizeReadyContent();
|
|
case "error":
|
|
return appState.updateError || localizeUpdateMessage(appState.updateMessage) || GENERIC_SERVICE_ERROR;
|
|
default:
|
|
return localizeUpdateMessage(appState.updateMessage) || localizeUpdateMessage(`当前已是最新版本(v${appState.appVersion || "..."})。`);
|
|
}
|
|
}),
|
|
promptConfirmText: computed(() => {
|
|
if (appState.updatePromptKind === "ready") {
|
|
return "立即重启更新";
|
|
}
|
|
return "确定";
|
|
}),
|
|
promptCancelText: computed(() => {
|
|
if (appState.updatePromptKind === "ready") {
|
|
return "稍后";
|
|
}
|
|
return "取消";
|
|
}),
|
|
promptShowCancel: computed(() => appState.updatePromptKind === "ready"),
|
|
});
|
|
|
|
export function getModelAdapterTestResultByID(adapterID) {
|
|
const id = asString(adapterID);
|
|
if (!id) {
|
|
return null;
|
|
}
|
|
return appState.modelAdapterTestResults[id] ?? null;
|
|
}
|
|
|
|
export function getModelAdapterTestResult(adapter) {
|
|
const normalized = normalizeModelAdapter(adapter);
|
|
if (normalized.id && appState.modelAdapterTestResults[normalized.id]) {
|
|
return appState.modelAdapterTestResults[normalized.id];
|
|
}
|
|
const requestHash = buildModelAdapterTestRequestHash(normalized);
|
|
return Object.values(appState.modelAdapterTestResults).find((result) => result.requestHash === requestHash) ?? null;
|
|
}
|
|
|
|
export function isModelAdapterTestResultRunning(adapter) {
|
|
return getModelAdapterTestResult(adapter)?.status === "running";
|
|
}
|
|
|
|
export function isModelAdapterTestResultStale(adapter, result) {
|
|
if (!result || !result.requestHash) {
|
|
return false;
|
|
}
|
|
return result.requestHash !== buildModelAdapterTestRequestHash(adapter);
|
|
}
|
|
|
|
export async function refreshModelAdapterTestResults() {
|
|
const results = await getModelAdapterTestResults();
|
|
applyModelAdapterTestResults(results);
|
|
return Object.values(appState.modelAdapterTestResults);
|
|
}
|
|
|
|
export function startModelAdapterTest(adapter) {
|
|
const normalized = normalizeModelAdapter(adapter);
|
|
return testModelAdapter(normalized).then((rawResult) => {
|
|
const result = normalizeModelAdapterTestResult(rawResult);
|
|
if (result.adapterID) {
|
|
appState.modelAdapterTestResults = {
|
|
...appState.modelAdapterTestResults,
|
|
[result.adapterID]: result,
|
|
};
|
|
}
|
|
return result;
|
|
});
|
|
}
|
|
|
|
export async function runModelAdapterTest(adapter) {
|
|
return startModelAdapterTest(adapter);
|
|
}
|
|
|
|
export async function persistUserConfig() {
|
|
const currentConfig = await loadPersistedUserConfig();
|
|
return persistConfigPayload({
|
|
...currentConfig,
|
|
modelAdapters: normalizeModelAdapters(appState.modelAdapters),
|
|
homeMetrics: {
|
|
...currentConfig.homeMetrics,
|
|
includeCacheWriteInHitRate: appState.includeCacheWriteInHitRate,
|
|
},
|
|
});
|
|
}
|
|
|
|
export async function saveIncludeCacheWriteInHitRate(value) {
|
|
const currentConfig = await loadPersistedUserConfig();
|
|
const previousValue = appState.includeCacheWriteInHitRate;
|
|
const nextValue = asBoolean(value);
|
|
appState.includeCacheWriteInHitRate = nextValue;
|
|
const result = await persistConfigPayload({
|
|
...currentConfig,
|
|
homeMetrics: {
|
|
...currentConfig.homeMetrics,
|
|
includeCacheWriteInHitRate: nextValue,
|
|
},
|
|
});
|
|
if (!result.ok) {
|
|
appState.includeCacheWriteInHitRate = previousValue;
|
|
}
|
|
return result;
|
|
}
|
|
|
|
export async function reloadUserConfig(options = {}) {
|
|
const config = await loadPersistedUserConfig();
|
|
applyConfigToState(config, options);
|
|
return config;
|
|
}
|
|
|
|
export async function saveModelAdapterAt(index, adapter) {
|
|
const currentConfig = await loadPersistedUserConfig();
|
|
const nextAdapters = normalizeModelAdapters(currentConfig.modelAdapters);
|
|
const nextAdapter = normalizeModelAdapter(adapter);
|
|
const targetIndex = index >= 0 && index < nextAdapters.length ? index : nextAdapters.length;
|
|
|
|
if (index >= 0 && index < nextAdapters.length) {
|
|
nextAdapters.splice(index, 1, nextAdapter);
|
|
} else {
|
|
nextAdapters.push(nextAdapter);
|
|
}
|
|
|
|
const result = await persistConfigPayload(
|
|
{
|
|
...currentConfig,
|
|
modelAdapters: nextAdapters,
|
|
},
|
|
{ modelAdaptersOnly: true },
|
|
);
|
|
if (!result.ok) {
|
|
return result;
|
|
}
|
|
return {
|
|
...result,
|
|
index: targetIndex,
|
|
adapter: appState.modelAdapters[targetIndex] ?? null,
|
|
};
|
|
}
|
|
|
|
export async function fetchAvailableModelIDs(payload) {
|
|
const result = await fetchModelAdapterModels(payload);
|
|
return asArray(result?.models)
|
|
.map((item) => asString(item))
|
|
.filter(Boolean);
|
|
}
|
|
|
|
export async function deleteModelAdapterAt(index) {
|
|
const currentConfig = await loadPersistedUserConfig();
|
|
const nextAdapters = normalizeModelAdapters(currentConfig.modelAdapters);
|
|
|
|
if (index < 0 || index >= nextAdapters.length) {
|
|
return {
|
|
ok: false,
|
|
error: "模型配置不存在,无法删除",
|
|
};
|
|
}
|
|
|
|
nextAdapters.splice(index, 1);
|
|
|
|
return persistConfigPayload(
|
|
{
|
|
...currentConfig,
|
|
modelAdapters: nextAdapters,
|
|
},
|
|
{ modelAdaptersOnly: true },
|
|
);
|
|
}
|
|
|
|
export async function saveModelAdapterOrder(adapterIDs) {
|
|
const orderedIDs = asArray(adapterIDs)
|
|
.map((item) => asString(item))
|
|
.filter(Boolean);
|
|
const currentConfig = await loadPersistedUserConfig();
|
|
const currentAdapters = normalizeModelAdapters(currentConfig.modelAdapters);
|
|
const adaptersByID = new Map(currentAdapters.map((adapter) => [adapter.id, adapter]));
|
|
const uniqueIDs = new Set(orderedIDs);
|
|
|
|
if (
|
|
orderedIDs.length !== currentAdapters.length
|
|
|| uniqueIDs.size !== currentAdapters.length
|
|
|| orderedIDs.some((id) => !adaptersByID.has(id))
|
|
) {
|
|
return {
|
|
ok: false,
|
|
error: "模型配置已发生变化,请刷新后重试",
|
|
};
|
|
}
|
|
|
|
const nextAdapters = orderedIDs.map((id, index) => ({
|
|
...adaptersByID.get(id),
|
|
sort: index + 1,
|
|
}));
|
|
return persistConfigPayload(
|
|
{
|
|
...currentConfig,
|
|
modelAdapters: nextAdapters,
|
|
},
|
|
{ modelAdaptersOnly: true },
|
|
);
|
|
}
|
|
|
|
function splitDisplayNameSeed(value) {
|
|
const text = asString(value);
|
|
const match = text.match(/^(.*?)(?:\s*[-+](\d+))?$/);
|
|
if (!match) {
|
|
return { base: text || "模型", number: 0 };
|
|
}
|
|
const base = asString(match[1]) || "模型";
|
|
const number = match[2] ? Number(match[2]) : 0;
|
|
return { base, number: Number.isFinite(number) ? number : 0 };
|
|
}
|
|
|
|
function buildNextDisplayName(existingAdapters, sourceName) {
|
|
const { base } = splitDisplayNameSeed(sourceName);
|
|
let next = 1;
|
|
const taken = new Set(
|
|
normalizeModelAdapters(existingAdapters)
|
|
.map((adapter) => adapter.displayName)
|
|
.filter(Boolean),
|
|
);
|
|
|
|
while (taken.has(`${base}-${next}`)) {
|
|
next += 1;
|
|
}
|
|
return `${base}-${next}`;
|
|
}
|
|
|
|
export async function duplicateModelAdapterAt(index) {
|
|
const currentConfig = await loadPersistedUserConfig();
|
|
const nextAdapters = normalizeModelAdapters(currentConfig.modelAdapters);
|
|
|
|
if (index < 0 || index >= nextAdapters.length) {
|
|
return {
|
|
ok: false,
|
|
error: "模型配置不存在,无法复制",
|
|
};
|
|
}
|
|
|
|
const source = normalizeModelAdapter(nextAdapters[index]);
|
|
const duplicate = {
|
|
...source,
|
|
id: "",
|
|
displayName: buildNextDisplayName(nextAdapters, source.displayName || source.modelID || "模型"),
|
|
};
|
|
|
|
nextAdapters.splice(index + 1, 0, duplicate);
|
|
|
|
return persistConfigPayload(
|
|
{
|
|
...currentConfig,
|
|
modelAdapters: nextAdapters,
|
|
},
|
|
{ modelAdaptersOnly: true },
|
|
);
|
|
}
|
|
|
|
export async function syncServiceState() {
|
|
const state = await getProxyState();
|
|
applyProxyState(state);
|
|
return state;
|
|
}
|
|
|
|
export async function syncHomeMetrics() {
|
|
const startedAt = Date.now();
|
|
appState.homeMetricsLoading = true;
|
|
try {
|
|
const summary = await getHomeMetricsSummary();
|
|
applyHomeMetrics(summary);
|
|
return {
|
|
ok: true,
|
|
error: "",
|
|
};
|
|
} catch (error) {
|
|
appState.homeMetricsError = toUserError(error);
|
|
return {
|
|
ok: false,
|
|
error: appState.homeMetricsError,
|
|
};
|
|
} finally {
|
|
const elapsed = Date.now() - startedAt;
|
|
if (elapsed < HOME_METRICS_MIN_LOADING_MS) {
|
|
await delay(HOME_METRICS_MIN_LOADING_MS - elapsed);
|
|
}
|
|
appState.homeMetricsLoading = false;
|
|
}
|
|
}
|
|
|
|
export async function startService() {
|
|
if (appState.serviceBusy) {
|
|
return { ok: false, error: "服务状态更新中,请稍后再试" };
|
|
}
|
|
appState.serviceBusy = true;
|
|
try {
|
|
const saved = await persistUserConfig();
|
|
if (!saved.ok) {
|
|
return saved;
|
|
}
|
|
const state = await startProxyService();
|
|
applyProxyState(state);
|
|
return { ok: true, error: "" };
|
|
} catch (error) {
|
|
await syncServiceState().catch(() => {});
|
|
return { ok: false, error: toUserError(error) };
|
|
} finally {
|
|
appState.serviceBusy = false;
|
|
}
|
|
}
|
|
|
|
export async function stopService() {
|
|
if (appState.serviceBusy) {
|
|
return { ok: false, error: "服务状态更新中,请稍后再试" };
|
|
}
|
|
appState.serviceBusy = true;
|
|
try {
|
|
const state = await stopProxyService();
|
|
applyProxyState(state);
|
|
return { ok: true, error: "" };
|
|
} catch (error) {
|
|
await syncServiceState().catch(() => {});
|
|
return { ok: false, error: toUserError(error) };
|
|
} finally {
|
|
appState.serviceBusy = false;
|
|
}
|
|
}
|
|
|
|
export async function toggleService() {
|
|
if (appState.serviceRunning) {
|
|
return stopService();
|
|
}
|
|
return startService();
|
|
}
|
|
|
|
export async function openLocalLogsDirectory() {
|
|
await openLogsDirectory();
|
|
}
|
|
|
|
export async function openConfigWindow() {
|
|
await openConfig();
|
|
}
|
|
|
|
export async function openModelConfigWindow() {
|
|
await openModelConfig();
|
|
}
|
|
|
|
export async function checkForAppUpdates() {
|
|
await checkForUpdates();
|
|
}
|
|
|
|
export function dismissUpdatePrompt() {
|
|
appState.updatePromptVisible = false;
|
|
appState.updatePromptBusy = false;
|
|
}
|
|
|
|
export async function confirmUpdatePrompt() {
|
|
if (appState.updatePromptKind !== "ready") {
|
|
dismissUpdatePrompt();
|
|
return;
|
|
}
|
|
if (appState.updatePromptBusy) {
|
|
return;
|
|
}
|
|
appState.updatePromptBusy = true;
|
|
try {
|
|
await installReadyUpdate();
|
|
} catch (error) {
|
|
appState.updatePromptBusy = false;
|
|
const message = toUserError(error);
|
|
appState.updateError = message;
|
|
openUpdatePrompt("error", { error: message });
|
|
}
|
|
}
|
|
|
|
export function toUserError(error) {
|
|
const message = extractErrorMessage(error);
|
|
return message || GENERIC_SERVICE_ERROR;
|
|
}
|
|
|
|
export async function bootstrapAppState() {
|
|
try {
|
|
await reloadUserConfig();
|
|
} catch (_error) {
|
|
// keep cached config if loading fails
|
|
}
|
|
await refreshModelAdapterTestResults().catch(() => {});
|
|
try {
|
|
appState.appVersion = await getAppVersion();
|
|
} catch (_error) {
|
|
appState.appVersion = "";
|
|
}
|
|
await syncServiceState().catch(() => {});
|
|
await syncHomeMetrics().catch(() => {});
|
|
}
|