Z ZPI 文档 v0.3.8
介绍页

源码

packages/desktop/src/renderer/SettingsPage.tsx

tsx1172 行查看原文
import * as Dialog from "@radix-ui/react-dialog";
import * as Menu from "@radix-ui/react-dropdown-menu";
import {
  getProviderPreset,
  type ProviderApi,
  type ProviderPresetId,
  presetModels,
  providerApi,
  providerApis,
  providerBaseUrl,
  providerPresets,
  usesChatGPTAuth,
} from "@zpi-dev/ai";
import { ActionHint, codePreviewThemes, defaultCodePreview, SortableList } from "@zpi-dev/ui";
import {
  Anchor,
  Archive,
  ArrowLeft,
  Bot,
  Braces,
  Brain,
  ChevronRight,
  Globe,
  Info,
  Keyboard,
  Monitor,
  Moon,
  Package,
  Pencil,
  Plus,
  Puzzle,
  RefreshCcw,
  Settings2,
  Sparkles,
  Sun,
  Terminal,
  Trash2,
  Wrench,
} from "lucide-react";
import { useEffect, useRef, useState } from "react";
import type { InterfacePreferences, ModelSettings, ProviderRecord } from "../shared/bridge.ts";
import { ArchivedTasks } from "./ArchivedTasks.tsx";
import { ChatGPTConnection } from "./ChatGPTConnection.tsx";
import { CommandSettings } from "./CommandSettings.tsx";
import { HooksSettings } from "./HooksSettings.tsx";
import { MemorySettings } from "./MemorySettings.tsx";
import { draftModel, ModelConfigDialog, type ModelDraft, serializeModel } from "./ModelConfigDialog.tsx";
import { PluginSettings } from "./PluginSettings.tsx";
import { ProviderApiFormatSelect } from "./ProviderApiFormatSelect.tsx";
import { ProviderCredentialInput } from "./ProviderCredentialInput.tsx";
import { ProviderHeadingActions } from "./ProviderHeadingActions.tsx";
import { ProviderLogo } from "./ProviderLogo.tsx";
import { ResourceSettings } from "./ResourceSettings.tsx";
import { SettingsBreadcrumbProvider } from "./SettingsBreadcrumb.tsx";
import { SettingsHelp } from "./SettingsHelp.tsx";
import { SettingsSelect } from "./SettingsSelect.tsx";
import { ShortcutSettings } from "./ShortcutSettings.tsx";
import { SubagentSettings } from "./SubagentSettings.tsx";
import { refresh, useStore } from "./store.ts";
import { unwrap } from "./unwrap.ts";
import { userErrorMessage } from "./user-error.ts";
import { WebSearchSettings } from "./WebSearchSettings.tsx";

const sections = [
  ["general", "常规", Settings2],
  ["interface", "界面设置", Monitor],
  ["shortcuts", "快捷键", Keyboard],
  ["prompt", "系统提示词", Sparkles],
  ["tools", "工具", Wrench],
  ["skills", "技能", Puzzle],
  ["commands", "命令", Terminal],
  ["models", "模型", Braces],
  ["plugins", "插件", Package],
  ["hooks", "钩子", Anchor],
  ["webSearch", "联网搜索", Globe],
  ["subagents", "子智能体", Bot],
  ["memory", "记忆", Brain],
  ["archived", "已归档任务", Archive],
] as const;
type SettingsTab = (typeof sections)[number][0];
const pageHelp: Partial<Record<SettingsTab, string>> = {
  subagents: "管理子智能体的提示词、工具、模型与作用域。保存后返回列表。",
  interface: "主题和界面字号影响本机显示,代码预览可单独配置。",
  models: "配置提供商和模型后,可在聊天中选择使用。模型和提供商可分别启停。",
  plugins: "插件在新对话首次发送时生效,已有对话保留冻结的版本、工具和选项。",
  hooks:
    "钩子在任务生命周期事件中运行本机命令。用户和项目配置保存在本机应用数据中,对新会话生效。插件自带的钩子在所属插件中管理。",
  commands: "在输入框键入 / 调用命令。可配置个人或项目命令,也可导入外部 Agent 的命令。",
};

import {
  defaultPreferences,
  mergeDiscoveredModels,
  modelDefaults,
  uiFontSizeLimits,
} from "../shared/config.ts";

const recommendedModels = (provider?: ProviderRecord) =>
  new Map<string, ModelSettings>([
    ...presetModels(provider?.preset ?? "", provider?.api ?? "openai-completions").map(
      (model) => [model.id, model] as const,
    ),
    ...(provider?.models ?? [])
      .filter((model) => model.useRecommendedConfig && model.metadataSource)
      .map((model) => [model.id, model] as const),
  ]);
const contextLabel = (value: number) =>
  new Intl.NumberFormat("en-US", { notation: "compact", maximumFractionDigits: 1 }).format(value);
