Z ZPI 文档 v0.3.8
介绍页

源码

packages/desktop/src/main/storage.ts

typescript916 行查看原文
import { randomUUID } from "node:crypto";
import { existsSync, mkdirSync, readFileSync, renameSync, rmSync, writeFileSync } from "node:fs";
import { dirname, join, resolve } from "node:path";
import type { Model } from "@zpi-dev/ai";
import {
  anthropicMessagesCompatKeys,
  assertSupportedOptions,
  canControlThinking,
  discoverProviderCredentials,
  getProviderPreset,
  isJsonObject,
  openAICompletionsCompatKeys,
  presetModels,
  providerApi,
  providerApis,
  providerBaseUrl,
  usesChatGPTAuth,
  validateReasoningConfig,
  validateThinkingMap,
} from "@zpi-dev/ai";
import {
  type ChatGPTCredential,
  isChatGPTCredential,
  refreshChatGPTCredential,
  verifyRefreshedChatGPTCredential,
} from "@zpi-dev/ai/auth/openai-chatgpt";
import {
  builtinPluginCatalog,
  defaultPluginSettings,
  desktopPluginIds,
  validateBuiltinPluginOptions,
} from "@zpi-dev/builtin-plugins";
import {
  defaultMemorySettings,
  type MemorySettings,
  memorySettingLimits,
} from "@zpi-dev/builtin-plugins/memory/types";
import { type PromptTemplate, piTemplate, validateTemplate } from "@zpi-dev/coding-agent";
import { isCodePreviewSettings } from "@zpi-dev/ui/code-preview";
import type {
  CombinedSelection,
  InterfacePreferences,
  ModelSelection,
  ModelSettings,
  ProviderInput,
  ProviderRecord,
  PublicSettings,
  WebSearchSettingsInput,
} from "../shared/bridge.ts";
import {
  availablePresets,
  defaultPreferences,
  defaultPreset,
  modelDefaults,
  sidebarLimits,
  toThinking,
  uiFontSizeLimits,
} from "../shared/config.ts";
import { memoryMode } from "../shared/plugins.ts";
import {
  defaultShortcuts,
  normalizeShortcuts,
  type ShortcutSettings,
  validateShortcuts,
} from "../shared/shortcuts.ts";
import { validatePluginSettings } from "./plugins/settings.ts";
import { StateStore } from "./state-store.ts";
export function atomicJson(path: string, data: unknown): void {
  mkdirSync(dirname(path), { recursive: true });
  const temp = `${path}.${randomUUID()}.tmp`;
  try {
    writeFileSync(temp, JSON.stringify(data, null, 2), { mode: 0o600 });
    renameSync(temp, path);
  } finally {
    rmSync(temp, { force: true });
  }
}
export interface CredentialEncryption {
  isEncryptionAvailable(): boolean;
  encryptString(value: string): Buffer;
  decryptString(value: Buffer): string;
}
type ProviderData = Omit<ProviderRecord, "hasApiKey" | "chatgptAccount">;
interface Credentials {
  baseUrl: string;
  apiKey: string;
  chatgpt?: ChatGPTCredential;
}
export interface SettingsData {
  shortcuts: ShortcutSettings;
  plugins: PublicSettings["plugins"];
  webSearchEnabled: boolean;
  version: 5;
  templates: PromptTemplate[];
  selectedTemplateId: string;
  disabledSkillPaths: string[];
  providers: ProviderData[];
  lastSelection: CombinedSelection | null;
  interface: Omit<InterfacePreferences, "notificationEnabled">;
}
const { notificationEnabled: _notificationEnabled, ...defaultStoredPreferences } = defaultPreferences;
export function resolveModel(provider: ProviderData, input: ModelSettings): Model {
  const model: Model = {
    id: input.id,
    name: input.name?.trim() || input.id,
    api: providerApi(provider.preset, provider.api ?? "openai-completions"),
    ...(usesChatGPTAuth(provider.preset) ? { auth: "chatgpt" as const } : {}),
    provider: provider.id,
    baseUrl: provider.baseUrl,
    input: (input.input ?? ["text"]).filter(
      (type): type is "text" | "image" => type === "text" || type === "image",
    ),
    reasoning: input.reasoning ?? modelDefaults.reasoning,
    contextWindow: input.contextWindow ?? modelDefaults.contextWindow,
    maxTokens: input.maxTokens ?? modelDefaults.maxTokens,
    compat: { ...modelDefaults.compat, ...input.compat },
    ...(input.reasoningConfig ? { reasoningConfig: structuredClone(input.reasoningConfig) } : {}),
    ...(input.thinkingLevelMap ? { thinkingLevelMap: structuredClone(input.thinkingLevelMap) } : {}),
    ...(input.defaultThinkingLevel ? { defaultThinkingLevel: input.defaultThinkingLevel } : {}),
    ...(input.samplingParams ? { samplingParams: structuredClone(input.samplingParams) } : {}),
    cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
  };
  model.defaultThinkingLevel = toThinking(defaultPreset(model), model);
  return model;
}
export class SettingsStore {
  private data: SettingsData = {
    shortcuts: defaultShortcuts(process.platform),
    plugins: structuredClone(defaultPluginSettings),
    webSearchEnabled: false,
    version: 5,
    templates: [],
    selectedTemplateId: "pi",
    disabledSkillPaths: [],
    providers: [],
    lastSelection: null,
    interface: structuredClone(defaultStoredPreferences),
  };
  private credentials: Record<string, Credentials> = {};
  private webApiKey = "";
  private refreshes = new Map<string, Promise<string>>();
  private persisted = false;
  private unreadCredentials = false;
  private dir: string;
  private encryption: CredentialEncryption;
  readonly state: StateStore;
  private ownsState: boolean;
  constructor(dir: string, encryption: CredentialEncryption, state?: StateStore) {
    this.ownsState = !state;
    this.state = state ?? new StateStore(join(dir, "state.sqlite"));
    this.dir = dir;
    this.encryption = encryption;
    try {
      const creds = join(dir, "credentials.enc");
      const raw = this.state.getSetting<SettingsData>("app");
      if (raw !== undefined) {
        this.validateData(raw);
        this.data = raw;
      }
      if (existsSync(creds)) {
        if (!encryption.isEncryptionAvailable()) this.unreadCredentials = true;
        else {
          const raw: unknown = JSON.parse(encryption.decryptString(readFileSync(creds)));
          if (!isJsonObject(raw)) throw new Error("storage: Invalid credential record");
          if (raw.version !== 4) throw new Error("storage: Unsupported credential format");
          const values = raw.providers;
          if (!isJsonObject(values)) throw new Error("storage: Invalid credential providers");
          for (const [id, value] of Object.entries(values)) {
            if (!isJsonObject(value) || typeof value.baseUrl !== "string" || typeof value.apiKey !== "string")
              throw new Error("storage: Invalid credential record");
            if (this.data.providers.find((p) => p.id === id)?.baseUrl === value.baseUrl)
              this.credentials[id] = {
                baseUrl: value.baseUrl,
                apiKey: value.apiKey,
                ...(isChatGPTCredential(value.chatgpt) ? { chatgpt: value.chatgpt } : {}),
              };
          }
          if (raw.webSearch !== undefined) {
            if (!isJsonObject(raw.webSearch) || typeof raw.webSearch.apiKey !== "string")
              throw new Error("storage: Invalid web search credentials");
            this.webApiKey = raw.webSearch.apiKey;
          }
          this.persisted = true;
        }
      }
    } catch (error) {
      this.close();
      throw error;
    }
  }
  close(): void {
    if (this.ownsState) {
      this.state.close();
      this.ownsState = false;
    }
  }
  get(): PublicSettings {
    return {
      webSearch: {
        provider: "deepseek",
        enabled: this.data.webSearchEnabled,
        hasApiKey: Boolean(this.webApiKey),
        apiKeyLength: this.unreadCredentials ? 0 : this.webApiKey.length,
        credentialsPersisted: this.persisted,
        credentialsLocked: this.unreadCredentials,
      },
      shortcuts: structuredClone(this.data.shortcuts),
      plugins: structuredClone(this.data.plugins),
      providers: this.data.providers.map((p) => ({
        ...structuredClone(p),
        hasApiKey: Boolean(this.credentials[p.id]?.apiKey),
        ...(this.credentials[p.id]?.chatgpt
          ? {
              chatgptAccount: {
                label: `${this.credentials[p.id].chatgpt?.email ?? "ChatGPT"} · ${this.credentials[p.id].chatgpt?.clientId.slice(-8)}`,
                connected: Boolean(
                  this.credentials[p.id].chatgpt?.access && this.credentials[p.id].chatgpt?.refresh,
                ),
              },
            }
          : {}),
      })),
      lastSelection: structuredClone(this.data.lastSelection),
      interface: {
        ...structuredClone(this.data.interface),
        notificationEnabled: this.data.plugins.enabled[desktopPluginIds.notifications] !== false,
      },
      credentialsPersisted: this.persisted,
      disabledSkillPaths: [...this.data.disabledSkillPaths],
    };
  }
  // Main-process only. Web tools never use a chat provider's credentials or environment keys.
  getWebSearchApiKey(): string {
    if (this.unreadCredentials) throw new Error("configuration: 保存的联网搜索凭据尚未解锁");
    if (!this.data.webSearchEnabled) throw new Error("configuration: DeepSeek 联网搜索服务已停用");
    return this.webApiKey;
  }
  // Explicit settings reveal may read a saved key even when the service is disabled.
  getWebSearchCredentials(): { apiKey: string } {
    if (this.unreadCredentials) throw new Error("configuration: 保存的联网搜索凭据尚未解锁");
    return { apiKey: this.webApiKey };
  }
  saveWebSearch(input: WebSearchSettingsInput): PublicSettings {
    if (!isJsonObject(input)) throw new Error("invalid_input: 联网搜索配置必须为对象");
    assertSupportedOptions(input, ["provider", "apiKey", "enabled"], "web search settings");
    if (input.provider !== "deepseek") throw new Error("invalid_input: 不支持的联网搜索提供商");
    if (input.enabled !== undefined && typeof input.enabled !== "boolean")
      throw new Error("invalid_input: 联网搜索服务状态无效");
    if (
      input.apiKey !== undefined &&
      (typeof input.apiKey !== "string" || input.apiKey.length > 8192 || /[\r\n\0]/.test(input.apiKey))
    )
      throw new Error("invalid_input: 联网搜索 API key 无效");
    const webApiKey = input.apiKey === undefined ? this.webApiKey : input.apiKey.trim();
    if (input.enabled === true) {
      if (this.unreadCredentials) throw new Error("configuration: 保存的联网搜索凭据尚未解锁");
      if (!webApiKey) throw new Error("configuration: 请先配置 DeepSeek 联网搜索 API Key");
    }
    const data = {
      ...this.data,
      ...(input.enabled !== undefined ? { webSearchEnabled: input.enabled } : {}),
    };
    if (input.apiKey === undefined) return this.savePublic(data);
    this.persist(data, this.credentials, webApiKey);
    return this.get();
  }
  getMemorySettings(): MemorySettings {
    const saved = this.state.getSetting<Omit<MemorySettings, "short" | "long">>("memory.settings");
    if (saved !== undefined)
      assertSupportedOptions(
        saved,
        Object.keys(defaultMemorySettings).filter((key) => key !== "short" && key !== "long"),
        "memory pipeline settings",
      );
    const mode = memoryMode(this.data.plugins);
    const value = { ...(saved ?? defaultMemorySettings), short: mode === "short", long: mode === "long" };
    this.validateMemorySettings(value);
    return structuredClone(value);
  }
  saveMemorySettings(input: MemorySettings): MemorySettings {
    this.validateMemorySettings(input);
    if (input.short && input.long) throw new Error("invalid_input: 短记忆和长期记忆不能同时开启");
    const previous = this.getMemorySettings();
    for (const key of ["selection", "extractSelection", "consolidateSelection"] as const) {
      const selection = input[key];
      if (!selection) continue;
      const provider = this.data.providers.find((item) => item.id === selection.provider);
      const model = provider?.models.find((item) => item.id === selection.modelId);
      // Missing models remain saved so the pipeline can report the exact unavailable selection.
      // Provider edits may invalidate a saved selection; that must not prevent pausing or disabling memory.
      const unchanged =
        previous[key]?.provider === selection.provider &&
        previous[key]?.modelId === selection.modelId &&
        previous[key]?.reasoning === selection.reasoning;
      if (
        !unchanged &&
        provider &&
        model &&
        !availablePresets(resolveModel(provider, model)).includes(selection.reasoning)
      )
        throw new Error("configuration: 记忆模型不支持所选思考级别");
    }
    const { short, long, ...pipeline } = input;
    const data = {
      ...this.data,
      plugins: {
        ...this.data.plugins,
        enabled: {
          ...this.data.plugins.enabled,
          ...(short || long ? { [desktopPluginIds.memory]: true } : {}),
        },
        options: {
          ...this.data.plugins.options,
          [desktopPluginIds.memory]: { mode: long ? "long" : short ? "short" : "off" },
        },
      },
    };
    this.validateData(data);
    this.state.transaction("memory.settings", "memory", () => {
      this.state.setSetting("memory.settings", structuredClone(pipeline));
      this.state.setSetting("app", data);
    });
    this.data = data;
    return this.getMemorySettings();
  }
  private validateMemorySettings(value: unknown): asserts value is MemorySettings {
    if (!isJsonObject(value)) throw new Error("invalid_input: 记忆设置必须为对象");
    assertSupportedOptions(value, Object.keys(defaultMemorySettings), "invalid_input: memory settings");
    for (const name of ["short", "long", "generate"])
      if (typeof value[name] !== "boolean") throw new Error("invalid_input: 记忆开关无效");
    if (value.modelMode !== "follow" && value.modelMode !== "manual")
      throw new Error("invalid_input: 记忆模型策略无效");
    for (const name of ["selection", "extractSelection", "consolidateSelection"]) {
      const selection = value[name];
      if (selection === null) continue;
      if (!isJsonObject(selection)) throw new Error("invalid_input: 记忆模型配置无效");
      assertSupportedOptions(
        selection,
        ["provider", "modelId", "reasoning"],
        "invalid_input: memory model selection",
      );
      for (const key of ["provider", "modelId", "reasoning"])
        if (
          typeof selection[key] !== "string" ||
          !selection[key].trim() ||
          selection[key].length > 256 ||
          /[\r\n\0]/.test(selection[key])
        )
          throw new Error("invalid_input: 记忆模型配置无效");
    }
    for (const [name, limits] of Object.entries(memorySettingLimits)) {
      const number = value[name];
      if (
        typeof number !== "number" ||
        !Number.isInteger(number) ||
        number < limits.min ||
        number > limits.max
      )
        throw new Error(`invalid_input: ${name} 必须为 ${limits.min}–${limits.max} 的整数`);
    }
  }
  getModel(selection: ModelSelection): Model | undefined {
    const p = this.data.providers.find((p) => p.id === selection.provider),
      m = p?.models.find((m) => m.id === selection.modelId);
    return p && p.enabled !== false && m && m.enabled !== false ? resolveModel(p, m) : undefined;
  }
  isSelectionValid(selection: CombinedSelection): boolean {
    const model = this.getModel(selection);
    return Boolean(
      model &&
        typeof selection.reasoning === "string" &&
        canControlThinking(model, toThinking(selection.reasoning, model)),
    );
  }
  getProviderCredentials(id: string): { apiKey: string } {
    if (!this.data.providers.some((p) => p.id === id)) throw new Error("not_found: 提供商不存在");
    if (this.unreadCredentials) throw new Error("configuration: 保存的凭据尚未解锁,无法读取");
    if (usesChatGPTAuth(this.data.providers.find((p) => p.id === id)?.preset)) return { apiKey: "" };
    return { apiKey: this.credentials[id]?.apiKey ?? "" };
  }
  snapshot(providerId = "custom"): ProviderInput & { id: string; apiKey: string } {
    const p = this.data.providers.find((p) => p.id === providerId);
    if (!p) throw new Error("configuration: 所选提供商已删除,请选择新模型");
    return structuredClone({ ...p, ...this.getProviderCredentials(providerId) });
  }
  saveProvider(input: ProviderInput, chatgptCredential?: ChatGPTCredential): PublicSettings {
    if (!isJsonObject(input)) throw new Error("configuration: 提供商配置必须为对象");
    assertSupportedOptions(
      input,
      ["id", "name", "baseUrl", "models", "apiKey", "preset", "enabled", "api"],
      "provider settings",
    );
    if (typeof input.name !== "string" || typeof input.baseUrl !== "string")
      throw new Error("configuration: 名称和 Base URL 必填");
    const id = input.id ?? randomUUID();
    const provider = {
      id,
      ...(input.api !== undefined ? { api: input.api } : {}),
      ...(input.enabled !== undefined ? { enabled: input.enabled } : {}),
      ...(input.preset ? { preset: input.preset } : {}),
      name: input.name.trim(),
      baseUrl: input.baseUrl.trim(),
      models: structuredClone(input.models),
    };
    this.validateProvider(provider);
    if (usesChatGPTAuth(input.preset) && input.apiKey !== undefined)
      throw new Error("configuration: ChatGPT 提供商需要登录授权");
    if (
      chatgptCredential !== undefined &&
      (!usesChatGPTAuth(input.preset) || !isChatGPTCredential(chatgptCredential))
    )
      throw new Error("configuration: 无效 ChatGPT 凭据");
    if (input.apiKey !== undefined && typeof input.apiKey !== "string")
      throw new Error("configuration: 无效凭据");
    if (this.unreadCredentials && input.apiKey === undefined)
      throw new Error("configuration: 保存的凭据尚未解锁");
    const chatgpt = chatgptCredential ?? this.credentials[id]?.chatgpt;
    const credentials = {
      ...this.credentials,
      [id]: {
        baseUrl: provider.baseUrl,
        apiKey: input.apiKey ?? this.credentials[id]?.apiKey ?? "",
        ...(usesChatGPTAuth(input.preset) && chatgpt ? { chatgpt: structuredClone(chatgpt) } : {}),
      },
    };
    const providers = this.data.providers.some((p) => p.id === id)
      ? this.data.providers.map((p) => (p.id === id ? provider : p))
      : [...this.data.providers, provider];
    this.persist({ ...this.data, providers }, credentials);
    return this.get();
  }
  getChatGPTHostId(): string {
    const file = join(this.dir, "chatgpt-host.json");
    if (existsSync(file)) {
      const data: unknown = JSON.parse(readFileSync(file, "utf8"));
      if (isJsonObject(data) && typeof data.hostId === "string") return data.hostId;
      throw new Error("storage: ChatGPT 安装标识损坏");
    }
    const hostId = `urn:uuid:${randomUUID()}`;
    atomicJson(file, { hostId });
    return hostId;
  }
  getChatGPTCredential(id: string): ChatGPTCredential | undefined {
    if (this.unreadCredentials) throw new Error("configuration: 保存的凭据尚未解锁");
    if (!usesChatGPTAuth(this.data.providers.find((p) => p.id === id)?.preset))
      throw new Error("configuration: 不是 ChatGPT 提供商");
    return structuredClone(this.credentials[id]?.chatgpt);
  }
  saveChatGPTCredential(id: string, credential: ChatGPTCredential): PublicSettings {
    this.getChatGPTCredential(id);
    if (!isChatGPTCredential(credential)) throw new Error("configuration: 无效 ChatGPT 凭据");
    this.persist(this.data, {
      ...this.credentials,
      [id]: {
        baseUrl: this.credentials[id]?.baseUrl ?? this.snapshot(id).baseUrl,
        apiKey: "",
        chatgpt: structuredClone(credential),
      },
    });
    return this.get();
  }
  disconnectChatGPT(id: string): PublicSettings {
    const credential = this.getChatGPTCredential(id);
    if (!credential) return this.get();
    const { identityPending: _identityPending, ...disconnected } = credential;
    return this.saveChatGPTCredential(id, {
      ...disconnected,
      access: "",
      refresh: "",
      idToken: "",
      expires: 0,
      scopes: [],
    });
  }
  async getRequestApiKey(
    id: string,
    fetcher: typeof fetch = fetch,
    rejectedApiKey?: string,
  ): Promise<string> {
    const provider = this.data.providers.find((p) => p.id === id);
    if (!provider) throw new Error("configuration: 所选提供商已删除");
    if (!usesChatGPTAuth(provider.preset)) return this.getProviderCredentials(id).apiKey;
    const pending = this.refreshes.get(id);
    if (pending) return pending;
    const credential = this.getChatGPTCredential(id);
    if (!credential?.access || !credential.refresh) throw new Error("configuration: 请先登录 ChatGPT");
    if (
      !credential.identityPending &&
      credential.access !== rejectedApiKey &&
      credential.expires > Date.now() + 3 * 60 * 1000
    )
      return credential.access;
    const refresh = (async () => {
      let expected = credential;
      const save = (next: ChatGPTCredential) => {
        const current = this.getChatGPTCredential(id);
        if (current?.refresh !== expected.refresh || current.access !== expected.access)
          throw new Error("configuration: ChatGPT 登录状态已改变,请重试");
        if (next !== expected) this.saveChatGPTCredential(id, next);
        expected = next;
      };
      const next = credential.identityPending
        ? await verifyRefreshedChatGPTCredential(credential, fetcher)
        : await refreshChatGPTCredential(credential, fetcher, save);
      save(next);
      return next.access;
    })();
    this.refreshes.set(id, refresh);
    try {
      return await refresh;
    } finally {
      if (this.refreshes.get(id) === refresh) this.refreshes.delete(id);
    }
  }
  reorderProviders(ids: string[]): PublicSettings {
    if (
      !Array.isArray(ids) ||
      ids.length !== this.data.providers.length ||
      new Set(ids).size !== ids.length ||
      ids.some((id) => !this.data.providers.some((provider) => provider.id === id))
    )
      throw new Error("invalid_input: 提供商排序必须包含全部提供商");
    const providers = [...this.data.providers].sort((a, b) => ids.indexOf(a.id) - ids.indexOf(b.id));
    const data = { ...this.data, providers };
    this.state.setSetting("app", data);
    this.data = data;
    return this.get();
  }
  discoverEnvironment(env: Record<string, string | undefined>): string[] {
    // Discovery is optional; never replace credentials that could not be decrypted.
    if (this.unreadCredentials) return [];
    const added: string[] = [];
    for (const discovery of discoverProviderCredentials(env)) {
      const preset = getProviderPreset(discovery.preset);
      if (!preset || this.data.providers.some((provider) => provider.preset === preset.id)) continue;
      const id = `env-${preset.id}`;
      if (this.data.providers.some((provider) => provider.id === id)) continue;
      this.saveProvider({
        id,
        preset: preset.id,
        name: preset.name,
        baseUrl: providerBaseUrl(preset.id),
        api: providerApi(preset.id),
        models: presetModels(preset.id, providerApi(preset.id)).map((model) => ({
          ...model,
          useRecommendedConfig: true,
          metadataSource: "catalog",
        })),
        apiKey: discovery.apiKey,
      });
      added.push(id);
    }
    return added;
  }
  deleteProvider(id: string): PublicSettings {
    const providers = this.data.providers.filter((p) => p.id !== id);
    if (providers.length === this.data.providers.length) throw new Error("not_found: 提供商不存在");
    const credentials = { ...this.credentials };
    delete credentials[id];
    this.persist({ ...this.data, providers }, credentials);
    return this.get();
  }
  rememberSelection(selection: CombinedSelection): void {
    if (!this.isSelectionValid(selection)) throw new Error("configuration: 请重新选择有效模型与思考程度");
    const data = { ...this.data, lastSelection: { ...selection } };
    this.state.setSetting("app", data);
    this.data = data;
  }
  updateShortcuts(input: ShortcutSettings): PublicSettings {
    return this.savePublic({ ...this.data, shortcuts: normalizeShortcuts(input, process.platform) });
  }
  updatePreferences(input: Partial<InterfacePreferences>): PublicSettings {
    if (Object.hasOwn(input, "notificationEnabled") && typeof input.notificationEnabled !== "boolean")
      throw new Error("configuration: 无效通知设置");
    const { notificationEnabled, ...preferences } = input;
    return this.savePublic({
      ...this.data,
      interface: { ...this.data.interface, ...preferences },
      ...(notificationEnabled !== undefined
        ? {
            plugins: {
              ...this.data.plugins,
              enabled: {
                ...this.data.plugins.enabled,
                [desktopPluginIds.notifications]: notificationEnabled,
              },
            },
          }
        : {}),
    });
  }
  setPluginEnabled(id: string, enabled: boolean): PublicSettings {
    if (
      (!builtinPluginCatalog.some((plugin) => plugin.id === id) &&
        !(this.state.getSetting<{ id: string }[]>("plugins.local") ?? []).some(
          (plugin) => plugin.id === id,
        )) ||
      typeof enabled !== "boolean"
    )
      throw new Error("invalid_input: 无效插件");
    return this.savePublic({
      ...this.data,
      plugins: { ...this.data.plugins, enabled: { ...this.data.plugins.enabled, [id]: enabled } },
    });
  }
  setPluginOptions(id: string, options: import("@zpi-dev/ai").JsonObject): PublicSettings {
    if (!builtinPluginCatalog.some((plugin) => plugin.id === id) || !isJsonObject(options))
      throw new Error("invalid_input: 无效插件配置");
    validateBuiltinPluginOptions(id, options);
    return this.savePublic({
      ...this.data,
      plugins: {
        ...this.data.plugins,
        options: { ...this.data.plugins.options, [id]: structuredClone(options) },
      },
    });
  }
  getTemplate(): PromptTemplate {
    return structuredClone(
      this.data.templates.find((t) => t.id === this.data.selectedTemplateId) ?? piTemplate,
    );
  }
  isSkillEnabled(path: string): boolean {
    return !this.data.disabledSkillPaths.includes(resolve(path));
  }
  setSkillEnabled(path: string, enabled: boolean): PublicSettings {
    if (typeof enabled !== "boolean") throw new Error("invalid_input: 无效技能状态");
    const normalized = resolve(path);
    return this.savePublic({
      ...this.data,
      disabledSkillPaths: [
        ...this.data.disabledSkillPaths.filter((p) => p !== normalized),
        ...(enabled ? [] : [normalized]),
      ],
    });
  }
  private savePublic(data: SettingsData): PublicSettings {
    this.validateData(data);
    this.state.setSetting("app", data);
    this.data = data;
    return this.get();
  }
  private persist(
    data: SettingsData,
    credentials: Record<string, Credentials>,
    webApiKey = this.webApiKey,
  ): void {
    this.validateData(data);
    if (this.unreadCredentials) throw new Error("configuration: 保存的凭据尚未解锁");
    const encrypted = this.encryption.isEncryptionAvailable();
    this.writeConfiguration(data, encrypted ? credentials : undefined, webApiKey);
    if (!encrypted) rmSync(join(this.dir, "credentials.enc"), { force: true });
    this.data = data;
    this.credentials = credentials;
    this.webApiKey = webApiKey;
    this.persisted = encrypted;
  }
  private writeConfiguration(
    data: SettingsData,
    credentials?: Record<string, Credentials>,
    webApiKey = this.webApiKey,
  ): void {
    // Credential-only updates reuse this.data; rotated tokens must not depend on an unchanged DB write.
    if (!credentials) {
      if (data !== this.data) this.state.setSetting("app", data);
      return;
    }
    // Preserve the encrypted-file contract; restore its bytes if the database commit fails.
    const encrypted = this.encryption.encryptString(
      JSON.stringify({ version: 4, providers: credentials, webSearch: { apiKey: webApiKey } }),
    );
    mkdirSync(this.dir, { recursive: true });
    const creds = join(this.dir, "credentials.enc");
    const token = randomUUID(),
      credsTemp = `${creds}.${token}.tmp`;
    const previous = existsSync(creds) ? readFileSync(creds) : undefined;
    let replaced = false;
    try {
      writeFileSync(credsTemp, encrypted, { mode: 0o600 });
      renameSync(credsTemp, creds);
      replaced = true;
      if (data !== this.data) this.state.setSetting("app", data);
    } catch (error) {
      if (replaced) {
        if (previous) {
          writeFileSync(credsTemp, previous, { mode: 0o600 });
          renameSync(credsTemp, creds);
        } else rmSync(creds, { force: true });
      }
      throw error;
    } finally {
      rmSync(credsTemp, { force: true });
    }
  }
  private validateData(data: SettingsData): void {
    assertSupportedOptions(
      data,
      [
        "version",
        "providers",
        "lastSelection",
        "interface",
        "templates",
        "selectedTemplateId",
        "disabledSkillPaths",
        "plugins",
        "webSearchEnabled",
        "shortcuts",
      ],
      "settings",
    );
    const shortcutError = validateShortcuts(data.shortcuts, process.platform);
    if (shortcutError) throw new Error(`storage: ${shortcutError}`);
    if (data.version !== 5 || !Array.isArray(data.providers)) throw new Error("storage: Invalid settings");
    if (typeof data.webSearchEnabled !== "boolean")
      throw new Error("storage: Invalid web search provider state");
    validatePluginSettings(data.plugins);
    if (
      !Array.isArray(data.templates) ||
      !Array.isArray(data.disabledSkillPaths) ||
      data.disabledSkillPaths.some((p) => typeof p !== "string" || p !== resolve(p)) ||
      !["pi", ...data.templates.map((t) => t.id)].includes(data.selectedTemplateId)
    )
      throw new Error("storage: Invalid prompt/skill settings");
    for (const t of data.templates) {
      validateTemplate(t);
      if (t.id === "pi") throw new Error("invalid_input: 内置模板只读");
    }
    if (new Set(data.templates.map((t) => t.id)).size !== data.templates.length)
      throw new Error("invalid_input: 重复模板 ID");
    for (const p of data.providers) this.validateProvider(p);
    if (new Set(data.providers.map((p) => p.id)).size !== data.providers.length)
      throw new Error("configuration: 重复提供商 ID");
    const selection = data.lastSelection;
    if (
      selection !== null &&
      (!isJsonObject(selection) ||
        typeof selection.provider !== "string" ||
        typeof selection.modelId !== "string" ||
        typeof selection.reasoning !== "string" ||
        !selection.reasoning.trim() ||
        selection.reasoning.length > 128)
    )
      throw new Error("storage: Invalid model selection");
    const prefs = data.interface;
    if (!isJsonObject(prefs)) throw new Error("storage: Invalid interface preferences");
    assertSupportedOptions(prefs, Object.keys(defaultStoredPreferences), "interface preferences");
    if (prefs.codePreview !== undefined && !isCodePreviewSettings(prefs.codePreview))
      throw new Error("configuration: 无效代码预览设置");
    if (
      [
        prefs.showContextUsage,
        prefs.showModelPerformance,
        prefs.showSendButton,
        prefs.notificationSoundEnabled,
        prefs.sidebarCollapsed,
        prefs.projectsCollapsed,
        prefs.tasksCollapsed,
      ].some((v) => typeof v !== "boolean") ||
      !["system", "light", "dark"].includes(prefs.theme) ||
      !Number.isInteger(prefs.fontSize) ||
      prefs.fontSize < uiFontSizeLimits.min ||
      prefs.fontSize > uiFontSizeLimits.max ||
      !Number.isFinite(prefs.sidebarWidth) ||
      prefs.sidebarWidth < sidebarLimits.min ||
      prefs.sidebarWidth > sidebarLimits.max ||
      !Array.isArray(prefs.collapsedProjectIds) ||
      prefs.collapsedProjectIds.some((id) => typeof id !== "string" || !/^[a-zA-Z0-9_-]+$/.test(id))
    )
      throw new Error("configuration: 无效界面设置");
  }
  private validateProvider(p: ProviderData): void {
    if (!isJsonObject(p)) throw new Error("configuration: 无效提供商");
    assertSupportedOptions(
      p,
      ["id", "name", "baseUrl", "models", "preset", "enabled", "api"],
      "provider record",
    );
    if (p.api !== undefined && !providerApis(p.preset).includes(p.api))
      throw new Error("configuration: 不支持的 API 格式");
    if (
      p.preset &&
      (!getProviderPreset(p.preset) ||
        p.baseUrl !== providerBaseUrl(p.preset, providerApi(p.preset, p.api ?? "openai-completions")))
    )
      throw new Error("configuration: 预置提供商的地址不匹配");
    if (
      typeof p.id !== "string" ||
      !/^[a-zA-Z0-9_-]+$/.test(p.id) ||
      typeof p.name !== "string" ||
      !p.name.trim() ||
      p.name.length > 200 ||
      (p.enabled !== undefined && typeof p.enabled !== "boolean")
    )
      throw new Error("configuration: 提供商名称不能为空");
    let url: URL;
    try {
      url = new URL(p.baseUrl);
    } catch {
      throw new Error("configuration: base URL 无效");
    }
    if (
      !["http:", "https:"].includes(url.protocol) ||
      url.username ||
      url.password ||
      url.search ||
      url.hash ||
      url.pathname.replace(/\/$/, "").endsWith("/chat/completions")
    )
      throw new Error("configuration: 输入 API 前缀 URL,例如 http://127.0.0.1:8000/v1");
    if (!Array.isArray(p.models)) throw new Error("configuration: 无效模型列表");
    for (const m of p.models) {
      if (!isJsonObject(m)) throw new Error("configuration: 无效模型");
      assertSupportedOptions(
        m,
        [
          "id",
          "name",
          "enabled",
          "useRecommendedConfig",
          "metadataSource",
          "input",
          "reasoning",
          "contextWindow",
          "maxTokens",
          "compat",
          "thinkingLevelMap",
          "reasoningConfig",
          "defaultThinkingLevel",
          "availability",
          "samplingParams",
        ],
        "model settings",
      );
      if (
        typeof m.id !== "string" ||
        !m.id.trim() ||
        m.id.length > 200 ||
        (m.name !== undefined && typeof m.name !== "string") ||
        (m.enabled !== undefined && typeof m.enabled !== "boolean") ||
        (m.reasoning !== undefined && typeof m.reasoning !== "boolean") ||
        (m.useRecommendedConfig !== undefined && typeof m.useRecommendedConfig !== "boolean") ||
        (m.metadataSource !== undefined &&
          !["remote", "catalog", "defaults"].includes(String(m.metadataSource)))
      )
        throw new Error("configuration: Model ID 必填");
      const context = m.contextWindow ?? modelDefaults.contextWindow,
        max = m.maxTokens ?? modelDefaults.maxTokens;
      if (!Number.isSafeInteger(context) || !Number.isSafeInteger(max) || max <= 0 || context < max)
        throw new Error("configuration: 上下文容量必须不小于输出上限且为正整数");
      if (
        m.input !== undefined &&
        (!Array.isArray(m.input) ||
          !m.input.includes("text") ||
          m.input.some((v) => !["text", "image", "video", "pdf"].includes(v)))
      )
        throw new Error("configuration: 输入类型仅支持文本、图片、视频和 PDF");
      if (m.compat !== undefined) {
        if (!isJsonObject(m.compat)) throw new Error("configuration: compat 必须为对象");
        assertSupportedOptions(
          m.compat,
          [...openAICompletionsCompatKeys, ...anthropicMessagesCompatKeys],
          "compat",
        );
        if (
          m.compat.structuredOutput !== undefined &&
          !["prompt", "json_object", "json_schema"].includes(m.compat.structuredOutput as string)
        )
          throw new Error("configuration: 无效 structuredOutput");
        for (const [k, v] of Object.entries(m.compat))
          if (
            k === "thinkingFormat"
              ? !["deepseek", "zai"].includes(String(v))
              : k === "maxTokensField"
                ? !["max_tokens", "max_completion_tokens"].includes(String(v))
                : k === "structuredOutput"
                  ? !["prompt", "json_object", "json_schema"].includes(String(v))
                  : k === "sessionAffinityFormat"
                    ? v !== "openrouter"
                    : k === "allowedFallbackModels"
                      ? !Array.isArray(v) ||
                        v.some(
                          (fallback) =>
                            !isJsonObject(fallback) ||
                            typeof fallback.provider !== "string" ||
                            typeof fallback.model !== "string" ||
                            !isJsonObject(fallback.cost) ||
                            ["input", "output", "cacheRead", "cacheWrite"].some(
                              (field) =>
                                typeof (fallback.cost as Record<string, unknown>)[field] !== "number" ||
                                (fallback.cost as Record<string, number>)[field] < 0,
                            ),
                        )
                      : typeof v !== "boolean"
          )
            throw new Error("configuration: 无效 compat");
      }
      if (m.reasoningConfig !== undefined) validateReasoningConfig(m.reasoningConfig);
      if (m.thinkingLevelMap !== undefined) validateThinkingMap(m.thinkingLevelMap);
      if (
        m.defaultThinkingLevel !== undefined &&
        (typeof m.defaultThinkingLevel !== "string" ||
          !m.defaultThinkingLevel.trim() ||
          !canControlThinking(resolveModel(p, m), m.defaultThinkingLevel))
      )
        throw new Error("configuration: 无效默认思考强度");
      if (m.availability !== undefined && !["listed", "unverified"].includes(m.availability))
        throw new Error("configuration: 无效模型可用性");
      if (m.samplingParams !== undefined && !isJsonObject(m.samplingParams))
        throw new Error("configuration: samplingParams 必须为 JSON 对象");
    }
    if (new Set(p.models.map((m) => m.id)).size !== p.models.length)
      throw new Error("configuration: 重复 Model ID");
  }
}