const newModel = (api: ProviderApi) => ({
  id: "",
  ...modelDefaults,
  ...(api === "anthropic-messages" ? { compat: { supportsEagerToolInputStreaming: true } } : {}),
  input: ["text"] as ModelSettings["input"],
  useRecommendedConfig: false,
});
function FontSizeInput({
  value,
  onChange,
  label = "界面字号",
}: {
  value: number;
  onChange: (value: number) => void;
  label?: string;
}) {
  const [draft, setDraft] = useState(String(value));
  const commit = () => {
    const number = draft.trim() ? Number(draft) : Number.NaN;
    const next = Number.isFinite(number)
      ? Math.max(uiFontSizeLimits.min, Math.min(uiFontSizeLimits.max, Math.round(number)))
      : value;
    setDraft(String(next));
    if (next !== value) onChange(next);
  };
  return (
    <div className="settings-font-size">
      <input
        aria-label={label}
        type="number"
        inputMode="numeric"
        min={uiFontSizeLimits.min}
        max={uiFontSizeLimits.max}
        step={1}
        value={draft}
        onChange={(event) => setDraft(event.target.value)}
        onBlur={commit}
        onKeyDown={(event) => {
          if (event.key === "Enter") event.currentTarget.blur();
          if (event.key === "Escape") {
            event.preventDefault();
            event.stopPropagation();
            setDraft(String(value));
          }
        }}
      />
      <span aria-hidden="true">px</span>
    </div>
  );
}
export function SettingsPage({ onClose }: { onClose: () => void }) {
  const opener = useRef(document.activeElement);
  const [detail, setDetail] = useState(false);
  const [breadcrumbContainer, setBreadcrumbContainer] = useState<HTMLDivElement | null>(null);
  const settings = useStore((s) => s.settings);
  const back = useRef<HTMLButtonElement>(null);
  useEffect(() => {
    back.current?.focus();
    return () => {
      if (opener.current instanceof HTMLElement && opener.current.isConnected) opener.current.focus();
    };
  }, []);
  const [selected, setSelected] = useState<string | undefined>(settings?.providers[0]?.id);
  const original = settings?.providers.find((p) => p.id === selected);
  const [name, setName] = useState(original?.name ?? "");
  const [enabled, setEnabled] = useState(original?.enabled !== false);
  const [url, setUrl] = useState(original?.baseUrl ?? "");
  const [models, setModels] = useState<ModelDraft[]>((original?.models ?? []).map(draftModel));
  const [editing, setEditing] = useState<{ index: number; draft: ModelDraft }>();
  const [preset, setPreset] = useState<ProviderPresetId | undefined>(original?.preset);
  const chatgpt = usesChatGPTAuth(preset);
  const [api, setApi] = useState<ProviderApi>(
    original ? providerApi(original.preset, original.api ?? "openai-completions") : "anthropic-messages",
  );
  const [picker, setPicker] = useState(!settings?.providers.length);
  const [discovering, setDiscovering] = useState(false);
  const discoveryRevision = useRef(0);
  const recommended = useRef(recommendedModels(original));
  const [key, setKey] = useState("");
  const [keyVisible, setKeyVisible] = useState(false);
  const [credentialRequest, setCredentialRequest] = useState(0);
  const [keyReady, setKeyReady] = useState(!selected);
  const [tab, setTab] = useState<SettingsTab>("interface");
  const codePreview = settings?.interface.codePreview ?? defaultCodePreview;
  const title = sections.find(([id]) => id === tab)?.[1];
  const [error, setError] = useState("");
  const [notice, setNotice] = useState("");
  const [saving, setSaving] = useState(false);
  const [confirmDelete, setConfirmDelete] = useState(false);
  const [renaming, setRenaming] = useState(false);
  const savedDraft = useRef({ name, enabled, url, models, preset, api, key });
  const [pendingSelection, setPendingSelection] = useState<(() => void) | null>(null);
  const requestSelection = (action: () => void) => {
    if (saving) return;
    if (
      JSON.stringify({ name, enabled, url, models, preset, api, key }) !== JSON.stringify(savedDraft.current)
    )
      setPendingSelection(() => action);
    else action();
  };
  const renameInput = useRef<HTMLInputElement>(null);
  const renameStart = useRef("");
  const renameRequested = useRef(false);
  useEffect(
    () => () => {
      discoveryRevision.current++;
    },
    [],
  );
  const select = (p?: ProviderRecord) => {
    const draft = {
      name: p?.name ?? "",
      enabled: p?.enabled !== false,
      url: p?.baseUrl ?? "",
      models: (p?.models ?? []).map(draftModel),
      preset: p?.preset,
      api: p ? providerApi(p.preset, p.api ?? "openai-completions") : "anthropic-messages",
      key: "",
    };
    savedDraft.current = draft;
    discoveryRevision.current++;
    setDiscovering(false);
    setSelected(p?.id);
    setPreset(draft.preset);
    setApi(draft.api);
    setPicker(false);
    setEditing(undefined);
    recommended.current = recommendedModels(p);
    setName(draft.name);
    setEnabled(draft.enabled);
    setUrl(draft.url);
    setModels(draft.models);
    setKey(draft.key);
    setKeyVisible(false);
    setKeyReady(!p);
    setCredentialRequest((value) => value + 1);
    setError("");
    setNotice("");
    setConfirmDelete(false);
    setRenaming(false);
  };
  useEffect(() => {
    let active = true;
    setKey("");
    setKeyReady(!selected);
    if (chatgpt) {
      setKeyReady(true);
      return;
    }
    if (selected)
      void window.zpi
        .getProviderCredentials(selected)
        .then(unwrap)
        .then((value) => {
          if (active) {
            savedDraft.current = { ...savedDraft.current, key: value.apiKey };
            setKey(value.apiKey);
            setKeyReady(true);
          }
        })
        .catch((e) => {
          if (active) {
            setError(userErrorMessage(e));
            setKeyReady(false);
          }
        });
    return () => {
      active = false;
    };
  }, [selected, credentialRequest, chatgpt]);
  const loadModels = async () => {
    if (!keyReady) return;
    const revision = ++discoveryRevision.current;
    setDiscovering(true);
    setError("");
    try {
      const result = unwrap(
        await window.zpi.discoverModels({
          api,
          ...(preset ? { preset } : { baseUrl: url }),
          ...(selected ? { providerId: selected } : {}),
          ...(!chatgpt ? { apiKey: key } : {}),
        }),
      );
      if (revision !== discoveryRevision.current) return;
      recommended.current = new Map(result.models.map((model) => [model.id, model]));
      setModels((current) => mergeDiscoveredModels(current, result.models.map(draftModel)));
      setNotice(result.warning ?? `已获取 ${result.models.length} 个模型。`);
    } catch (error) {
      if (revision === discoveryRevision.current) setError(userErrorMessage(error));
    } finally {
      if (revision === discoveryRevision.current) setDiscovering(false);
    }
  };
  const selectPreset = (id: ProviderPresetId) => {
    const descriptor = getProviderPreset(id);
    if (!descriptor) return;
    select();
    setPreset(id);
    setName(descriptor.name);
    const nextApi = providerApi(id);
    setApi(nextApi);
    setUrl(providerBaseUrl(id, nextApi));
    const catalog = presetModels(id, nextApi);
    recommended.current = new Map(catalog.map((model) => [model.id, model]));
    setModels(
      catalog.map((model) => draftModel({ ...model, useRecommendedConfig: true, metadataSource: "catalog" })),
    );
  };
  const changeApi = (nextApi: ProviderApi) => {
    discoveryRevision.current++;
    setDiscovering(false);
    setApi(nextApi);
    if (preset) setUrl(providerBaseUrl(preset, nextApi));
    const defaults = new Map(presetModels(preset ?? "", nextApi).map((model) => [model.id, model]));
    recommended.current = defaults;
    setModels((current) =>
      current.map((model) => {
        const known = defaults.get(model.id);
        const next = {
          ...model,
          ...(known && model.useRecommendedConfig !== false ? known : {}),
          compat: known?.compat ?? newModel(nextApi).compat,
        };
        delete (next as ModelSettings).reasoningConfig;
        if (nextApi === "anthropic-messages" && model.useRecommendedConfig !== false)
          delete next.samplingParams;
        delete next.defaultThinkingLevel;
        if (known?.defaultThinkingLevel) next.defaultThinkingLevel = known.defaultThinkingLevel;
        if (known?.thinkingLevelMap) next.thinkingLevelMap = known.thinkingLevelMap;
        else delete next.thinkingLevelMap;
        return draftModel(next);
      }),
    );
    setEditing(undefined);
    setError("");
    setNotice("已切换协议并更新推理参数映射,保存后生效。");
  };
  const updateAppearance = (input: Partial<InterfacePreferences>) => {
    void window.zpi
      .updatePreferences(input)
      .then(unwrap)
      .then((value) => useStore.setState({ settings: value }))
      .catch((error) => setError(userErrorMessage(error)));
  };
  const save = async (nextModels = models, nextEnabled = enabled, nextName = name) => {
    if (!keyReady) return false;
    setSaving(true);
    setError("");
    try {
      let discoveryNotice = "";
      if (!selected && preset && !chatgpt && key.trim()) {
        const result = unwrap(
          await window.zpi.discoverModels({
            preset,
            api,
            apiKey: key,
          }),
        );
        nextModels = mergeDiscoveredModels(nextModels, result.models.map(draftModel));
        recommended.current = new Map(result.models.map((model) => [model.id, model]));
        discoveryNotice = result.warning ?? "";
      }
      const value = unwrap(
        await window.zpi.saveProvider({
          ...(selected ? { id: selected } : {}),
          ...(preset ? { preset } : {}),
          api,
          name: nextName,
          enabled: nextEnabled,
          baseUrl: url,
          models: nextModels.map(serializeModel),
          ...(!chatgpt ? { apiKey: key } : {}),
        }),
      );
      useStore.setState({ settings: value });
      const saved = value.providers.find((provider) => provider.id === selected) ?? value.providers.at(-1);
      select(saved);
      await refresh();
      setNotice(discoveryNotice || "提供商已保存。");
      return true;
    } catch (error) {
      setError(userErrorMessage(error));
      return false;
    } finally {
      setSaving(false);
    }
  };
  return (
    <section
      className="settings-screen"
      aria-label="设置"
      onKeyDown={(event) => {
        if (
          event.key === "Escape" &&
          !event.nativeEvent.isComposing &&
          event.keyCode !== 229 &&
          !event.defaultPrevented &&
          !(event.target instanceof Element && event.target.closest('[role="dialog"]'))
        ) {
          event.preventDefault();
          event.stopPropagation();
          requestSelection(onClose);
        }
      }}
    >
      <aside className="settings-sidebar">
        <div className="settings-sidebar-drag-space" aria-hidden="true" />
        <ActionHint appearance="control" label="返回工作区" side="right">
          <button
            ref={back}
            className="settings-back"
            aria-label="关闭设置"
            disabled={saving}
            onClick={() => requestSelection(onClose)}
          >
            <ArrowLeft size={16} />
            <span>返回任务</span>
          </button>
        </ActionHint>
        <nav className="settings-tabs" aria-label="设置分类">
          {sections.map(([id, label, Icon]) => (
            <ActionHint appearance="control" label={label} side="right" key={id}>
              <button aria-label={label} aria-pressed={tab === id} onClick={() => setTab(id)}>
                <Icon size={16} />
                <span>{label}</span>
              </button>
            </ActionHint>
          ))}
        </nav>
      </aside>
      <div className="settings-main">
        <div className="settings-titlebar" ref={setBreadcrumbContainer} aria-hidden={!detail}>
          {detail && pageHelp[tab] && <SettingsHelp label={title ?? "设置"} text={pageHelp[tab] ?? ""} />}
        </div>
        <div className="settings-scroll">
          <div
            className={`settings-content ${tab === "models" ? "model-settings-content" : ""}${detail ? " settings-detail-content" : ""}`}
          >
            {!["archived", "shortcuts", "webSearch", "subagents"].includes(tab) && !detail && (
              <div className="settings-page-heading">
                <div className="settings-heading-title">
                  <h1>{tab === "models" ? "模型设置" : tab === "interface" ? "外观" : title}</h1>
                  {pageHelp[tab] && <SettingsHelp label={title ?? "设置"} text={pageHelp[tab] ?? ""} />}
                </div>
              </div>
            )}
            <SettingsBreadcrumbProvider container={breadcrumbContainer} onDetailChange={setDetail}>
              <div className="settings-page">
                {tab === "commands" ? (
                  <CommandSettings />
                ) : tab === "hooks" ? (
                  <HooksSettings onOpenPlugins={() => setTab("plugins")} />
                ) : tab === "archived" ? (
                  <ArchivedTasks />
                ) : tab === "shortcuts" ? (
                  <ShortcutSettings />
                ) : tab === "general" ? (
                  <div className="general-settings">
                    <div className="settings-group">
                      {(
                        [
                          ["notificationEnabled", "任务通知", "任务完成、失败或需要确认时发送桌面通知。"],
                          ["notificationSoundEnabled", "通知声音", "通知开启后,可单独关闭任务通知提示音。"],
                        ] as const
                      ).map(([field, label, description]) => (
                        <div className="settings-row" key={field}>
                          <span className="settings-label">
                            <strong>{label}</strong>
                            <SettingsHelp label={label} text={description} />
                          </span>
                          <span className="settings-switch task-notification-switch">
                            <input
                              type="checkbox"
                              role="switch"
                              aria-label={label}
                              aria-checked={settings?.interface[field] ?? defaultPreferences[field]}
                              checked={settings?.interface[field] ?? defaultPreferences[field]}
                              disabled={
                                field === "notificationSoundEnabled" &&
                                !(settings?.interface.notificationEnabled ?? true)
                              }
                              onChange={(event) => updateAppearance({ [field]: event.target.checked })}
                            />
                            <span aria-hidden="true" />
                          </span>
                        </div>
                      ))}
                    </div>
                    {error && <div role="alert">{error}</div>}
                  </div>
                ) : tab === "webSearch" ? (
                  <WebSearchSettings />
                ) : tab === "memory" ? (
                  <MemorySettings onNavigate={onClose} />
                ) : tab === "subagents" ? (
                  <SubagentSettings />
                ) : tab === "plugins" ? (
                  <PluginSettings />
                ) : tab === "prompt" || tab === "tools" || tab === "skills" ? (
                  <ResourceSettings page={tab} />
                ) : tab === "interface" ? (
                  <div className="interface-settings">
                    <h2>界面</h2>
                    <div className="settings-group">
                      <div className="settings-row">
                        <span className="settings-row-copy">
                          <strong>界面主题</strong>
                        </span>
                        <SettingsSelect
                          label="界面主题"
                          value={settings?.interface.theme ?? "system"}
                          options={[
                            { value: "system", label: "系统", icon: <Monitor size={16} /> },
                            { value: "dark", label: "深色", icon: <Moon size={16} /> },
                            { value: "light", label: "浅色", icon: <Sun size={16} /> },
                          ]}
                          onChange={(theme) => updateAppearance({ theme })}
                        />
                      </div>
                      <div className="settings-row">
                        <span className="settings-label">
                          <strong>界面字号</strong>
                          <SettingsHelp label="界面字号" text="仅调整界面文字,图标和布局尺寸保持不变。" />
                        </span>
                        <FontSizeInput
                          key={settings?.interface.fontSize ?? defaultPreferences.fontSize}
                          value={settings?.interface.fontSize ?? defaultPreferences.fontSize}
                          onChange={(fontSize) => updateAppearance({ fontSize })}
                        />
                      </div>
                    </div>
                    <h2>审查代码预览</h2>
                    <div className="settings-group">
                      {(
                        [
                          ["lightTheme", "浅色代码主题"],
                          ["darkTheme", "深色代码主题"],
                        ] as const
                      ).map(([field, label]) => (
                        <div className="settings-row" key={field}>
                          <span className="settings-row-copy">
                            <strong>{label}</strong>
                          </span>
                          <SettingsSelect
                            label={label}
                            value={codePreview[field]}
                            options={codePreviewThemes}
                            onChange={(value) =>
                              updateAppearance({ codePreview: { ...codePreview, [field]: value } })
                            }
                          />
                        </div>
                      ))}
                      <div className="settings-row">
                        <span className="settings-row-copy">
                          <strong>代码字号</strong>
                        </span>
                        <FontSizeInput
                          label="代码字号"
                          value={codePreview.fontSizePx}
                          key={codePreview.fontSizePx}
                          onChange={(fontSizePx) =>
                            updateAppearance({ codePreview: { ...codePreview, fontSizePx } })
                          }
                        />
                      </div>
                      {(
                        [
                          ["showLineNumbers", "显示行号"],
                          ["wrapLongLines", "长行自动换行"],
                        ] as const
                      ).map(([field, label]) => (
                        <div className="settings-row" key={field}>
                          <span className="settings-row-copy">
                            <strong>{label}</strong>
                          </span>
                          <span className="settings-switch">
                            <input
                              type="checkbox"
                              aria-label={label}
                              checked={codePreview[field]}
                              onChange={(event) =>
                                updateAppearance({
                                  codePreview: { ...codePreview, [field]: event.target.checked },
                                })
                              }
                            />
                            <span aria-hidden="true" />
                          </span>
                        </div>
                      ))}
                    </div>
                    <h2 className="settings-label">
                      聊天 <SettingsHelp label="聊天" text="Enter 发送,Shift + Enter 换行。" />
                    </h2>
                    <div className="settings-group">
                      {(
                        [
                          [
                            "showModelPerformance",
                            "显示模型性能与用量",
                            "在输入框下方显示整个任务的性能与用量,点击查看时间和 token 明细。",
                          ],
                          [
                            "showContextUsage",
                            "显示上下文占用",
                            "在输入栏显示当前模型的上下文容量和使用情况。",
                          ],
                          [
                            "showSendButton",
                            "显示发送按钮",
                            "在输入栏右下方显示发送按钮;运行时显示停止按钮。",
                          ],
                        ] as const
                      ).map(([field, label, description]) => (
                        <div className="settings-row" key={field}>
                          <span className="settings-label">
                            <strong>{label}</strong>
                            <SettingsHelp label={label} text={description} />
                          </span>
                          <span className="settings-switch">
                            <input
                              type="checkbox"
                              aria-label={label}
                              checked={settings?.interface[field] ?? false}
                              onChange={(e) => {
                                void window.zpi
                                  .updatePreferences({ [field]: e.target.checked })
                                  .then(unwrap)
                                  .then((value) => useStore.setState({ settings: value }))
                                  .catch((e) => setError(userErrorMessage(e)));
                              }}
                            />
                            <span aria-hidden="true" />
                          </span>
                        </div>
                      ))}
                    </div>
                    {error && <div role="alert">{error}</div>}
                  </div>
                ) : (
                  <div className="model-provider-section">
                    <div className="resource-section-heading model-settings-toolbar">
                      <div className="model-section-actions">
                        <button
                          className="model-refresh"
                          aria-label="获取模型"
                          title="刷新模型列表"
                          disabled={
                            saving ||
                            discovering ||
                            picker ||
                            !keyReady ||
                            !url.trim() ||
                            (chatgpt && !original?.chatgptAccount?.connected)
                          }
                          onClick={() => void loadModels()}
                        >
                          <RefreshCcw size={16} className={discovering ? "spin" : undefined} />
                        </button>
                        <button
                          className="primary"
                          disabled={saving || discovering}
                          onClick={() => setPicker(true)}
                        >
                          <Plus size={14} />
                          新增提供商
                        </button>
                      </div>
                    </div>
                    <div className="settings-layout model-provider-panel">
                      <nav className="provider-list" aria-label="提供商列表">
                        <div className="provider-nav-group">
                          <h3>供应商</h3>
                          <SortableList
                            items={settings?.providers ?? []}
                            disabled={saving || discovering}
                            onReorder={(providers) => {
                              setSaving(true);
                              setError("");
                              void window.zpi
                                .reorderProviders(providers.map((provider) => provider.id))
                                .then(unwrap)
                                .then((settings) => useStore.setState({ settings }))
                                .catch((error) => setError(userErrorMessage(error)))
                                .finally(() => setSaving(false));
                            }}
                            renderItem={(
                              provider,
                              { setNodeRef, style, attributes, listeners, isDragging },
                            ) => (
                              <ActionHint appearance="control" label={provider.name} side="right">
                                <button
                                  ref={setNodeRef}
                                  style={style}
                                  {...attributes}
                                  {...listeners}
                                  data-sortable-select
                                  data-provider-id={provider.id}
                                  className={`sortable-provider ${provider.id === selected && !picker ? "selected" : ""}${isDragging ? " dragging" : ""}`}
                                  disabled={saving || discovering}
                                  aria-label={provider.name}
                                  onClick={() => requestSelection(() => select(provider))}
                                >
                                  <ProviderLogo preset={provider.preset} size={16} />
                                  <span>{provider.name}</span>
                                  <i
                                    className={`provider-status ${provider.enabled === false ? "disabled" : "enabled"}`}
                                    title={provider.enabled === false ? "已关闭" : "已启用"}
                                    aria-hidden="true"
                                  />
                                </button>
                              </ActionHint>
                            )}
                          />
                        </div>
                      </nav>
                      <div className="provider-editor">
                        {picker ? (
                          <section className="provider-template-picker">
                            <header>
                              {Boolean(settings?.providers.length) && (
                                <button aria-label="返回提供商" onClick={() => setPicker(false)}>
                                  <ArrowLeft size={16} />
                                </button>
                              )}
                              <h2>添加模型提供商</h2>
                            </header>
                            <h3>预置与自定义供应商</h3>
                            <div className="provider-template-grid">
                              <ActionHint appearance="control" label="自定义提供商">
                                <button onClick={() => requestSelection(() => select())}>
                                  <Package size={24} />
                                  <span className="provider-template-label">自定义提供商</span>
                                  <ChevronRight size={16} />
                                </button>
                              </ActionHint>
                              {providerPresets.map((item) => (
                                <ActionHint appearance="control" label={item.name} key={item.id}>
                                  <button onClick={() => requestSelection(() => selectPreset(item.id))}>
                                    <ProviderLogo preset={item.id} size={32} />
                                    <span className="provider-template-label">{item.name}</span>
                                    <ChevronRight size={16} />
                                  </button>
                                </ActionHint>
                              ))}
                            </div>
                          </section>
                        ) : (
                          <div className="provider-details">
                            <header className="provider-detail-heading">
                              <ProviderLogo preset={preset} size={20} />
                              {renaming ? (
                                <input
                                  ref={renameInput}
                                  className="provider-rename-input"
                                  aria-label="重命名供应商"
                                  value={name}
                                  onChange={(event) => setName(event.target.value)}
                                  onBlur={() => {
                                    setRenaming(false);
                                    if (name.trim() && name.trim() !== renameStart.current)
                                      void save(models, enabled, name.trim());
                                    else setName(renameStart.current);
                                  }}
                                  onKeyDown={(event) => {
                                    if (event.key === "Enter") event.currentTarget.blur();
                                    if (event.key === "Escape") {
                                      event.stopPropagation();
                                      setName(renameStart.current);
                                      setRenaming(false);
                                    }
                                  }}
                                />
                              ) : (
                                <h2>{name || "新提供商"}</h2>
                              )}
                              <ProviderHeadingActions
                                enabled={enabled}
                                name={name}
                                disabled={saving || discovering}
                                switchDisabled={saving || discovering || !keyReady}
                                onEnabledChange={(value) => {
                                  if (selected) void save(models, value);
                                  else setEnabled(value);
                                }}
                                onCloseAutoFocus={(event) => {
                                  if (renameRequested.current) {
                                    event.preventDefault();
                                    renameRequested.current = false;
                                    renameInput.current?.focus();
                                    renameInput.current?.select();
                                  }
                                }}
                              >
                                {selected && (
                                  <>
                                    {!preset && (
                                      <>
                                        <Menu.Item
                                          className="provider-action-item"
                                          onSelect={() => {
                                            renameStart.current = name;
                                            renameRequested.current = true;
                                            setRenaming(true);
                                          }}
                                        >
                                          <Pencil size={14} />
                                          重命名
                                        </Menu.Item>
                                        <Menu.Separator className="provider-action-separator" />
                                      </>
                                    )}
                                    <Menu.Item
                                      className="provider-action-item provider-action-delete"
                                      onSelect={() => setConfirmDelete(true)}
                                    >
                                      <Trash2 size={14} />
                                      删除
                                    </Menu.Item>
                                  </>
                                )}
                              </ProviderHeadingActions>
                            </header>
                            <div className="provider-connection-fields">
                              {!preset && (
                                <>
                                  <label>
                                    <span>提供商名称</span>
                                    <input
                                      aria-label="提供商名称"
                                      disabled={saving || discovering}
                                      value={name}
                                      onChange={(event) => setName(event.target.value)}
                                    />
                                  </label>
                                  <label>
                                    <span>Base URL</span>
                                    <input
                                      aria-label="Base URL"
                                      placeholder={
                                        api === "anthropic-messages"
                                          ? "http://127.0.0.1:8000"
                                          : "http://127.0.0.1:8000/v1"
                                      }
                                      disabled={saving || discovering}
                                      value={url}
                                      onChange={(event) => setUrl(event.target.value)}
                                    />
                                  </label>
                                </>
                              )}
                              {preset && !chatgpt && preset !== "openai-api" && (
                                <label>
                                  <span>Base URL</span>
                                  <input aria-label="Base URL" readOnly value={url} />
                                </label>
                              )}
                              <div className="provider-api-field">
                                <label htmlFor="provider-api-format">API 格式</label>
                                <ProviderApiFormatSelect
                                  value={api}
                                  preset={preset}
                                  disabled={
                                    providerApis(preset).length === 1 || saving || discovering || !keyReady
                                  }
                                  onChange={changeApi}
                                />
                              </div>
                              {chatgpt ? (
                                <ChatGPTConnection
                                  key={selected ?? "new-chatgpt"}
                                  provider={original}
                                  onBusy={setSaving}
                                  onError={setError}
                                  onNotice={setNotice}
                                  onSettings={(value, providerId) => {
                                    useStore.setState({ settings: value });
                                    select(value.providers.find((provider) => provider.id === providerId));
                                    void refresh();
                                  }}
                                />
                              ) : (
                                <label htmlFor="model-provider-api-key">
                                  <span>API Key</span>
                                  <ProviderCredentialInput
                                    id="model-provider-api-key"
                                    visible={keyVisible}
                                    onToggleVisibility={() => setKeyVisible(!keyVisible)}
                                    value={key}
                                    onChange={(event) => setKey(event.target.value)}
                                    disabled={!keyReady || saving || discovering}
                                    placeholder={keyReady ? "输入 API Key" : "正在读取凭据…"}
                                  />
                                </label>
                              )}
                            </div>
                            <section className="provider-models" aria-label="模型列表">
                              <div className="model-list-heading">
                                <h3>模型列表</h3>
                                <div className="model-list-actions">
                                  <button
                                    className="secondary"
                                    disabled={saving || discovering}
                                    onClick={() =>
                                      setEditing({ index: models.length, draft: draftModel(newModel(api)) })
                                    }
                                  >
                                    <Plus size={14} />
                                    新增模型
                                  </button>
                                </div>
                              </div>
                              {models.some((model) => model.id) ? (
                                <div className="provider-model-list">
                                  <SortableList
                                    items={models.filter((model) => model.id)}
                                    disabled={saving || discovering || !keyReady || Boolean(editing)}
                                    onReorder={(next) => {
                                      setModels(next);
                                      if (selected)
                                        void save(next).then((saved) => {
                                          if (!saved) setModels(models);
                                        });
                                    }}
                                    renderItem={(
                                      model,
                                      { setNodeRef, style, attributes, listeners, isDragging },
                                    ) => {
                                      const index = models.indexOf(model);
                                      return (
                                        // biome-ignore lint/a11y/useSemanticElements: The sortable row contains independent edit, delete and switch controls.
                                        <div
                                          ref={setNodeRef}
                                          style={style}
                                          {...attributes}
                                          {...listeners}
                                          data-model-id={model.id}
                                          role="button"
                                          tabIndex={0}
                                          aria-label={`拖拽排序模型 ${model.id}`}
                                          className={`provider-model-row sortable-model${isDragging ? " dragging" : ""}`}
                                        >
                                          <div className="model-row-summary">
                                            <span className="model-row-name" title={model.name || model.id}>
                                              {model.name || model.id}
                                            </span>
                                            <span
                                              className="model-context-badge"
                                              title={`上下文窗口 ${model.contextWindow ?? modelDefaults.contextWindow}`}
                                            >
                                              {contextLabel(
                                                model.contextWindow ?? modelDefaults.contextWindow,
                                              )}
                                            </span>
                                            {model.input?.includes("image") && (
                                              <span className="model-vision-badge">视觉</span>
                                            )}
                                          </div>
                                          <button
                                            disabled={saving || discovering}
                                            aria-label={`编辑模型 ${model.id}`}
                                            title="编辑模型配置"
                                            onClick={() => setEditing({ index, draft: model })}
                                          >
                                            <Pencil size={14} />
                                          </button>
                                          <button
                                            disabled={saving || discovering}
                                            aria-label={`删除模型 ${model.id}`}
                                            title="删除模型"
                                            onClick={() =>
                                              setModels(models.filter((_, item) => item !== index))
                                            }
                                          >
                                            <Trash2 size={14} />
                                          </button>
                                          <label className="settings-switch model-enabled-switch">
                                            <input
                                              type="checkbox"
                                              role="switch"
                                              aria-label={`启用模型 ${model.id}`}
                                              aria-checked={model.enabled !== false}
                                              checked={model.enabled !== false}
                                              disabled={saving || discovering || !keyReady}
                                              onChange={(event) => {
                                                const next = models.map((item, at) =>
                                                  at === index
                                                    ? { ...item, enabled: event.target.checked }
                                                    : item,
                                                );
                                                if (selected) void save(next);
                                                else setModels(next);
                                              }}
                                            />
                                            <span />
                                          </label>
                                        </div>
                                      );
                                    }}
                                  />
                                </div>
                              ) : (
                                <div className="provider-model-empty">
                                  <Info size={16} aria-hidden="true" />
                                  暂无模型,点击“新增模型”添加。
                                </div>
                              )}
                            </section>
                            <div className="provider-footer">
                              {error && (
                                <div className="run-error" role="alert">
                                  {error}
                                </div>
                              )}
                              {notice && (
                                <div className="run-notice" role="status">
                                  {notice}
                                </div>
                              )}
                              <div className="modal-actions">
                                <button
                                  className="primary"
                                  disabled={
                                    saving ||
                                    !keyReady ||
                                    (chatgpt ? !selected : Boolean(preset) && !key.trim()) ||
                                    discovering
                                  }
                                  onClick={() => void save()}
                                >
                                  保存提供商
                                </button>
                              </div>
                            </div>
                          </div>
                        )}
                      </div>
                    </div>
                    <Dialog.Root
                      open={confirmDelete}
                      onOpenChange={(open) => {
                        if (!saving) setConfirmDelete(open);
                      }}
                    >
                      <Dialog.Portal>
                        <Dialog.Overlay className="model-config-backdrop" />
                        <Dialog.Content
                          className="provider-delete-dialog"
                          onOpenAutoFocus={(event) => {
                            event.preventDefault();
                            document
                              .querySelector<HTMLButtonElement>(".provider-delete-dialog .primary")
                              ?.focus();
                          }}
                          onKeyDownCapture={(event) => {
                            if (event.key === "Enter") {
                              event.preventDefault();
                              event.stopPropagation();
                              event.currentTarget.querySelector<HTMLButtonElement>(".primary")?.click();
                            }
                          }}
                          onEscapeKeyDown={(event) => {
                            if (saving) event.preventDefault();
                          }}
                        >
                          <header>
                            <Dialog.Title>删除供应商“{name}”?</Dialog.Title>
                            <Dialog.Description>
                              删除后将移除这条自定义 Provider 配置,当前设置页中的相关内容不会自动恢复。
                            </Dialog.Description>
                          </header>
                          {error && (
                            <div className="model-config-error" role="alert">
                              {error}
                            </div>
                          )}
                          <footer>
                            <Dialog.Close className="secondary" disabled={saving}>
                              取消<span aria-hidden="true">esc</span>
                            </Dialog.Close>
                            <button
                              className="primary"
                              disabled={saving}
                              onClick={async () => {
                                if (!selected || saving) return;
                                setSaving(true);
                                setError("");
                                try {
                                  const value = unwrap(await window.zpi.deleteProvider(selected));
                                  useStore.setState({ settings: value });
                                  await refresh();
                                  select(value.providers[0]);
                                  setPicker(value.providers.length === 0);
                                } catch (error) {
                                  setError(userErrorMessage(error));
                                } finally {
                                  setSaving(false);
                                }
                              }}
                            >
                              确认删除<span aria-hidden="true">⏎</span>
                            </button>
                          </footer>
                        </Dialog.Content>
                      </Dialog.Portal>
                    </Dialog.Root>
                    {editing && (
                      <ModelConfigDialog
                        key={`${selected ?? preset ?? "new"}:${editing.index}`}
                        initial={editing.draft}
                        chatgpt={chatgpt}
                        api={api}
                        recommended={recommended.current.get(editing.draft.id)}
                        onClose={() => setEditing(undefined)}
                        onSave={async (model) => {
                          const next = models.slice();
                          next[editing.index] = model;
                          if (selected && !(await save(next))) throw new Error("模型保存失败,请检查配置。");
                          if (!selected) setModels(next);
                        }}
                      />
                    )}
                  </div>
                )}
              </div>
            </SettingsBreadcrumbProvider>
          </div>
        </div>
      </div>
      <Dialog.Root
        open={pendingSelection !== null}
        onOpenChange={(open) => {
          if (!open) setPendingSelection(null);
        }}
      >
        <Dialog.Portal>
          <Dialog.Overlay className="model-config-backdrop" />
          <Dialog.Content className="provider-delete-dialog">
            <header>
              <Dialog.Title>放弃未保存的更改?</Dialog.Title>
              <Dialog.Description>离开当前编辑会丢弃尚未保存的提供商配置。</Dialog.Description>
            </header>
            <footer>
              <Dialog.Close className="secondary">继续编辑</Dialog.Close>
              <button
                className="primary"
                onClick={() => {
                  const action = pendingSelection;
                  setPendingSelection(null);
                  action?.();
                }}
              >
                放弃更改并继续
              </button>
            </footer>
          </Dialog.Content>
        </Dialog.Portal>
      </Dialog.Root>
    </section>
  );
}