2655 lines
97 KiB
TypeScript
2655 lines
97 KiB
TypeScript
import { randomUUID } from "@/lib/uuid";
|
|
import { listen } from "@/lib/backend/api";
|
|
import { open as openDialog } from "@/lib/backend/platform/dialog";
|
|
import { openUrl } from "@/lib/backend/platform/opener";
|
|
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
|
import { useTranslation } from "react-i18next";
|
|
import {
|
|
MdAdd,
|
|
MdDelete,
|
|
MdEdit,
|
|
MdLogin,
|
|
MdLogout,
|
|
MdOpenInNew,
|
|
MdRefresh,
|
|
MdVisibility,
|
|
MdVisibilityOff,
|
|
} from "react-icons/md";
|
|
import { TbPlugConnected } from "react-icons/tb";
|
|
import { toast } from "sonner";
|
|
import { ProviderBadge } from "@/components/ai/ProviderBadge";
|
|
import {
|
|
AlertDialog,
|
|
AlertDialogAction,
|
|
AlertDialogCancel,
|
|
AlertDialogContent,
|
|
AlertDialogDescription,
|
|
AlertDialogFooter,
|
|
AlertDialogHeader,
|
|
AlertDialogTitle,
|
|
AlertDialogTrigger,
|
|
} from "@/components/ui/alert-dialog";
|
|
import { Badge } from "@/components/ui/badge";
|
|
import { Button } from "@/components/ui/button";
|
|
import { Input } from "@/components/ui/input";
|
|
import { Label } from "@/components/ui/label";
|
|
import {
|
|
Select,
|
|
SelectContent,
|
|
SelectItem,
|
|
SelectTrigger,
|
|
SelectValue,
|
|
} from "@/components/ui/select";
|
|
import { Textarea } from "@/components/ui/textarea";
|
|
import { useApp } from "@/context/AppContext";
|
|
import {
|
|
AI_PROVIDERS,
|
|
aiModelIdForCredential,
|
|
aiModelIdForProvider,
|
|
BUILTIN_PROVIDERS,
|
|
DEFAULT_AI_SETTINGS,
|
|
DEFAULT_MODEL_REASONING_EFFORTS,
|
|
getCustomProviderBaseUrlPlaceholder,
|
|
getProviderLabel,
|
|
isBuiltinProvider,
|
|
MODEL_REASONING_EFFORTS,
|
|
supportsApiFormatSelection,
|
|
} from "@/lib/aiSettings";
|
|
import { writeClipboardText } from "@/lib/clipboard";
|
|
import { isCloudSecretMasked, secretInputValue, secretPlaceholder } from "@/lib/cloudSync";
|
|
import { getErrorMessage } from "@/lib/errors";
|
|
import { invoke } from "@/lib/invoke";
|
|
import { getOwnerMainWindowLabel } from "@/lib/windowManager";
|
|
import type {
|
|
AICustomActionConfig,
|
|
AIModelConfigItem,
|
|
AIModelReasoningEffort,
|
|
AIProviderApiProtocol,
|
|
AIProviderCredential,
|
|
AIProviderKind,
|
|
AIProxySettings,
|
|
AISettings,
|
|
ClaudeCodeIntegrationSettings,
|
|
CodexIntegrationSettings,
|
|
ExternalMcpSettings,
|
|
McpRuntimeStatus,
|
|
} from "@/types/global";
|
|
import { AiPermissionSelect } from "./AiPermissionSelect";
|
|
import {
|
|
SettingFieldGrid,
|
|
SettingInput,
|
|
SettingNumberInput,
|
|
SettingRow,
|
|
SettingSection,
|
|
SettingSelect,
|
|
SettingSwitch,
|
|
} from "./SettingFormItems";
|
|
|
|
function updateDefaultModelId(ai: AISettings, models: AIModelConfigItem[]) {
|
|
if (
|
|
ai.default_model_id &&
|
|
models.some((model) => model.enabled && model.id === ai.default_model_id)
|
|
) {
|
|
return ai.default_model_id;
|
|
}
|
|
return models.find((model) => model.enabled)?.id ?? null;
|
|
}
|
|
|
|
function newCredential(): AIProviderCredential {
|
|
return {
|
|
id: `credential-${randomUUID()}`,
|
|
name: "",
|
|
provider_kind: "openai_compatible",
|
|
api_protocol: null,
|
|
api_format: "chat_completions",
|
|
base_url: "",
|
|
api_key: "",
|
|
enabled: true,
|
|
};
|
|
}
|
|
|
|
function providerNameTaken(
|
|
name: string,
|
|
credentials: AIProviderCredential[],
|
|
excludeId?: string,
|
|
): boolean {
|
|
const normalized = name.trim().toLocaleLowerCase();
|
|
return (
|
|
normalized.length > 0 &&
|
|
credentials.some(
|
|
(credential) =>
|
|
credential.enabled &&
|
|
credential.id !== excludeId &&
|
|
credential.name.trim().toLocaleLowerCase() === normalized,
|
|
)
|
|
);
|
|
}
|
|
|
|
function availableProviderName(
|
|
baseName: string,
|
|
credentials: AIProviderCredential[],
|
|
excludeId?: string,
|
|
): string {
|
|
if (!providerNameTaken(baseName, credentials, excludeId)) return baseName;
|
|
let suffix = 2;
|
|
while (providerNameTaken(`${baseName}-${suffix}`, credentials, excludeId)) suffix += 1;
|
|
return `${baseName}-${suffix}`;
|
|
}
|
|
|
|
function defaultProviderName(
|
|
credential: AIProviderCredential,
|
|
credentials: AIProviderCredential[],
|
|
): string {
|
|
const baseName =
|
|
credential.provider_kind === "openai_compatible"
|
|
? "my-provider"
|
|
: getProviderLabel(credential.provider_kind);
|
|
return availableProviderName(baseName, credentials, credential.id);
|
|
}
|
|
|
|
function providerApiProtocol(credential: AIProviderCredential): AIProviderApiProtocol {
|
|
if (credential.api_protocol) return credential.api_protocol;
|
|
switch (credential.provider_kind) {
|
|
case "anthropic":
|
|
return "anthropic";
|
|
case "gemini":
|
|
return "gemini";
|
|
case "ollama":
|
|
return "ollama";
|
|
default:
|
|
return "openai_compatible";
|
|
}
|
|
}
|
|
|
|
function providerProtocolSelectValue(credential: AIProviderCredential): string {
|
|
const protocol = providerApiProtocol(credential);
|
|
if (protocol !== "openai_compatible") return protocol;
|
|
return credential.api_format === "responses" ? "responses" : "chat_completions";
|
|
}
|
|
|
|
function newAction(prefix: string): AICustomActionConfig {
|
|
return {
|
|
id: `${prefix}-${randomUUID()}`,
|
|
name: "自定义 AI 功能",
|
|
prompt: "",
|
|
enabled: true,
|
|
};
|
|
}
|
|
|
|
function DeleteIconButton({
|
|
onDelete,
|
|
title,
|
|
disabled,
|
|
}: {
|
|
onDelete: () => void;
|
|
title: string;
|
|
disabled?: boolean;
|
|
}) {
|
|
return (
|
|
<Button
|
|
type="button"
|
|
size="icon-sm"
|
|
variant="ghost"
|
|
title={title}
|
|
aria-label={title}
|
|
disabled={disabled}
|
|
className="text-destructive hover:bg-destructive/10 hover:text-destructive"
|
|
onClick={onDelete}
|
|
>
|
|
<MdDelete className="text-[0.95rem]" />
|
|
</Button>
|
|
);
|
|
}
|
|
|
|
interface CodexCliStatus {
|
|
installed: boolean;
|
|
path?: string | null;
|
|
version?: string | null;
|
|
error?: string | null;
|
|
source?: string | null;
|
|
checkedPaths?: string[];
|
|
}
|
|
|
|
interface CodexAccountStatus {
|
|
connected: boolean;
|
|
authMode?: string | null;
|
|
planType?: string | null;
|
|
email?: string | null;
|
|
requiresOpenaiAuth: boolean;
|
|
}
|
|
|
|
interface CodexLoginStart {
|
|
loginId?: string | null;
|
|
loginType: string;
|
|
authUrl?: string | null;
|
|
verificationUrl?: string | null;
|
|
userCode?: string | null;
|
|
}
|
|
|
|
interface ClaudeCodeCliStatus {
|
|
installed: boolean;
|
|
path?: string | null;
|
|
version?: string | null;
|
|
error?: string | null;
|
|
source?: string | null;
|
|
checkedPaths?: string[];
|
|
}
|
|
|
|
interface McpClientConfigs {
|
|
sidecarPath: string;
|
|
codex: unknown;
|
|
claudeCode: unknown;
|
|
cursor: unknown;
|
|
}
|
|
|
|
interface ClaudeCodeAccountStatus {
|
|
connected: boolean;
|
|
authMode?: string | null;
|
|
message?: string | null;
|
|
}
|
|
|
|
function normalizeCodexSettings(
|
|
value?: Partial<CodexIntegrationSettings>,
|
|
): CodexIntegrationSettings {
|
|
return {
|
|
enabled: value?.enabled ?? false,
|
|
executable_path: value?.executable_path ?? null,
|
|
runtime: value?.runtime ?? "app_server",
|
|
default_model: value?.default_model ?? null,
|
|
config_directory: value?.config_directory ?? null,
|
|
permission_mode: value?.permission_mode ?? "confirm",
|
|
tool_integration_mode: value?.tool_integration_mode ?? "nyaterm_mcp",
|
|
thread_mode: value?.thread_mode ?? "persistent",
|
|
remote_terminal_agent_enabled: value?.remote_terminal_agent_enabled ?? false,
|
|
};
|
|
}
|
|
|
|
function normalizeClaudeCodeSettings(
|
|
value?: Partial<ClaudeCodeIntegrationSettings>,
|
|
): ClaudeCodeIntegrationSettings {
|
|
return {
|
|
enabled: value?.enabled ?? false,
|
|
executable_path: value?.executable_path ?? null,
|
|
runtime: value?.runtime ?? "stream_json_cli",
|
|
default_model: value?.default_model ?? null,
|
|
config_directory: value?.config_directory ?? null,
|
|
permission_mode: value?.permission_mode ?? "confirm",
|
|
tool_integration_mode: value?.tool_integration_mode ?? "nyaterm_mcp",
|
|
};
|
|
}
|
|
|
|
export function AiGeneralTab() {
|
|
const { t } = useTranslation();
|
|
const { appSettings, updateAppSettings } = useApp();
|
|
const ai = appSettings.ai;
|
|
const update = (patch: Partial<AISettings>) => updateAppSettings({ ai: { ...ai, ...patch } });
|
|
const proxy = { ...DEFAULT_AI_SETTINGS.proxy, ...ai.proxy };
|
|
const updateProxy = (patch: Partial<AIProxySettings>) =>
|
|
update({ proxy: { ...proxy, ...patch } });
|
|
|
|
return (
|
|
<div className="space-y-5">
|
|
<SettingSection title={t("ai.general")}>
|
|
<SettingRow label={t("ai.enabled")}>
|
|
<SettingSwitch checked={ai.enabled} onChange={(enabled) => update({ enabled })} />
|
|
</SettingRow>
|
|
<SettingRow label={t("ai.redaction")}>
|
|
<SettingSwitch
|
|
checked={ai.redaction_enabled}
|
|
onChange={(redaction_enabled) => update({ redaction_enabled })}
|
|
/>
|
|
</SettingRow>
|
|
<SettingRow label={t("ai.allowSave")}>
|
|
<SettingSwitch
|
|
checked={ai.allow_save_command}
|
|
onChange={(allow_save_command) => update({ allow_save_command })}
|
|
/>
|
|
</SettingRow>
|
|
<SettingRow label={t("ai.recordHistory")}>
|
|
<SettingSwitch
|
|
checked={ai.record_history}
|
|
onChange={(record_history) => update({ record_history })}
|
|
/>
|
|
</SettingRow>
|
|
<SettingFieldGrid>
|
|
<SettingInput
|
|
label={t("ai.requestUserAgent")}
|
|
desc={t("ai.requestUserAgentDesc")}
|
|
value={ai.request_user_agent}
|
|
onChange={(event) => update({ request_user_agent: event.target.value })}
|
|
fieldClassName="lg:col-span-2"
|
|
/>
|
|
<SettingNumberInput
|
|
label={t("ai.contextLineLimit")}
|
|
min={50}
|
|
max={500}
|
|
step={50}
|
|
value={ai.context_line_limit}
|
|
onChange={(context_line_limit) => update({ context_line_limit })}
|
|
/>
|
|
<SettingNumberInput
|
|
label={t("ai.timeoutMs")}
|
|
min={5000}
|
|
max={300000}
|
|
step={1000}
|
|
value={ai.timeout_ms}
|
|
onChange={(timeout_ms) => update({ timeout_ms })}
|
|
/>
|
|
</SettingFieldGrid>
|
|
</SettingSection>
|
|
|
|
<SettingSection title={t("ai.proxyTitle")} desc={t("ai.proxyDescription")}>
|
|
<SettingSelect
|
|
label={t("ai.proxyMode")}
|
|
value={proxy.mode}
|
|
onValueChange={(mode) => updateProxy({ mode: mode as AIProxySettings["mode"] })}
|
|
>
|
|
<SelectItem value="system">{t("ai.proxySystem")}</SelectItem>
|
|
<SelectItem value="direct">{t("ai.proxyDirect")}</SelectItem>
|
|
<SelectItem value="custom">{t("ai.proxyCustom")}</SelectItem>
|
|
</SettingSelect>
|
|
{proxy.mode === "custom" && (
|
|
<>
|
|
<SettingSelect
|
|
label={t("settings.proxyProtocol")}
|
|
value={proxy.protocol}
|
|
onValueChange={(protocol) =>
|
|
updateProxy({ protocol: protocol as AIProxySettings["protocol"] })
|
|
}
|
|
>
|
|
<SelectItem value="http">HTTP</SelectItem>
|
|
<SelectItem value="socks5">SOCKS5</SelectItem>
|
|
</SettingSelect>
|
|
<SettingFieldGrid>
|
|
<SettingInput
|
|
label={t("settings.proxyHost")}
|
|
aria-label={t("settings.proxyHost")}
|
|
placeholder="127.0.0.1"
|
|
value={proxy.host}
|
|
onChange={(event) => updateProxy({ host: event.target.value })}
|
|
/>
|
|
<SettingNumberInput
|
|
label={t("settings.proxyPort")}
|
|
min={1}
|
|
max={65535}
|
|
value={proxy.port}
|
|
onChange={(port) => updateProxy({ port })}
|
|
/>
|
|
<SettingInput
|
|
label={t("ai.proxyUsername")}
|
|
aria-label={t("ai.proxyUsername")}
|
|
autoComplete="off"
|
|
value={proxy.username ?? ""}
|
|
onChange={(event) => updateProxy({ username: event.target.value })}
|
|
/>
|
|
</SettingFieldGrid>
|
|
<SettingRow label={t("ai.proxyPassword")} desc={t("ai.proxyPasswordDescription")}>
|
|
<Input
|
|
type="password"
|
|
aria-label={t("ai.proxyPassword")}
|
|
autoComplete="new-password"
|
|
value={secretInputValue(proxy.password)}
|
|
placeholder={secretPlaceholder(proxy.password, t("ai.proxyPassword"))}
|
|
onChange={(event) => updateProxy({ password: event.target.value })}
|
|
/>
|
|
<Button
|
|
type="button"
|
|
variant="outline"
|
|
disabled={!proxy.password}
|
|
onClick={() => updateProxy({ password: "" })}
|
|
>
|
|
{t("ai.proxyClearPassword")}
|
|
</Button>
|
|
</SettingRow>
|
|
<SettingInput
|
|
label={t("ai.proxyBypass")}
|
|
aria-label={t("ai.proxyBypass")}
|
|
desc={t("ai.proxyBypassDescription")}
|
|
value={proxy.no_proxy}
|
|
onChange={(event) => updateProxy({ no_proxy: event.target.value })}
|
|
/>
|
|
</>
|
|
)}
|
|
<p className="text-xs text-muted-foreground">{t("ai.proxyTestHint")}</p>
|
|
</SettingSection>
|
|
|
|
<SettingSection title={t("ai.agentSettings")}>
|
|
<SettingFieldGrid>
|
|
<SettingSelect
|
|
label={t("ai.smartAutoExecuteMaxRisk")}
|
|
desc={t("ai.smartAutoExecuteMaxRiskDesc")}
|
|
value={ai.agent_smart_auto_execute_max_risk ?? "low"}
|
|
onValueChange={(agent_smart_auto_execute_max_risk) =>
|
|
update({
|
|
agent_smart_auto_execute_max_risk:
|
|
agent_smart_auto_execute_max_risk as AISettings["agent_smart_auto_execute_max_risk"],
|
|
})
|
|
}
|
|
>
|
|
<SelectItem value="low">{t("ai.riskLow")}</SelectItem>
|
|
<SelectItem value="medium">{t("ai.riskMedium")}</SelectItem>
|
|
<SelectItem value="high">{t("ai.riskHigh")}</SelectItem>
|
|
<SelectItem value="critical">{t("ai.riskCritical")}</SelectItem>
|
|
</SettingSelect>
|
|
</SettingFieldGrid>
|
|
<SettingFieldGrid>
|
|
<SettingNumberInput
|
|
label={t("ai.agentMaxSteps")}
|
|
min={1}
|
|
max={50}
|
|
step={1}
|
|
value={ai.max_agent_steps ?? 10}
|
|
onChange={(max_agent_steps) => update({ max_agent_steps })}
|
|
/>
|
|
<SettingNumberInput
|
|
label={t("ai.agentStepTimeout")}
|
|
min={5000}
|
|
max={120000}
|
|
step={1000}
|
|
value={ai.agent_step_timeout_ms ?? 30000}
|
|
onChange={(agent_step_timeout_ms) => update({ agent_step_timeout_ms })}
|
|
/>
|
|
<SettingNumberInput
|
|
label={t("ai.terminalOutputLines")}
|
|
min={0}
|
|
max={100}
|
|
step={1}
|
|
value={ai.terminal_output_lines}
|
|
onChange={(terminal_output_lines) => update({ terminal_output_lines })}
|
|
/>
|
|
</SettingFieldGrid>
|
|
<div className="text-xs text-muted-foreground">{t("ai.agentMaxStepsDesc")}</div>
|
|
<div className="text-xs text-muted-foreground">{t("ai.terminalOutputLinesDesc")}</div>
|
|
</SettingSection>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
interface ModelGroup {
|
|
groupKey: string;
|
|
label: string;
|
|
credential?: AIProviderCredential;
|
|
backend?: "genai" | "codex";
|
|
models: AIModelConfigItem[];
|
|
}
|
|
|
|
export function AiAgentsTab() {
|
|
const { t } = useTranslation();
|
|
const { appSettings, updateAppSettings } = useApp();
|
|
const ai = appSettings.ai;
|
|
const codex = normalizeCodexSettings(ai.codex);
|
|
const claudeCode = normalizeClaudeCodeSettings(ai.claude_code);
|
|
const externalMcp: ExternalMcpSettings = ai.external_mcp ?? {
|
|
enabled: false,
|
|
permission_mode: "confirm",
|
|
session_scope: "current_window",
|
|
};
|
|
const [mcpStatus, setMcpStatus] = useState<McpRuntimeStatus | null>(null);
|
|
const [cliStatus, setCliStatus] = useState<CodexCliStatus | null>(null);
|
|
const [accountStatus, setAccountStatus] = useState<CodexAccountStatus | null>(
|
|
null,
|
|
);
|
|
const [claudeCliStatus, setClaudeCliStatus] =
|
|
useState<ClaudeCodeCliStatus | null>(null);
|
|
const [claudeAccountStatus, setClaudeAccountStatus] =
|
|
useState<ClaudeCodeAccountStatus | null>(null);
|
|
const [deviceLogin, setDeviceLogin] = useState<CodexLoginStart | null>(null);
|
|
const [busy, setBusy] = useState(false);
|
|
|
|
const codexModels = useMemo(
|
|
() => ai.models.filter((model) => model.backend === "codex" && model.enabled),
|
|
[ai.models],
|
|
);
|
|
|
|
const updateCodex = useCallback(
|
|
(patch: Partial<CodexIntegrationSettings>) =>
|
|
updateAppSettings({ ai: { ...ai, codex: { ...codex, ...patch } } }),
|
|
[ai, codex, updateAppSettings],
|
|
);
|
|
|
|
const updateClaudeCode = useCallback(
|
|
(patch: Partial<ClaudeCodeIntegrationSettings>) =>
|
|
updateAppSettings({
|
|
ai: { ...ai, claude_code: { ...claudeCode, ...patch } },
|
|
}),
|
|
[ai, claudeCode, updateAppSettings],
|
|
);
|
|
|
|
const updateExternalMcp = useCallback(
|
|
(patch: Partial<ExternalMcpSettings>) =>
|
|
updateAppSettings({
|
|
ai: { ...ai, external_mcp: { ...externalMcp, ...patch } },
|
|
}),
|
|
[ai, externalMcp, updateAppSettings],
|
|
);
|
|
|
|
const setExternalMcpEnabled = useCallback(
|
|
async (enabled: boolean) => {
|
|
try {
|
|
const status = await invoke<McpRuntimeStatus>("set_external_mcp_enabled", {
|
|
enabled,
|
|
ownerWindowLabel: getOwnerMainWindowLabel(),
|
|
});
|
|
setMcpStatus(status);
|
|
updateExternalMcp({ enabled });
|
|
} catch (error) {
|
|
toast.error(getErrorMessage(error));
|
|
}
|
|
},
|
|
[updateExternalMcp],
|
|
);
|
|
|
|
useEffect(() => {
|
|
void invoke<McpRuntimeStatus>("get_external_mcp_status")
|
|
.then(setMcpStatus)
|
|
.catch(() => {});
|
|
let disposed = false;
|
|
let unlisten: (() => void) | undefined;
|
|
void listen<McpRuntimeStatus>("mcp-status-changed", (event) => {
|
|
if (!disposed) setMcpStatus(event.payload);
|
|
}).then((dispose) => {
|
|
if (disposed) dispose();
|
|
else unlisten = dispose;
|
|
});
|
|
return () => {
|
|
disposed = true;
|
|
unlisten?.();
|
|
};
|
|
}, []);
|
|
|
|
const copyMcpConfig = useCallback(
|
|
async (client: "codex" | "claudeCode" | "cursor") => {
|
|
try {
|
|
const configs = await invoke<McpClientConfigs>("get_external_mcp_client_configs");
|
|
await writeClipboardText(JSON.stringify(configs[client], null, 2));
|
|
toast.success(t("ai.externalMcpConfigCopied"));
|
|
} catch (error) {
|
|
toast.error(getErrorMessage(error));
|
|
}
|
|
},
|
|
[t],
|
|
);
|
|
|
|
const detect = useCallback(
|
|
async (options?: { silent?: boolean }) => {
|
|
setBusy(true);
|
|
try {
|
|
const status = await invoke<CodexCliStatus>("detect_codex_cli");
|
|
setCliStatus(status);
|
|
if (
|
|
status.installed &&
|
|
status.path &&
|
|
status.path !== codex.executable_path &&
|
|
(!codex.executable_path || !options?.silent)
|
|
) {
|
|
updateCodex({ executable_path: status.path });
|
|
}
|
|
if (!options?.silent) {
|
|
if (status.installed) toast.success(t("ai.codexDetected"));
|
|
else toast.error(status.error || t("ai.codexNotInstalled"));
|
|
}
|
|
} catch (error) {
|
|
if (!options?.silent) {
|
|
toast.error(getErrorMessage(error));
|
|
}
|
|
} finally {
|
|
setBusy(false);
|
|
}
|
|
},
|
|
[codex.executable_path, t, updateCodex],
|
|
);
|
|
|
|
const detectClaudeCode = useCallback(
|
|
async (options?: { silent?: boolean }) => {
|
|
setBusy(true);
|
|
try {
|
|
const status = await invoke<ClaudeCodeCliStatus>("detect_claude_code_cli");
|
|
setClaudeCliStatus(status);
|
|
if (
|
|
status.installed &&
|
|
status.path &&
|
|
status.path !== claudeCode.executable_path &&
|
|
(!claudeCode.executable_path || !options?.silent)
|
|
) {
|
|
updateClaudeCode({ executable_path: status.path });
|
|
}
|
|
if (!options?.silent) {
|
|
if (status.installed) toast.success(t("ai.claudeCodeDetected"));
|
|
else toast.error(status.error || t("ai.claudeCodeNotInstalled"));
|
|
}
|
|
} catch (error) {
|
|
if (!options?.silent) {
|
|
toast.error(getErrorMessage(error));
|
|
}
|
|
} finally {
|
|
setBusy(false);
|
|
}
|
|
},
|
|
[claudeCode.executable_path, t, updateClaudeCode],
|
|
);
|
|
|
|
const refreshAccount = useCallback(
|
|
async (options?: { silent?: boolean }) => {
|
|
setBusy(true);
|
|
try {
|
|
const status = await invoke<CodexAccountStatus>("get_codex_account_status");
|
|
setAccountStatus(status);
|
|
if (!options?.silent) {
|
|
toast.success(t("ai.codexStatusRefreshed"));
|
|
}
|
|
} catch (error) {
|
|
if (!options?.silent) {
|
|
toast.error(getErrorMessage(error));
|
|
}
|
|
} finally {
|
|
setBusy(false);
|
|
}
|
|
},
|
|
[t],
|
|
);
|
|
|
|
const refreshClaudeAccount = useCallback(
|
|
async (options?: { silent?: boolean }) => {
|
|
setBusy(true);
|
|
try {
|
|
const status = await invoke<ClaudeCodeAccountStatus>("get_claude_code_account_status");
|
|
setClaudeAccountStatus(status);
|
|
if (!options?.silent) {
|
|
toast.success(t("ai.claudeCodeStatusRefreshed"));
|
|
}
|
|
} catch (error) {
|
|
if (!options?.silent) {
|
|
toast.error(getErrorMessage(error));
|
|
}
|
|
} finally {
|
|
setBusy(false);
|
|
}
|
|
},
|
|
[t],
|
|
);
|
|
|
|
const startLogin = useCallback(
|
|
async (flow: "browser" | "deviceCode") => {
|
|
setBusy(true);
|
|
try {
|
|
const result = await invoke<CodexLoginStart>("start_codex_login", {
|
|
flow,
|
|
});
|
|
setDeviceLogin(flow === "deviceCode" ? result : null);
|
|
if (result.authUrl) {
|
|
await openUrl(result.authUrl);
|
|
}
|
|
toast.success(t("ai.codexLoginStarted"));
|
|
} catch (error) {
|
|
toast.error(getErrorMessage(error));
|
|
} finally {
|
|
setBusy(false);
|
|
}
|
|
},
|
|
[t],
|
|
);
|
|
|
|
const logout = useCallback(async () => {
|
|
setBusy(true);
|
|
try {
|
|
await invoke("logout_codex");
|
|
setAccountStatus({ connected: false, requiresOpenaiAuth: true });
|
|
toast.success(t("ai.codexLoggedOut"));
|
|
} catch (error) {
|
|
toast.error(getErrorMessage(error));
|
|
} finally {
|
|
setBusy(false);
|
|
}
|
|
}, [t]);
|
|
|
|
const bootstrappedRef = useRef(false);
|
|
useEffect(() => {
|
|
if (bootstrappedRef.current) return;
|
|
bootstrappedRef.current = true;
|
|
void detect({ silent: true });
|
|
void detectClaudeCode({ silent: true });
|
|
if (codex.enabled) void refreshAccount({ silent: true });
|
|
if (claudeCode.enabled) void refreshClaudeAccount({ silent: true });
|
|
}, [
|
|
claudeCode.enabled,
|
|
codex.enabled,
|
|
detect,
|
|
detectClaudeCode,
|
|
refreshAccount,
|
|
refreshClaudeAccount,
|
|
]);
|
|
|
|
const connectedLabel = accountStatus?.connected
|
|
? t("ai.codexConnected")
|
|
: t("ai.codexNotLoggedIn");
|
|
|
|
return (
|
|
<div className="space-y-5">
|
|
<SettingSection
|
|
title={t("ai.localAgents")}
|
|
action={
|
|
<Button size="sm" variant="outline" disabled={busy} onClick={() => void detect()}>
|
|
<MdRefresh className={busy ? "animate-spin" : ""} />
|
|
{t("ai.detect")}
|
|
</Button>
|
|
}
|
|
contentClassName="space-y-4"
|
|
>
|
|
<div className="rounded-md border border-border/70 bg-background/75 p-4">
|
|
<div className="mb-4 flex flex-wrap items-start justify-between gap-3">
|
|
<div className="min-w-0 flex-1">
|
|
<div className="text-sm font-medium">OpenAI Codex</div>
|
|
<div className="mt-1 text-xs text-muted-foreground">{t("ai.codexDesc")}</div>
|
|
</div>
|
|
<div className="flex flex-wrap items-center justify-end gap-2">
|
|
<Badge variant={cliStatus?.installed ? "default" : "outline"}>
|
|
{cliStatus?.installed ? t("ai.installed") : t("ai.notInstalled")}
|
|
</Badge>
|
|
<Badge variant={accountStatus?.connected ? "default" : "outline"}>
|
|
{connectedLabel}
|
|
</Badge>
|
|
<SettingSwitch
|
|
aria-label={t("ai.codexEnabled")}
|
|
checked={codex.enabled}
|
|
onChange={(enabled) => updateCodex({ enabled })}
|
|
/>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="space-y-4">
|
|
<SettingFieldGrid>
|
|
<SettingInput
|
|
label={t("ai.codexPath")}
|
|
value={codex.executable_path ?? ""}
|
|
placeholder="codex"
|
|
onChange={(event) => updateCodex({ executable_path: event.target.value || null })}
|
|
fieldClassName="lg:col-span-2"
|
|
/>
|
|
<SettingSelect
|
|
label={t("ai.codexThreadMode")}
|
|
value={codex.thread_mode}
|
|
onValueChange={(thread_mode) =>
|
|
updateCodex({
|
|
thread_mode: thread_mode as CodexIntegrationSettings["thread_mode"],
|
|
})
|
|
}
|
|
>
|
|
<SelectItem value="persistent">{t("ai.codexThreadPersistent")}</SelectItem>
|
|
<SelectItem value="ephemeral">{t("ai.codexThreadEphemeral")}</SelectItem>
|
|
</SettingSelect>
|
|
<SettingSelect
|
|
label={t("ai.codexDefaultModel")}
|
|
value={codex.default_model ?? "__none__"}
|
|
onValueChange={(value) =>
|
|
updateCodex({
|
|
default_model: value === "__none__" ? null : value,
|
|
})
|
|
}
|
|
>
|
|
<SelectItem value="__none__">{t("ai.useCodexDefaultModel")}</SelectItem>
|
|
{codexModels.map((model) => (
|
|
<SelectItem key={model.id} value={model.name}>
|
|
{model.name}
|
|
</SelectItem>
|
|
))}
|
|
</SettingSelect>
|
|
<AiPermissionSelect
|
|
value={codex.permission_mode ?? "confirm"}
|
|
targetLabel="Codex"
|
|
onValueChange={(permission_mode) =>
|
|
updateCodex({
|
|
permission_mode,
|
|
})
|
|
}
|
|
/>
|
|
</SettingFieldGrid>
|
|
|
|
<div className="grid gap-2 text-xs text-muted-foreground sm:grid-cols-2">
|
|
<div>
|
|
{t("ai.codexVersion")}: {cliStatus?.version || "-"}
|
|
</div>
|
|
<div>
|
|
{t("ai.codexAuthMode")}: {accountStatus?.authMode || "-"}
|
|
</div>
|
|
<div>
|
|
{t("ai.codexPlan")}: {accountStatus?.planType || "-"}
|
|
</div>
|
|
<div>
|
|
{t("ai.codexEmail")}: {accountStatus?.email || "-"}
|
|
</div>
|
|
</div>
|
|
|
|
{deviceLogin?.verificationUrl && deviceLogin.userCode ? (
|
|
<div className="rounded-md border border-border/70 bg-muted/20 p-3 text-xs">
|
|
<div className="font-medium">{t("ai.codexDeviceLogin")}</div>
|
|
<div className="mt-2 font-mono">{deviceLogin.verificationUrl}</div>
|
|
<div className="mt-1 font-mono text-sm">{deviceLogin.userCode}</div>
|
|
</div>
|
|
) : null}
|
|
|
|
<div className="flex flex-wrap gap-2">
|
|
<Button
|
|
size="sm"
|
|
variant="outline"
|
|
disabled={busy}
|
|
onClick={() => void refreshAccount()}
|
|
>
|
|
<MdRefresh className={busy ? "animate-spin" : ""} />
|
|
{t("ai.refreshStatus")}
|
|
</Button>
|
|
<Button size="sm" disabled={busy} onClick={() => void startLogin("browser")}>
|
|
<MdLogin />
|
|
{t("ai.codexLogin")}
|
|
</Button>
|
|
<Button
|
|
size="sm"
|
|
variant="outline"
|
|
disabled={busy}
|
|
onClick={() => void startLogin("deviceCode")}
|
|
>
|
|
<MdOpenInNew />
|
|
{t("ai.codexDeviceCodeLogin")}
|
|
</Button>
|
|
<Button size="sm" variant="outline" disabled={busy} onClick={() => void logout()}>
|
|
<MdLogout />
|
|
{t("ai.codexLogout")}
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="rounded-md border border-border/70 bg-background/75 p-4">
|
|
<div className="mb-4 flex flex-wrap items-start justify-between gap-3">
|
|
<div className="min-w-0 flex-1">
|
|
<div className="text-sm font-medium">Claude Code</div>
|
|
<div className="mt-1 text-xs text-muted-foreground">{t("ai.claudeCodeDesc")}</div>
|
|
</div>
|
|
<div className="flex flex-wrap items-center justify-end gap-2">
|
|
<Badge variant={claudeCliStatus?.installed ? "default" : "outline"}>
|
|
{claudeCliStatus?.installed ? t("ai.installed") : t("ai.notInstalled")}
|
|
</Badge>
|
|
<Badge variant={claudeAccountStatus?.connected ? "default" : "outline"}>
|
|
{claudeAccountStatus?.connected
|
|
? t("ai.claudeCodeConnected")
|
|
: t("ai.claudeCodeNotLoggedIn")}
|
|
</Badge>
|
|
<SettingSwitch
|
|
aria-label={t("ai.claudeCodeEnabled")}
|
|
checked={claudeCode.enabled}
|
|
onChange={(enabled) => updateClaudeCode({ enabled })}
|
|
/>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="space-y-4">
|
|
<SettingFieldGrid>
|
|
<SettingInput
|
|
label={t("ai.claudeCodePath")}
|
|
value={claudeCode.executable_path ?? ""}
|
|
placeholder="claude"
|
|
onChange={(event) =>
|
|
updateClaudeCode({
|
|
executable_path: event.target.value || null,
|
|
})
|
|
}
|
|
fieldClassName="lg:col-span-2"
|
|
/>
|
|
<SettingInput
|
|
label={t("ai.claudeCodeConfigDirectory")}
|
|
value={claudeCode.config_directory ?? ""}
|
|
placeholder="~/.claude"
|
|
onChange={(event) =>
|
|
updateClaudeCode({
|
|
config_directory: event.target.value || null,
|
|
})
|
|
}
|
|
/>
|
|
<SettingInput
|
|
label={t("ai.claudeCodeDefaultModel")}
|
|
value={claudeCode.default_model ?? ""}
|
|
placeholder="sonnet"
|
|
onChange={(event) =>
|
|
updateClaudeCode({
|
|
default_model: event.target.value || null,
|
|
})
|
|
}
|
|
/>
|
|
<AiPermissionSelect
|
|
value={claudeCode.permission_mode ?? "confirm"}
|
|
targetLabel="Claude Code"
|
|
onValueChange={(permission_mode) =>
|
|
updateClaudeCode({
|
|
permission_mode,
|
|
})
|
|
}
|
|
/>
|
|
</SettingFieldGrid>
|
|
|
|
<div className="grid gap-2 text-xs text-muted-foreground sm:grid-cols-2">
|
|
<div>
|
|
{t("ai.claudeCodeVersion")}: {claudeCliStatus?.version || "-"}
|
|
</div>
|
|
<div>
|
|
{t("ai.claudeCodeAuthMode")}:{" "}
|
|
{claudeAccountStatus?.authMode || "-"}
|
|
</div>
|
|
</div>
|
|
|
|
<div className="flex flex-wrap gap-2">
|
|
<Button
|
|
size="sm"
|
|
variant="outline"
|
|
disabled={busy}
|
|
onClick={() => void detectClaudeCode()}
|
|
>
|
|
<MdRefresh className={busy ? "animate-spin" : ""} />
|
|
{t("ai.detect")}
|
|
</Button>
|
|
<Button
|
|
size="sm"
|
|
variant="outline"
|
|
disabled={busy}
|
|
onClick={() => void refreshClaudeAccount()}
|
|
>
|
|
<MdRefresh className={busy ? "animate-spin" : ""} />
|
|
{t("ai.refreshStatus")}
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</SettingSection>
|
|
|
|
<SettingSection title={t("ai.externalMcp")} contentClassName="space-y-4">
|
|
<SettingRow label={t("ai.externalMcpEnabled")} desc={t("ai.externalMcpDesc")}>
|
|
<div className="flex items-center gap-2">
|
|
<Badge variant={mcpStatus?.running ? "default" : "outline"}>
|
|
{mcpStatus?.error
|
|
? t("ai.externalMcpError")
|
|
: mcpStatus?.running
|
|
? t("ai.externalMcpRunning")
|
|
: t("ai.externalMcpDisabled")}
|
|
</Badge>
|
|
<SettingSwitch
|
|
checked={externalMcp.enabled}
|
|
onChange={(enabled) => void setExternalMcpEnabled(enabled)}
|
|
/>
|
|
</div>
|
|
</SettingRow>
|
|
{mcpStatus?.error ? (
|
|
<div className="text-xs text-destructive">{mcpStatus.error}</div>
|
|
) : null}
|
|
<SettingFieldGrid>
|
|
<AiPermissionSelect
|
|
value={externalMcp.permission_mode}
|
|
targetLabel={t("ai.externalMcp")}
|
|
onValueChange={(permission_mode) =>
|
|
updateExternalMcp({
|
|
permission_mode,
|
|
})
|
|
}
|
|
/>
|
|
<SettingSelect
|
|
label={t("ai.externalMcpScope")}
|
|
value={externalMcp.session_scope}
|
|
onValueChange={(session_scope) =>
|
|
updateExternalMcp({
|
|
session_scope: session_scope as ExternalMcpSettings["session_scope"],
|
|
})
|
|
}
|
|
>
|
|
<SelectItem value="current_window">{t("ai.externalMcpCurrentWindow")}</SelectItem>
|
|
<SelectItem value="all_sessions">{t("ai.externalMcpAllSessions")}</SelectItem>
|
|
</SettingSelect>
|
|
</SettingFieldGrid>
|
|
<div className="text-xs text-muted-foreground">
|
|
{t("ai.externalMcpRuntimeSummary", {
|
|
window: mcpStatus?.ownerWindowLabel ?? "-",
|
|
sessions: mcpStatus?.scopedSessionCount ?? 0,
|
|
connections: mcpStatus?.connectionCount ?? 0,
|
|
})}
|
|
</div>
|
|
<div className="grid gap-3 sm:grid-cols-3">
|
|
{(["codex", "claudeCode", "cursor"] as const).map((client) => (
|
|
<div key={client} className="rounded-md border border-border/70 p-3">
|
|
<div className="text-sm font-medium">
|
|
{client === "codex" ? "Codex" : client === "claudeCode" ? "Claude Code" : "Cursor"}
|
|
</div>
|
|
<Button
|
|
className="mt-3 w-full"
|
|
size="sm"
|
|
variant="outline"
|
|
onClick={() => void copyMcpConfig(client)}
|
|
>
|
|
{t("ai.externalMcpCopyConfig")}
|
|
</Button>
|
|
</div>
|
|
))}
|
|
</div>
|
|
</SettingSection>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function groupKeyForCredential(credential: AIProviderCredential) {
|
|
return isBuiltinProvider(credential.id) ? credential.provider_kind : credential.id;
|
|
}
|
|
|
|
function groupModels(
|
|
models: AIModelConfigItem[],
|
|
credentials: AIProviderCredential[],
|
|
): ModelGroup[] {
|
|
const credentialMap = new Map<string, AIProviderCredential>();
|
|
const groups = new Map<string, AIModelConfigItem[]>();
|
|
for (const credential of credentials) {
|
|
const key = groupKeyForCredential(credential);
|
|
credentialMap.set(key, credential);
|
|
if (!groups.has(key)) groups.set(key, []);
|
|
}
|
|
for (const model of models) {
|
|
if (model.backend === "codex") {
|
|
const key = "codex";
|
|
if (!groups.has(key)) groups.set(key, []);
|
|
groups.get(key)?.push(model);
|
|
continue;
|
|
}
|
|
const key = model.credential_id ?? model.provider_kind ?? "unknown";
|
|
const list = groups.get(key);
|
|
if (list) list.push(model);
|
|
else groups.set(key, [model]);
|
|
}
|
|
return Array.from(groups.entries()).map(([groupKey, items]) => {
|
|
if (groupKey === "codex") {
|
|
return {
|
|
groupKey,
|
|
label: "OpenAI Codex",
|
|
backend: "codex",
|
|
models: items,
|
|
};
|
|
}
|
|
const cred = credentialMap.get(groupKey);
|
|
const label =
|
|
cred && !isBuiltinProvider(cred.id)
|
|
? cred.name || "Custom Provider"
|
|
: getProviderLabel(groupKey);
|
|
return { groupKey, label, credential: cred, models: items };
|
|
});
|
|
}
|
|
|
|
function reconcileProviderModels(
|
|
models: AIModelConfigItem[],
|
|
credential: AIProviderCredential,
|
|
names: string[],
|
|
): AIModelConfigItem[] {
|
|
const builtin = isBuiltinProvider(credential.id);
|
|
const groupKey = groupKeyForCredential(credential);
|
|
const returnedNames = new Map<string, string>();
|
|
for (const name of names) {
|
|
const trimmed = name.trim();
|
|
if (!trimmed) continue;
|
|
const id = builtin
|
|
? aiModelIdForProvider(credential.provider_kind, trimmed)
|
|
: aiModelIdForCredential(credential.id, trimmed);
|
|
returnedNames.set(id, trimmed);
|
|
}
|
|
const now = new Date().toISOString();
|
|
const nextModels = models.map((model) => {
|
|
if (model.backend !== "genai" || (model.credential_id ?? model.provider_kind) !== groupKey) {
|
|
return model;
|
|
}
|
|
return {
|
|
...model,
|
|
last_seen_at: returnedNames.has(model.id) ? now : null,
|
|
};
|
|
});
|
|
const existingIds = new Set(nextModels.map((model) => model.id));
|
|
for (const [id, name] of returnedNames) {
|
|
if (existingIds.has(id)) continue;
|
|
nextModels.push({
|
|
id,
|
|
name,
|
|
backend: "genai",
|
|
provider_kind: credential.provider_kind,
|
|
credential_id: builtin ? null : credential.id,
|
|
enabled: false,
|
|
source: "rust-genai",
|
|
last_seen_at: now,
|
|
});
|
|
}
|
|
return nextModels;
|
|
}
|
|
|
|
export function AiModelsTab() {
|
|
const { t } = useTranslation();
|
|
const { appSettings, updateAppSettings } = useApp();
|
|
const ai = appSettings.ai;
|
|
const [selectedProviderCredentialId, setSelectedProviderCredentialId] = useState<string | null>(
|
|
null,
|
|
);
|
|
const [showProviderChoices, setShowProviderChoices] = useState(false);
|
|
const [providerDraft, setProviderDraft] = useState<{
|
|
credential: AIProviderCredential;
|
|
isAutomatic?: boolean;
|
|
} | null>(() =>
|
|
ai.provider_credentials.some((credential) => credential.enabled)
|
|
? null
|
|
: { credential: newCredential(), isAutomatic: true },
|
|
);
|
|
const [showProviderApiKey, setShowProviderApiKey] = useState(false);
|
|
const [revealedProviderApiKey, setRevealedProviderApiKey] = useState<{
|
|
credentialId: string;
|
|
value: string;
|
|
} | null>(null);
|
|
const [revealingProviderApiKey, setRevealingProviderApiKey] = useState(false);
|
|
const providerApiKeyRevealGeneration = useRef(0);
|
|
const [providerNameInput, setProviderNameInput] = useState<{
|
|
credentialId: string;
|
|
value: string;
|
|
} | null>(null);
|
|
const [showProviderValidation, setShowProviderValidation] = useState(false);
|
|
const [testingProviderId, setTestingProviderId] = useState<string | null>(null);
|
|
const [providerModelCount, setProviderModelCount] = useState<number | null>(null);
|
|
const [testedDraftModels, setTestedDraftModels] = useState<{
|
|
credentialId: string;
|
|
names: string[];
|
|
} | null>(null);
|
|
const [providerConnectionStatus, setProviderConnectionStatus] = useState<
|
|
"idle" | "testing" | "success" | "error"
|
|
>("idle");
|
|
const [providerStatuses, setProviderStatuses] = useState<
|
|
Record<string, "idle" | "testing" | "success" | "error">
|
|
>({});
|
|
const providerConnectionTestGeneration = useRef(0);
|
|
const providerListRefreshGeneration = useRef(0);
|
|
const [manualModelNames, setManualModelNames] = useState<Record<string, string>>({});
|
|
const [editingModelId, setEditingModelId] = useState<string | null>(null);
|
|
const [refreshing, setRefreshing] = useState(false);
|
|
const [refreshingCurrentModels, setRefreshingCurrentModels] = useState(false);
|
|
const [testingModelId, setTestingModelId] = useState<string | null>(null);
|
|
const [modelTestResults, setModelTestResults] = useState<Record<string, "success" | "error">>({});
|
|
const modelTestGeneration = useRef(0);
|
|
const proxySignature = JSON.stringify(ai.proxy);
|
|
const cancelProviderConnectionTest = () => {
|
|
providerConnectionTestGeneration.current += 1;
|
|
setTestingProviderId(null);
|
|
setRefreshingCurrentModels(false);
|
|
if (testingProviderId) {
|
|
setProviderStatuses((current) =>
|
|
current[testingProviderId] === "testing"
|
|
? { ...current, [testingProviderId]: "idle" }
|
|
: current,
|
|
);
|
|
}
|
|
};
|
|
const invalidateProviderListRefresh = () => {
|
|
providerListRefreshGeneration.current += 1;
|
|
setRefreshing(false);
|
|
setProviderStatuses((current) => {
|
|
const next = { ...current };
|
|
for (const id of Object.keys(next)) {
|
|
if (next[id] === "testing") next[id] = "idle";
|
|
}
|
|
return next;
|
|
});
|
|
};
|
|
// biome-ignore lint/correctness/useExhaustiveDependencies: Configuration changes invalidate pending model tests and their results.
|
|
useEffect(() => {
|
|
modelTestGeneration.current += 1;
|
|
setTestingModelId(null);
|
|
setModelTestResults({});
|
|
}, [
|
|
ai.models,
|
|
ai.provider_credentials,
|
|
ai.default_reasoning_effort,
|
|
ai.request_user_agent,
|
|
ai.timeout_ms,
|
|
proxySignature,
|
|
]);
|
|
// biome-ignore lint/correctness/useExhaustiveDependencies: Proxy changes invalidate all pending provider requests and results.
|
|
useEffect(() => {
|
|
providerConnectionTestGeneration.current += 1;
|
|
providerListRefreshGeneration.current += 1;
|
|
setTestingProviderId(null);
|
|
setRefreshingCurrentModels(false);
|
|
setRefreshing(false);
|
|
setProviderStatuses({});
|
|
setProviderConnectionStatus("idle");
|
|
setProviderModelCount(null);
|
|
setTestedDraftModels(null);
|
|
}, [proxySignature]);
|
|
const update = (patch: Partial<AISettings>) => updateAppSettings({ ai: { ...ai, ...patch } });
|
|
|
|
const enabledCredentials = useMemo(
|
|
() => ai.provider_credentials.filter((credential) => credential.enabled),
|
|
[ai.provider_credentials],
|
|
);
|
|
// biome-ignore lint/correctness/useExhaustiveDependencies: Adding or removing an account closes the preset picker.
|
|
useEffect(() => {
|
|
setShowProviderChoices(false);
|
|
}, [enabledCredentials.length]);
|
|
useEffect(() => {
|
|
if (enabledCredentials.length === 0 && !providerDraft) {
|
|
setProviderDraft({ credential: newCredential(), isAutomatic: true });
|
|
} else if (enabledCredentials.length > 0 && providerDraft?.isAutomatic) {
|
|
setProviderDraft(null);
|
|
}
|
|
}, [enabledCredentials.length, providerDraft]);
|
|
const selectedProviderCredential =
|
|
providerDraft?.credential ??
|
|
enabledCredentials.find((credential) => credential.id === selectedProviderCredentialId) ??
|
|
enabledCredentials[0];
|
|
const selectedProviderConnectionStatus =
|
|
selectedProviderCredential && !providerDraft
|
|
? (providerStatuses[selectedProviderCredential.id] ?? "idle")
|
|
: providerConnectionStatus;
|
|
const selectedProviderName =
|
|
providerNameInput &&
|
|
selectedProviderCredential &&
|
|
providerNameInput.credentialId === selectedProviderCredential.id
|
|
? providerNameInput.value
|
|
: (selectedProviderCredential?.name ?? "");
|
|
const selectedProviderDefaultName = selectedProviderCredential
|
|
? defaultProviderName(selectedProviderCredential, enabledCredentials)
|
|
: "my-provider";
|
|
const selectedProviderTitle = selectedProviderName.trim() || selectedProviderDefaultName;
|
|
const selectedProviderNameTaken = providerNameTaken(
|
|
selectedProviderName,
|
|
enabledCredentials,
|
|
selectedProviderCredential?.id,
|
|
);
|
|
const selectedProviderEndpointMissing = !selectedProviderCredential?.base_url?.trim();
|
|
const selectedProviderApiKeyRequired =
|
|
!!selectedProviderCredential &&
|
|
selectedProviderCredential.provider_kind !== "ollama" &&
|
|
selectedProviderCredential.provider_kind !== "openai_compatible";
|
|
const selectedProviderApiKeyMissing =
|
|
selectedProviderApiKeyRequired && !selectedProviderCredential?.api_key?.trim();
|
|
// biome-ignore lint/correctness/useExhaustiveDependencies: These transient fields belong to the selected account.
|
|
useEffect(() => {
|
|
setProviderNameInput(null);
|
|
setShowProviderValidation(false);
|
|
setEditingModelId(null);
|
|
}, [selectedProviderCredential?.id]);
|
|
// biome-ignore lint/correctness/useExhaustiveDependencies: Switching accounts must hide and discard the previously revealed secret.
|
|
useEffect(() => {
|
|
providerApiKeyRevealGeneration.current += 1;
|
|
setShowProviderApiKey(false);
|
|
setRevealedProviderApiKey(null);
|
|
setRevealingProviderApiKey(false);
|
|
}, [selectedProviderCredential?.id]);
|
|
|
|
const hideProviderApiKey = () => {
|
|
providerApiKeyRevealGeneration.current += 1;
|
|
setShowProviderApiKey(false);
|
|
setRevealedProviderApiKey(null);
|
|
setRevealingProviderApiKey(false);
|
|
};
|
|
|
|
const toggleProviderApiKeyVisibility = async () => {
|
|
if (!selectedProviderCredential) return;
|
|
if (showProviderApiKey) {
|
|
hideProviderApiKey();
|
|
return;
|
|
}
|
|
if (!isCloudSecretMasked(selectedProviderCredential.api_key)) {
|
|
setShowProviderApiKey(true);
|
|
return;
|
|
}
|
|
|
|
const credentialId = selectedProviderCredential.id;
|
|
const generation = ++providerApiKeyRevealGeneration.current;
|
|
setRevealingProviderApiKey(true);
|
|
try {
|
|
const value = await invoke<string>("reveal_ai_provider_api_key", {
|
|
credentialId,
|
|
});
|
|
if (providerApiKeyRevealGeneration.current !== generation) return;
|
|
setRevealedProviderApiKey({ credentialId, value });
|
|
setShowProviderApiKey(true);
|
|
} catch (error) {
|
|
if (providerApiKeyRevealGeneration.current === generation) {
|
|
toast.error(getErrorMessage(error));
|
|
}
|
|
} finally {
|
|
if (providerApiKeyRevealGeneration.current === generation) {
|
|
setRevealingProviderApiKey(false);
|
|
}
|
|
}
|
|
};
|
|
|
|
const visibleModels = useMemo(() => {
|
|
if (!selectedProviderCredential) return [];
|
|
if (providerDraft) {
|
|
return testedDraftModels?.credentialId === selectedProviderCredential.id
|
|
? reconcileProviderModels([], selectedProviderCredential, testedDraftModels.names)
|
|
: [];
|
|
}
|
|
const groupKey = groupKeyForCredential(selectedProviderCredential);
|
|
return ai.models.filter(
|
|
(model) =>
|
|
model.backend !== "codex" && (model.credential_id ?? model.provider_kind) === groupKey,
|
|
);
|
|
}, [ai.models, selectedProviderCredential, providerDraft, testedDraftModels]);
|
|
|
|
const hasEnabledModel = ai.models.some(
|
|
(model) =>
|
|
model.enabled &&
|
|
(model.backend === "codex"
|
|
? ai.codex.enabled
|
|
: enabledCredentials.some(
|
|
(credential) =>
|
|
groupKeyForCredential(credential) === (model.credential_id ?? model.provider_kind),
|
|
)),
|
|
);
|
|
|
|
const rawGroupedModels = useMemo(
|
|
() =>
|
|
groupModels(visibleModels, selectedProviderCredential ? [selectedProviderCredential] : []),
|
|
[visibleModels, selectedProviderCredential],
|
|
);
|
|
|
|
const sortOrderRef = useRef<Map<string, string[]>>(new Map());
|
|
|
|
const groupedModels = useMemo(() => {
|
|
const prevOrder = sortOrderRef.current;
|
|
const nextOrder = new Map<string, string[]>();
|
|
|
|
const sorted = rawGroupedModels.map((group) => {
|
|
const prevIds = prevOrder.get(group.groupKey);
|
|
const currentIds = group.models.map((m) => m.id);
|
|
const currentIdSet = new Set(currentIds);
|
|
const sameSet =
|
|
prevIds !== undefined &&
|
|
prevIds.length === currentIds.length &&
|
|
prevIds.every((id) => currentIdSet.has(id));
|
|
|
|
if (sameSet && prevIds) {
|
|
const modelMap = new Map(group.models.map((m) => [m.id, m]));
|
|
const orderedModels = prevIds
|
|
.map((id) => modelMap.get(id))
|
|
.filter((m): m is AIModelConfigItem => m !== undefined);
|
|
nextOrder.set(group.groupKey, prevIds);
|
|
return { ...group, models: orderedModels };
|
|
}
|
|
|
|
const freshSorted = [...group.models].sort((a, b) => Number(b.enabled) - Number(a.enabled));
|
|
nextOrder.set(
|
|
group.groupKey,
|
|
freshSorted.map((m) => m.id),
|
|
);
|
|
return { ...group, models: freshSorted };
|
|
});
|
|
|
|
sortOrderRef.current = nextOrder;
|
|
return sorted;
|
|
}, [rawGroupedModels]);
|
|
|
|
const updateModels = (models: AIModelConfigItem[]) => {
|
|
update({ models, default_model_id: updateDefaultModelId(ai, models) });
|
|
};
|
|
|
|
const refreshProviders = async () => {
|
|
if (refreshing || enabledCredentials.length === 0) return;
|
|
const generation = ++providerListRefreshGeneration.current;
|
|
setRefreshing(true);
|
|
setProviderModelCount(null);
|
|
setProviderStatuses((current) => {
|
|
const next = { ...current };
|
|
for (const credential of enabledCredentials) next[credential.id] = "testing";
|
|
return next;
|
|
});
|
|
try {
|
|
const results = await Promise.all(
|
|
enabledCredentials.map(async (credential) => {
|
|
try {
|
|
const names = await invoke<string[]>("test_ai_provider_connection", {
|
|
aiSettings: ai,
|
|
credentialId: credential.id,
|
|
});
|
|
if (providerListRefreshGeneration.current === generation) {
|
|
setProviderStatuses((current) => ({ ...current, [credential.id]: "success" }));
|
|
}
|
|
return { credential, names };
|
|
} catch {
|
|
if (providerListRefreshGeneration.current === generation) {
|
|
setProviderStatuses((current) => ({ ...current, [credential.id]: "error" }));
|
|
}
|
|
return null;
|
|
}
|
|
}),
|
|
);
|
|
if (providerListRefreshGeneration.current !== generation) return;
|
|
const successes = results.filter(
|
|
(result): result is NonNullable<typeof result> => result !== null,
|
|
);
|
|
if (successes.length > 0) {
|
|
updateAppSettings((current) => {
|
|
let models = current.ai.models;
|
|
for (const { credential, names } of successes) {
|
|
if (
|
|
!current.ai.provider_credentials.some(
|
|
(item) => item.id === credential.id && item.enabled,
|
|
)
|
|
) {
|
|
continue;
|
|
}
|
|
models = reconcileProviderModels(models, credential, names);
|
|
}
|
|
return {
|
|
ai: {
|
|
...current.ai,
|
|
models,
|
|
default_model_id: updateDefaultModelId(current.ai, models),
|
|
},
|
|
};
|
|
});
|
|
}
|
|
} finally {
|
|
if (providerListRefreshGeneration.current === generation) setRefreshing(false);
|
|
}
|
|
};
|
|
|
|
const testModel = async (model: AIModelConfigItem) => {
|
|
if (testingModelId || model.backend !== "genai") return;
|
|
const generation = ++modelTestGeneration.current;
|
|
setTestingModelId(model.id);
|
|
setModelTestResults((previous) => {
|
|
const next = { ...previous };
|
|
delete next[model.id];
|
|
return next;
|
|
});
|
|
try {
|
|
await invoke("test_ai_model_connection", { aiSettings: ai, modelId: model.id });
|
|
if (modelTestGeneration.current !== generation) return;
|
|
setModelTestResults((previous) => ({ ...previous, [model.id]: "success" }));
|
|
toast.success(t("ai.modelTestSucceeded", { model: model.name }));
|
|
} catch (error) {
|
|
if (modelTestGeneration.current !== generation) return;
|
|
setModelTestResults((previous) => ({ ...previous, [model.id]: "error" }));
|
|
toast.error(t("ai.modelTestFailed", { model: model.name }), {
|
|
description: getErrorMessage(error),
|
|
});
|
|
} finally {
|
|
if (modelTestGeneration.current === generation) setTestingModelId(null);
|
|
}
|
|
};
|
|
|
|
const updateModel = (id: string, patch: Partial<AIModelConfigItem>) => {
|
|
const models = ai.models.map((model) => {
|
|
if (model.id !== id) return model;
|
|
const next = { ...model, ...patch };
|
|
if (next.source === "manual") {
|
|
next.id = next.credential_id
|
|
? aiModelIdForCredential(next.credential_id, next.name)
|
|
: aiModelIdForProvider(next.provider_kind ?? "openai_compatible", next.name);
|
|
}
|
|
return next;
|
|
});
|
|
updateModels(models);
|
|
};
|
|
|
|
const toggleModelReasoningEffort = (model: AIModelConfigItem, effort: AIModelReasoningEffort) => {
|
|
const selected = new Set(model.supported_reasoning_efforts ?? DEFAULT_MODEL_REASONING_EFFORTS);
|
|
if (selected.has(effort)) selected.delete(effort);
|
|
else selected.add(effort);
|
|
updateModel(model.id, {
|
|
supported_reasoning_efforts: MODEL_REASONING_EFFORTS.filter((value) => selected.has(value)),
|
|
});
|
|
};
|
|
|
|
const addManualModel = (credential: AIProviderCredential) => {
|
|
const groupKey = groupKeyForCredential(credential);
|
|
const name = (manualModelNames[groupKey] ?? "").trim();
|
|
if (!name || !credential) return;
|
|
|
|
const builtin = isBuiltinProvider(credential.id);
|
|
const id = builtin
|
|
? aiModelIdForProvider(credential.provider_kind, name)
|
|
: aiModelIdForCredential(credential.id, name);
|
|
const existing = ai.models.find((model) => model.id === id);
|
|
|
|
if (existing?.enabled) {
|
|
toast.info(t("ai.manualModelExists", { model: name }));
|
|
return;
|
|
}
|
|
|
|
if (existing) {
|
|
const models = ai.models.map((model) =>
|
|
model.id === id
|
|
? {
|
|
...model,
|
|
name,
|
|
provider_kind: credential.provider_kind,
|
|
credential_id: builtin ? null : credential.id,
|
|
enabled: true,
|
|
}
|
|
: model,
|
|
);
|
|
updateModels(models);
|
|
setManualModelNames((prev) => ({ ...prev, [groupKey]: "" }));
|
|
toast.success(t("ai.manualModelAdded", { model: name }));
|
|
return;
|
|
}
|
|
|
|
const model: AIModelConfigItem = {
|
|
id,
|
|
name,
|
|
provider_kind: credential.provider_kind,
|
|
credential_id: builtin ? null : credential.id,
|
|
enabled: true,
|
|
source: "manual",
|
|
backend: "genai",
|
|
last_seen_at: null,
|
|
};
|
|
const models = [model, ...ai.models];
|
|
update({
|
|
models,
|
|
default_model_id:
|
|
ai.default_model_id &&
|
|
models.some((item) => item.enabled && item.id === ai.default_model_id)
|
|
? ai.default_model_id
|
|
: model.id,
|
|
});
|
|
setManualModelNames((prev) => ({ ...prev, [groupKey]: "" }));
|
|
toast.success(t("ai.manualModelAdded", { model: name }));
|
|
};
|
|
|
|
const removeModel = (id: string) => {
|
|
const model = ai.models.find((item) => item.id === id);
|
|
if (!model) return;
|
|
setEditingModelId((current) => (current === id ? null : current));
|
|
const models = ai.models.filter((item) => item.id !== id);
|
|
update({
|
|
models,
|
|
default_model_id: updateDefaultModelId(ai, models),
|
|
});
|
|
toast.success(t("ai.manualModelDeleted", { model: model.name }));
|
|
};
|
|
|
|
const updateCredential = (id: string, patch: Partial<AIProviderCredential>) => {
|
|
if (typeof patch.name === "string" && providerNameTaken(patch.name, enabledCredentials, id)) {
|
|
return;
|
|
}
|
|
invalidateProviderListRefresh();
|
|
setProviderStatuses((current) => ({ ...current, [id]: "idle" }));
|
|
cancelProviderConnectionTest();
|
|
setProviderConnectionStatus("idle");
|
|
setProviderModelCount(null);
|
|
if (patch.provider_kind) {
|
|
patch = { ...patch, api_protocol: null };
|
|
}
|
|
if (
|
|
patch.provider_kind &&
|
|
!supportsApiFormatSelection({ provider_kind: patch.provider_kind })
|
|
) {
|
|
patch = { ...patch, api_format: "chat_completions" };
|
|
}
|
|
const nextCredentials = ai.provider_credentials.map((credential) =>
|
|
credential.id === id ? { ...credential, ...patch } : credential,
|
|
);
|
|
const nextCredential = nextCredentials.find((credential) => credential.id === id);
|
|
let nextModels = ai.models.map((model) =>
|
|
model.credential_id === id && nextCredential
|
|
? { ...model, provider_kind: nextCredential.provider_kind }
|
|
: model,
|
|
);
|
|
|
|
if (nextCredential && isBuiltinProvider(id) && "enabled" in patch) {
|
|
const providerKind = nextCredential.provider_kind;
|
|
const builtinInfo = BUILTIN_PROVIDERS[providerKind];
|
|
if (builtinInfo) {
|
|
if (patch.enabled) {
|
|
const existingIds = new Set(nextModels.map((m) => m.id));
|
|
for (const name of builtinInfo.models) {
|
|
const modelId = aiModelIdForProvider(providerKind, name);
|
|
if (!existingIds.has(modelId)) {
|
|
nextModels.push({
|
|
id: modelId,
|
|
name,
|
|
backend: "genai",
|
|
provider_kind: providerKind,
|
|
credential_id: null,
|
|
enabled: false,
|
|
source: "rust-genai",
|
|
last_seen_at: null,
|
|
});
|
|
}
|
|
}
|
|
} else {
|
|
nextModels = nextModels.filter(
|
|
(m) => m.provider_kind !== providerKind || m.credential_id != null,
|
|
);
|
|
}
|
|
}
|
|
}
|
|
|
|
update({
|
|
provider_credentials: nextCredentials,
|
|
models: nextModels,
|
|
default_model_id: updateDefaultModelId(ai, nextModels),
|
|
});
|
|
};
|
|
|
|
const updateSelectedProviderCredential = (id: string, patch: Partial<AIProviderCredential>) => {
|
|
if (providerDraft?.credential.id !== id) {
|
|
updateCredential(id, patch);
|
|
return;
|
|
}
|
|
|
|
cancelProviderConnectionTest();
|
|
setProviderConnectionStatus("idle");
|
|
setProviderModelCount(null);
|
|
if (patch.provider_kind) {
|
|
patch = { ...patch, api_protocol: null };
|
|
}
|
|
if (
|
|
patch.provider_kind &&
|
|
!supportsApiFormatSelection({ provider_kind: patch.provider_kind })
|
|
) {
|
|
patch = { ...patch, api_format: "chat_completions" };
|
|
}
|
|
if (
|
|
"base_url" in patch ||
|
|
"api_key" in patch ||
|
|
"api_protocol" in patch ||
|
|
"api_format" in patch
|
|
) {
|
|
setTestedDraftModels(null);
|
|
}
|
|
setProviderDraft((current) =>
|
|
current?.credential.id === id
|
|
? { ...current, isAutomatic: false, credential: { ...current.credential, ...patch } }
|
|
: current,
|
|
);
|
|
};
|
|
|
|
const changeCustomProviderIcon = async () => {
|
|
const credential = selectedProviderCredential;
|
|
if (!credential || credential.provider_kind !== "openai_compatible") return;
|
|
|
|
try {
|
|
const selectedPath = await openDialog({
|
|
directory: false,
|
|
multiple: false,
|
|
filters: [
|
|
{
|
|
name: t("dialog.connectionIconFiles"),
|
|
extensions: ["png", "jpg", "jpeg", "webp", "bmp", "gif"],
|
|
},
|
|
],
|
|
title: t("ai.changeProviderIcon"),
|
|
});
|
|
if (typeof selectedPath !== "string" || !selectedPath) return;
|
|
|
|
const iconDataUrl = await invoke<string>("import_ai_provider_icon", { path: selectedPath });
|
|
updateSelectedProviderCredential(credential.id, { icon_data_url: iconDataUrl });
|
|
} catch (error) {
|
|
toast.error(getErrorMessage(error));
|
|
}
|
|
};
|
|
|
|
const changeSelectedProviderName = (value: string) => {
|
|
if (!selectedProviderCredential) return;
|
|
if (providerDraft) {
|
|
updateSelectedProviderCredential(selectedProviderCredential.id, { name: value });
|
|
return;
|
|
}
|
|
setProviderNameInput({ credentialId: selectedProviderCredential.id, value });
|
|
if (
|
|
value.trim() &&
|
|
!providerNameTaken(value, enabledCredentials, selectedProviderCredential.id)
|
|
) {
|
|
updateSelectedProviderCredential(selectedProviderCredential.id, { name: value });
|
|
}
|
|
};
|
|
|
|
const finishSelectedProviderName = () => {
|
|
if (!selectedProviderCredential || providerDraft) return;
|
|
const value =
|
|
providerNameInput?.credentialId === selectedProviderCredential.id
|
|
? providerNameInput.value
|
|
: selectedProviderCredential.name;
|
|
if (providerNameTaken(value, enabledCredentials, selectedProviderCredential.id)) {
|
|
toast.error(t("ai.providerNameDuplicate"));
|
|
} else if (!value.trim()) {
|
|
updateSelectedProviderCredential(selectedProviderCredential.id, {
|
|
name: selectedProviderDefaultName,
|
|
});
|
|
}
|
|
setProviderNameInput(null);
|
|
};
|
|
|
|
const updateSelectedProviderProtocol = (credential: AIProviderCredential, value: string) => {
|
|
const previousProtocol = providerApiProtocol(credential);
|
|
const api_protocol: AIProviderApiProtocol =
|
|
value === "chat_completions" || value === "responses"
|
|
? "openai_compatible"
|
|
: (value as AIProviderApiProtocol);
|
|
const patch: Partial<AIProviderCredential> = {
|
|
api_protocol,
|
|
api_format: value === "responses" ? "responses" : "chat_completions",
|
|
};
|
|
if (!credential.base_url?.trim()) {
|
|
patch.base_url =
|
|
BUILTIN_PROVIDERS[credential.provider_kind]?.defaultBaseUrl ?? credential.base_url;
|
|
}
|
|
if (credential.provider_kind === "ollama") {
|
|
const baseUrl = credential.base_url?.trim();
|
|
if (previousProtocol === "ollama" && api_protocol === "openai_compatible") {
|
|
if (baseUrl === "http://localhost:11434/") {
|
|
patch.base_url = "http://localhost:11434/v1/";
|
|
}
|
|
} else if (previousProtocol === "openai_compatible" && api_protocol === "ollama") {
|
|
if (baseUrl === "http://localhost:11434/v1/") {
|
|
patch.base_url = "http://localhost:11434/";
|
|
}
|
|
}
|
|
}
|
|
updateSelectedProviderCredential(credential.id, patch);
|
|
};
|
|
|
|
const selectProviderPreset = (providerKind: AIProviderKind) => {
|
|
cancelProviderConnectionTest();
|
|
setProviderModelCount(null);
|
|
setProviderConnectionStatus("idle");
|
|
setTestedDraftModels(null);
|
|
hideProviderApiKey();
|
|
const providerInfo = BUILTIN_PROVIDERS[providerKind];
|
|
const providerLabel = getProviderLabel(providerKind);
|
|
const existingBuiltin = providerInfo
|
|
? ai.provider_credentials.find((credential) => credential.id === providerKind)
|
|
: undefined;
|
|
let credential: AIProviderCredential;
|
|
if (providerInfo && existingBuiltin && !existingBuiltin.enabled) {
|
|
credential = {
|
|
...existingBuiltin,
|
|
name: availableProviderName(
|
|
existingBuiltin.name.trim() || providerLabel,
|
|
enabledCredentials,
|
|
),
|
|
api_protocol: null,
|
|
api_format: "chat_completions",
|
|
base_url: providerInfo.defaultBaseUrl,
|
|
enabled: true,
|
|
};
|
|
} else {
|
|
credential = {
|
|
...newCredential(),
|
|
id: providerInfo && !existingBuiltin ? providerKind : `credential-${randomUUID()}`,
|
|
name: availableProviderName(providerLabel, enabledCredentials),
|
|
provider_kind: providerKind,
|
|
base_url: providerInfo ? providerInfo.defaultBaseUrl : "",
|
|
};
|
|
}
|
|
addProviderCredential(credential);
|
|
};
|
|
|
|
const stageCustomProvider = () => {
|
|
cancelProviderConnectionTest();
|
|
setProviderModelCount(null);
|
|
setProviderConnectionStatus("idle");
|
|
setTestedDraftModels(null);
|
|
hideProviderApiKey();
|
|
setProviderDraft({ credential: newCredential() });
|
|
};
|
|
|
|
const addProviderCredential = (draftCredential: AIProviderCredential) => {
|
|
const credentialId = draftCredential.id;
|
|
const name = availableProviderName(
|
|
draftCredential.name.trim() || defaultProviderName(draftCredential, enabledCredentials),
|
|
enabledCredentials,
|
|
credentialId,
|
|
);
|
|
invalidateProviderListRefresh();
|
|
const credential = {
|
|
...draftCredential,
|
|
id: credentialId,
|
|
name,
|
|
base_url: draftCredential.base_url?.trim() ?? "",
|
|
api_key: draftCredential.api_key?.trim() ?? "",
|
|
enabled: true,
|
|
};
|
|
const existingIndex = ai.provider_credentials.findIndex((item) => item.id === credentialId);
|
|
const providerCredentials = [...ai.provider_credentials];
|
|
if (existingIndex >= 0) providerCredentials[existingIndex] = credential;
|
|
else providerCredentials.unshift(credential);
|
|
|
|
let models = [...ai.models];
|
|
const providerInfo = BUILTIN_PROVIDERS[credential.provider_kind];
|
|
if (providerInfo) {
|
|
const existingModelIds = new Set(models.map((model) => model.id));
|
|
for (const name of providerInfo.models) {
|
|
const id =
|
|
credentialId === credential.provider_kind
|
|
? aiModelIdForProvider(credential.provider_kind, name)
|
|
: aiModelIdForCredential(credentialId, name);
|
|
if (existingModelIds.has(id)) continue;
|
|
models.push({
|
|
id,
|
|
name,
|
|
backend: "genai",
|
|
provider_kind: credential.provider_kind,
|
|
credential_id: credentialId === credential.provider_kind ? null : credentialId,
|
|
enabled: false,
|
|
source: "rust-genai",
|
|
last_seen_at: null,
|
|
});
|
|
}
|
|
}
|
|
if (testedDraftModels?.credentialId === draftCredential.id) {
|
|
models = reconcileProviderModels(models, credential, testedDraftModels.names);
|
|
}
|
|
|
|
update({
|
|
provider_credentials: providerCredentials,
|
|
models,
|
|
default_model_id: updateDefaultModelId(ai, models),
|
|
});
|
|
setProviderDraft(null);
|
|
setProviderStatuses((current) => ({
|
|
...current,
|
|
[credentialId]:
|
|
providerDraft?.credential.id === credentialId && providerConnectionStatus === "success"
|
|
? "success"
|
|
: "idle",
|
|
}));
|
|
setTestedDraftModels(null);
|
|
setSelectedProviderCredentialId(credentialId);
|
|
setProviderNameInput(null);
|
|
setShowProviderValidation(false);
|
|
setShowProviderChoices(false);
|
|
setProviderConnectionStatus("idle");
|
|
setProviderModelCount(null);
|
|
cancelProviderConnectionTest();
|
|
};
|
|
|
|
const addCustomProvider = () => {
|
|
const credential =
|
|
providerDraft?.credential.provider_kind === "openai_compatible"
|
|
? providerDraft.credential
|
|
: newCredential();
|
|
addProviderCredential(credential);
|
|
};
|
|
|
|
const removeCredential = (id: string) => {
|
|
invalidateProviderListRefresh();
|
|
setProviderStatuses((current) => {
|
|
const next = { ...current };
|
|
delete next[id];
|
|
return next;
|
|
});
|
|
const models = ai.models.filter((model) => model.credential_id !== id);
|
|
update({
|
|
provider_credentials: ai.provider_credentials.filter((credential) => credential.id !== id),
|
|
models,
|
|
default_model_id: updateDefaultModelId(ai, models),
|
|
});
|
|
};
|
|
|
|
const deleteSelectedProvider = () => {
|
|
if (!selectedProviderCredential) return;
|
|
if (providerDraft) {
|
|
setProviderDraft(null);
|
|
setTestedDraftModels(null);
|
|
} else {
|
|
const id = selectedProviderCredential.id;
|
|
const nextCredential = enabledCredentials.find((credential) => credential.id !== id);
|
|
if (isBuiltinProvider(id)) {
|
|
updateCredential(id, { enabled: false });
|
|
} else {
|
|
removeCredential(id);
|
|
}
|
|
setSelectedProviderCredentialId(nextCredential?.id ?? null);
|
|
}
|
|
cancelProviderConnectionTest();
|
|
setProviderModelCount(null);
|
|
setProviderConnectionStatus("idle");
|
|
hideProviderApiKey();
|
|
};
|
|
|
|
const testProviderConnection = async (fromModelList = false) => {
|
|
if (!selectedProviderCredential) return;
|
|
const credentialId = selectedProviderCredential.id;
|
|
const requestGeneration = ++providerConnectionTestGeneration.current;
|
|
setTestingProviderId(credentialId);
|
|
if (fromModelList) setRefreshingCurrentModels(true);
|
|
setProviderModelCount(null);
|
|
setProviderConnectionStatus("testing");
|
|
if (!providerDraft) {
|
|
setProviderStatuses((current) => ({ ...current, [credentialId]: "testing" }));
|
|
}
|
|
try {
|
|
const aiSettings = providerDraft
|
|
? {
|
|
...ai,
|
|
provider_credentials: [...ai.provider_credentials, selectedProviderCredential],
|
|
}
|
|
: ai;
|
|
const modelNames = await invoke<string[]>("test_ai_provider_connection", {
|
|
aiSettings,
|
|
credentialId,
|
|
});
|
|
if (providerConnectionTestGeneration.current === requestGeneration) {
|
|
setProviderModelCount(modelNames.length);
|
|
setProviderConnectionStatus("success");
|
|
if (!providerDraft) {
|
|
setProviderStatuses((current) => ({ ...current, [credentialId]: "success" }));
|
|
}
|
|
if (providerDraft) {
|
|
setTestedDraftModels({ credentialId, names: modelNames });
|
|
} else {
|
|
updateAppSettings((current) => {
|
|
const models = reconcileProviderModels(
|
|
current.ai.models,
|
|
selectedProviderCredential,
|
|
modelNames,
|
|
);
|
|
return {
|
|
ai: {
|
|
...current.ai,
|
|
models,
|
|
default_model_id: updateDefaultModelId(current.ai, models),
|
|
},
|
|
};
|
|
});
|
|
}
|
|
toast.success(t(fromModelList ? "ai.modelsRefreshed" : "ai.connectionTestSucceeded"));
|
|
}
|
|
} catch (error) {
|
|
if (providerConnectionTestGeneration.current === requestGeneration) {
|
|
setProviderConnectionStatus("error");
|
|
if (!providerDraft) {
|
|
setProviderStatuses((current) => ({ ...current, [credentialId]: "error" }));
|
|
}
|
|
toast.error(getErrorMessage(error));
|
|
}
|
|
} finally {
|
|
if (providerConnectionTestGeneration.current === requestGeneration) {
|
|
if (fromModelList) setRefreshingCurrentModels(false);
|
|
setTestingProviderId(null);
|
|
}
|
|
}
|
|
};
|
|
|
|
const protocolOptions = (credential: AIProviderCredential) => (
|
|
<>
|
|
<SelectItem value="chat_completions">{t("ai.providerProtocolChatCompletions")}</SelectItem>
|
|
<SelectItem value="responses">{t("ai.providerProtocolResponses")}</SelectItem>
|
|
<SelectItem value="anthropic">{t("ai.providerProtocolAnthropic")}</SelectItem>
|
|
{(credential.provider_kind === "gemini" ||
|
|
credential.provider_kind === "openai_compatible") && (
|
|
<SelectItem value="gemini">{t("ai.providerProtocolGemini")}</SelectItem>
|
|
)}
|
|
{credential.provider_kind === "ollama" && (
|
|
<SelectItem value="ollama">{t("ai.providerProtocolOllama")}</SelectItem>
|
|
)}
|
|
</>
|
|
);
|
|
|
|
const renderModelListCard = () => (
|
|
<SettingSection
|
|
title={t("ai.modelList")}
|
|
headerRowClassName="flex-row items-center justify-between sm:items-center"
|
|
action={
|
|
<Button
|
|
type="button"
|
|
size="icon-sm"
|
|
variant="outline"
|
|
disabled={
|
|
refreshing ||
|
|
refreshingCurrentModels ||
|
|
testingProviderId !== null ||
|
|
!selectedProviderCredential ||
|
|
selectedProviderEndpointMissing ||
|
|
selectedProviderApiKeyMissing
|
|
}
|
|
onClick={() => void testProviderConnection(true)}
|
|
title={t("ai.refreshModels")}
|
|
aria-label={t("ai.refreshModels")}
|
|
>
|
|
<MdRefresh className={refreshingCurrentModels ? "animate-spin" : ""} />
|
|
</Button>
|
|
}
|
|
>
|
|
<div>
|
|
{groupedModels.length === 0 ? (
|
|
<div className="px-3 py-8 text-center text-xs text-muted-foreground">
|
|
{t("ai.noModels")}
|
|
</div>
|
|
) : (
|
|
groupedModels.map((group) => (
|
|
<div key={group.groupKey}>
|
|
{group.credential && !providerDraft ? (
|
|
<div className="border-b border-border/60 px-3 py-2">
|
|
<div className="flex h-8 overflow-hidden rounded-md border border-border/60 bg-muted/12 transition-colors focus-within:border-primary/45 focus-within:bg-background/70 focus-within:ring-1 focus-within:ring-primary/15">
|
|
<Input
|
|
value={manualModelNames[group.groupKey] ?? ""}
|
|
placeholder={t("ai.manualModelPlaceholder")}
|
|
className="h-full min-w-0 flex-1 rounded-none border-0 bg-transparent px-3 font-mono text-xs shadow-none placeholder:text-muted-foreground/55 focus-visible:border-transparent focus-visible:ring-0"
|
|
onChange={(event) =>
|
|
setManualModelNames((prev) => ({
|
|
...prev,
|
|
[group.groupKey]: event.target.value,
|
|
}))
|
|
}
|
|
onKeyDown={(event) => {
|
|
if (event.key === "Enter" && group.credential) {
|
|
event.preventDefault();
|
|
addManualModel(group.credential);
|
|
}
|
|
}}
|
|
/>
|
|
<Button
|
|
size="sm"
|
|
variant="ghost"
|
|
disabled={!manualModelNames[group.groupKey]?.trim()}
|
|
title={t("common.add")}
|
|
className="h-full rounded-none border-l border-border/60 px-3 text-xs text-muted-foreground hover:bg-primary/10 hover:text-primary disabled:opacity-35"
|
|
onClick={() => group.credential && addManualModel(group.credential)}
|
|
>
|
|
<MdAdd />
|
|
{t("common.add")}
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
) : null}
|
|
{group.models.map((model) => (
|
|
<div key={model.id} className="border-b border-border/60 last:border-b-0">
|
|
<div className="flex items-center gap-3 px-3 py-2">
|
|
<div className="flex min-w-0 flex-1 items-center gap-2">
|
|
<div className="min-w-0 truncate text-xs">{model.name}</div>
|
|
{model.backend === "genai" && model.last_seen_at ? (
|
|
<Badge
|
|
variant="outline"
|
|
className="h-5 shrink-0 border-primary/40 bg-primary/10 px-1.5 text-[0.625rem] font-normal text-primary"
|
|
>
|
|
{t("ai.providerReturnedBadge")}
|
|
</Badge>
|
|
) : null}
|
|
{model.source === "manual" ? (
|
|
<Badge
|
|
variant="outline"
|
|
className="h-5 border-border/70 px-1.5 text-[0.625rem] font-normal text-muted-foreground"
|
|
>
|
|
{t("ai.manualModelBadge")}
|
|
</Badge>
|
|
) : null}
|
|
</div>
|
|
<div className="flex shrink-0 items-center gap-1">
|
|
<Button
|
|
type="button"
|
|
size="icon-sm"
|
|
variant="ghost"
|
|
title={t(
|
|
model.backend === "codex"
|
|
? "ai.modelTestUnsupported"
|
|
: modelTestResults[model.id] === "success"
|
|
? "ai.modelTestSucceededShort"
|
|
: modelTestResults[model.id] === "error"
|
|
? "ai.modelTestFailedShort"
|
|
: "ai.testModel",
|
|
)}
|
|
aria-label={t("ai.testModel")}
|
|
className={
|
|
modelTestResults[model.id] === "success"
|
|
? "text-emerald-500"
|
|
: modelTestResults[model.id] === "error"
|
|
? "text-destructive"
|
|
: undefined
|
|
}
|
|
disabled={
|
|
!!providerDraft || model.backend !== "genai" || testingModelId !== null
|
|
}
|
|
onClick={() => void testModel(model)}
|
|
>
|
|
<TbPlugConnected
|
|
className={`text-[0.95rem] ${
|
|
testingModelId === model.id ? "animate-pulse" : ""
|
|
}`}
|
|
/>
|
|
</Button>
|
|
<Button
|
|
type="button"
|
|
size="icon-sm"
|
|
variant="ghost"
|
|
title={t("ai.editModelConfig")}
|
|
aria-label={t("ai.editModelConfig")}
|
|
aria-expanded={editingModelId === model.id}
|
|
aria-controls={`model-config-${encodeURIComponent(model.id)}`}
|
|
disabled={!!providerDraft}
|
|
onClick={() =>
|
|
setEditingModelId((current) =>
|
|
current === model.id ? null : model.id,
|
|
)
|
|
}
|
|
>
|
|
<MdEdit className="text-[0.95rem]" />
|
|
</Button>
|
|
<DeleteIconButton
|
|
title={t("ai.deleteModel")}
|
|
onDelete={() => removeModel(model.id)}
|
|
disabled={!!providerDraft}
|
|
/>
|
|
</div>
|
|
<SettingSwitch
|
|
checked={model.enabled}
|
|
disabled={!!providerDraft}
|
|
onChange={(enabled) => updateModel(model.id, { enabled })}
|
|
/>
|
|
</div>
|
|
{editingModelId === model.id ? (
|
|
<div
|
|
id={`model-config-${encodeURIComponent(model.id)}`}
|
|
className="border-t border-border/60 bg-muted/10 px-3 py-3"
|
|
>
|
|
<div className="text-xs font-medium">{t("ai.supportedReasoningEfforts")}</div>
|
|
<fieldset
|
|
aria-label={t("ai.supportedReasoningEfforts")}
|
|
className="mt-2 flex flex-wrap gap-2"
|
|
>
|
|
{MODEL_REASONING_EFFORTS.map((effort) => {
|
|
const selected = (
|
|
model.supported_reasoning_efforts ?? DEFAULT_MODEL_REASONING_EFFORTS
|
|
).includes(effort);
|
|
return (
|
|
<Button
|
|
key={effort}
|
|
type="button"
|
|
size="sm"
|
|
variant="outline"
|
|
aria-pressed={!!selected}
|
|
className={`h-7 px-2.5 text-xs font-normal ${
|
|
selected ? "border-primary bg-primary/10 text-primary" : ""
|
|
}`}
|
|
onClick={() => toggleModelReasoningEffort(model, effort)}
|
|
>
|
|
{effort}
|
|
</Button>
|
|
);
|
|
})}
|
|
</fieldset>
|
|
</div>
|
|
) : null}
|
|
</div>
|
|
))}
|
|
</div>
|
|
))
|
|
)}
|
|
</div>
|
|
{enabledCredentials.length === 0 ? (
|
|
<div className="text-xs text-muted-foreground">{t("ai.manualModelNoProvider")}</div>
|
|
) : null}
|
|
{!hasEnabledModel ? (
|
|
<div className="text-xs text-amber-600">{t("ai.enableOneModelHint")}</div>
|
|
) : null}
|
|
</SettingSection>
|
|
);
|
|
|
|
return (
|
|
<div className="space-y-5">
|
|
<SettingSection
|
|
title={t("ai.providerList")}
|
|
headerRowClassName="flex-row flex-wrap items-center justify-between sm:items-center"
|
|
contentClassName={
|
|
enabledCredentials.length === 0 && !showProviderChoices ? "hidden" : undefined
|
|
}
|
|
action={
|
|
<div className="flex items-center gap-2">
|
|
<Button
|
|
type="button"
|
|
size="icon-sm"
|
|
variant="outline"
|
|
className="rounded-lg border-border/70"
|
|
disabled={refreshing || testingProviderId !== null || enabledCredentials.length === 0}
|
|
onClick={() => void refreshProviders()}
|
|
title={t("ai.refreshProviders")}
|
|
aria-label={t("ai.refreshProviders")}
|
|
>
|
|
<MdRefresh className={refreshing ? "animate-spin" : ""} />
|
|
</Button>
|
|
<Button
|
|
type="button"
|
|
variant="outline"
|
|
size="sm"
|
|
className="rounded-lg border-border/70"
|
|
aria-expanded={showProviderChoices}
|
|
onClick={() => {
|
|
setShowProviderChoices(true);
|
|
stageCustomProvider();
|
|
}}
|
|
>
|
|
<MdAdd />
|
|
{t("ai.addProvider")}
|
|
</Button>
|
|
</div>
|
|
}
|
|
>
|
|
<div className="space-y-3">
|
|
<div className="flex min-w-0 flex-wrap items-center gap-2 px-0.5 py-1">
|
|
{enabledCredentials.map((credential) => (
|
|
<Button
|
|
key={credential.id}
|
|
type="button"
|
|
size="default"
|
|
variant="outline"
|
|
className={`h-10 min-w-0 max-w-full rounded-lg px-3 text-sm font-normal ${
|
|
selectedProviderCredential?.id === credential.id
|
|
? "border-primary bg-primary/5 ring-1 ring-primary/20"
|
|
: "border-border/70"
|
|
}`}
|
|
aria-pressed={selectedProviderCredential?.id === credential.id}
|
|
title={credential.name.trim() || getProviderLabel(credential.provider_kind)}
|
|
onClick={() => {
|
|
cancelProviderConnectionTest();
|
|
setProviderDraft(null);
|
|
setSelectedProviderCredentialId(credential.id);
|
|
setShowProviderChoices(false);
|
|
hideProviderApiKey();
|
|
setProviderModelCount(null);
|
|
setProviderConnectionStatus("idle");
|
|
}}
|
|
>
|
|
<ProviderBadge
|
|
kind={credential.provider_kind}
|
|
iconDataUrl={credential.icon_data_url}
|
|
/>
|
|
<span className="min-w-0 max-w-40 truncate">
|
|
{credential.name.trim() || getProviderLabel(credential.provider_kind)}
|
|
</span>
|
|
<span
|
|
role="img"
|
|
className={`size-2.5 shrink-0 rounded-full ${
|
|
providerStatuses[credential.id] === "testing"
|
|
? "animate-pulse bg-primary"
|
|
: providerStatuses[credential.id] === "success"
|
|
? "bg-emerald-500"
|
|
: providerStatuses[credential.id] === "error"
|
|
? "bg-destructive"
|
|
: "bg-muted-foreground/30"
|
|
}`}
|
|
title={t(`ai.connectionStatus.${providerStatuses[credential.id] ?? "idle"}`)}
|
|
aria-label={t(`ai.connectionStatus.${providerStatuses[credential.id] ?? "idle"}`)}
|
|
/>
|
|
</Button>
|
|
))}
|
|
</div>
|
|
{showProviderChoices ? (
|
|
<div className="flex flex-wrap items-center gap-2 border-t border-border/60 pt-3">
|
|
{AI_PROVIDERS.filter((provider) => BUILTIN_PROVIDERS[provider.value]).map(
|
|
(provider) => (
|
|
<Button
|
|
key={provider.value}
|
|
type="button"
|
|
size="default"
|
|
variant="outline"
|
|
className={`h-10 rounded-lg border-border/70 px-3 text-sm font-normal ${
|
|
providerDraft?.credential.provider_kind === provider.value
|
|
? "border-primary bg-primary/5 ring-1 ring-primary/20"
|
|
: ""
|
|
}`}
|
|
aria-pressed={providerDraft?.credential.provider_kind === provider.value}
|
|
onClick={() => selectProviderPreset(provider.value)}
|
|
>
|
|
<ProviderBadge kind={provider.value} />
|
|
{provider.label}
|
|
</Button>
|
|
),
|
|
)}
|
|
<Button
|
|
type="button"
|
|
size="default"
|
|
variant="outline"
|
|
className={`h-10 rounded-lg border-border/70 px-3 text-sm font-normal ${
|
|
providerDraft?.credential.provider_kind === "openai_compatible"
|
|
? "border-primary bg-primary/5 ring-1 ring-primary/20"
|
|
: ""
|
|
}`}
|
|
aria-pressed={providerDraft?.credential.provider_kind === "openai_compatible"}
|
|
onClick={addCustomProvider}
|
|
>
|
|
<MdAdd />
|
|
{t("ai.customProviderOption")}
|
|
</Button>
|
|
</div>
|
|
) : null}
|
|
</div>
|
|
</SettingSection>
|
|
|
|
{enabledCredentials.length > 0 ? (
|
|
<SettingSection
|
|
title={
|
|
selectedProviderCredential ? (
|
|
<span className="flex min-w-0 items-center gap-2">
|
|
{selectedProviderCredential.provider_kind === "openai_compatible" ? (
|
|
<button
|
|
type="button"
|
|
className="group relative size-6 shrink-0 cursor-pointer rounded-full focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-primary"
|
|
onClick={() => void changeCustomProviderIcon()}
|
|
title={t("ai.changeProviderIcon")}
|
|
aria-label={t("ai.changeProviderIcon")}
|
|
>
|
|
<ProviderBadge
|
|
kind={selectedProviderCredential.provider_kind}
|
|
iconDataUrl={selectedProviderCredential.icon_data_url}
|
|
/>
|
|
<span className="absolute inset-0 grid place-items-center rounded-full bg-background/80 opacity-0 transition-opacity group-hover:opacity-100 group-focus-visible:opacity-100">
|
|
<MdEdit className="size-3.5" />
|
|
</span>
|
|
</button>
|
|
) : (
|
|
<ProviderBadge
|
|
kind={selectedProviderCredential.provider_kind}
|
|
iconDataUrl={selectedProviderCredential.icon_data_url}
|
|
/>
|
|
)}
|
|
<span className="truncate">{selectedProviderTitle}</span>
|
|
</span>
|
|
) : undefined
|
|
}
|
|
className="min-h-24"
|
|
headerRowClassName="sm:items-center"
|
|
contentClassName="space-y-0 px-4 py-2 sm:px-5"
|
|
action={
|
|
selectedProviderCredential ? (
|
|
<div className="flex items-center gap-2">
|
|
<AlertDialog>
|
|
<AlertDialogTrigger asChild>
|
|
<Button
|
|
type="button"
|
|
size="icon-sm"
|
|
variant="outline"
|
|
title={t("ai.deleteProvider")}
|
|
aria-label={t("ai.deleteProvider")}
|
|
className="text-destructive hover:text-destructive"
|
|
>
|
|
<MdDelete />
|
|
</Button>
|
|
</AlertDialogTrigger>
|
|
<AlertDialogContent size="sm">
|
|
<AlertDialogHeader>
|
|
<AlertDialogTitle>{t("ai.deleteProvider")}</AlertDialogTitle>
|
|
<AlertDialogDescription>
|
|
{t(
|
|
providerDraft ? "ai.discardProviderConfirm" : "ai.deleteProviderConfirm",
|
|
{ name: selectedProviderTitle },
|
|
)}
|
|
</AlertDialogDescription>
|
|
</AlertDialogHeader>
|
|
<AlertDialogFooter>
|
|
<AlertDialogCancel>{t("common.cancel")}</AlertDialogCancel>
|
|
<AlertDialogAction variant="destructive" onClick={deleteSelectedProvider}>
|
|
{t("common.delete")}
|
|
</AlertDialogAction>
|
|
</AlertDialogFooter>
|
|
</AlertDialogContent>
|
|
</AlertDialog>
|
|
</div>
|
|
) : undefined
|
|
}
|
|
>
|
|
{selectedProviderCredential ? (
|
|
<div>
|
|
<div className="grid gap-2 border-b border-border/60 py-2.5 first:pt-2 sm:grid-cols-[minmax(0,1fr)_minmax(0,2.2fr)] sm:items-center">
|
|
<Label className="text-xs font-normal">{t("ai.profileName")}</Label>
|
|
<div className="space-y-1">
|
|
<Input
|
|
value={selectedProviderName}
|
|
placeholder={selectedProviderDefaultName}
|
|
className="h-9 rounded-lg text-sm"
|
|
aria-invalid={selectedProviderNameTaken}
|
|
onChange={(event) => changeSelectedProviderName(event.target.value)}
|
|
onBlur={finishSelectedProviderName}
|
|
/>
|
|
{selectedProviderNameTaken ? (
|
|
<p className="text-xs text-destructive">{t("ai.providerNameDuplicate")}</p>
|
|
) : null}
|
|
</div>
|
|
</div>
|
|
<div className="grid gap-2 border-b border-border/60 py-2.5 sm:grid-cols-[minmax(0,1fr)_minmax(0,2.2fr)] sm:items-center">
|
|
<Label className="text-xs font-normal">
|
|
{t("ai.providerEndpoint")} <span aria-hidden="true">*</span>
|
|
</Label>
|
|
<div className="space-y-1">
|
|
<Input
|
|
value={selectedProviderCredential.base_url ?? ""}
|
|
placeholder={getCustomProviderBaseUrlPlaceholder(
|
|
selectedProviderCredential.provider_kind,
|
|
)}
|
|
className="h-9 rounded-lg text-sm"
|
|
required
|
|
aria-invalid={showProviderValidation && selectedProviderEndpointMissing}
|
|
onBlur={() => setShowProviderValidation(true)}
|
|
onChange={(event) =>
|
|
updateSelectedProviderCredential(selectedProviderCredential.id, {
|
|
base_url: event.target.value,
|
|
})
|
|
}
|
|
/>
|
|
{showProviderValidation && selectedProviderEndpointMissing ? (
|
|
<p className="text-xs text-destructive">{t("ai.providerEndpointRequired")}</p>
|
|
) : null}
|
|
</div>
|
|
</div>
|
|
<div className="grid gap-2 border-b border-border/60 py-2.5 sm:grid-cols-[minmax(0,1fr)_minmax(0,2.2fr)] sm:items-center">
|
|
<Label className="text-xs font-normal">{t("ai.providerProtocol")}</Label>
|
|
<Select
|
|
value={providerProtocolSelectValue(selectedProviderCredential)}
|
|
onValueChange={(value) =>
|
|
updateSelectedProviderProtocol(selectedProviderCredential, value)
|
|
}
|
|
>
|
|
<SelectTrigger className="h-9 w-full rounded-lg text-left text-sm">
|
|
<SelectValue />
|
|
</SelectTrigger>
|
|
<SelectContent>{protocolOptions(selectedProviderCredential)}</SelectContent>
|
|
</Select>
|
|
</div>
|
|
<div className="grid gap-2 border-b border-border/60 py-2.5 sm:grid-cols-[minmax(0,1fr)_minmax(0,2.2fr)] sm:items-center">
|
|
<Label className="text-xs font-normal">
|
|
{t("settings.apiKey")}
|
|
{selectedProviderApiKeyRequired ? (
|
|
<span aria-hidden="true"> *</span>
|
|
) : null}
|
|
</Label>
|
|
<div className="space-y-1">
|
|
<div className="relative">
|
|
<Input
|
|
data-custom-password-reveal
|
|
type={showProviderApiKey ? "text" : "password"}
|
|
value={
|
|
showProviderApiKey &&
|
|
revealedProviderApiKey?.credentialId === selectedProviderCredential.id &&
|
|
isCloudSecretMasked(selectedProviderCredential.api_key)
|
|
? revealedProviderApiKey.value
|
|
: secretInputValue(selectedProviderCredential.api_key)
|
|
}
|
|
placeholder={secretPlaceholder(
|
|
selectedProviderCredential.api_key,
|
|
t("settings.apiKey"),
|
|
)}
|
|
className="h-9 rounded-lg pr-10 text-sm"
|
|
required={selectedProviderApiKeyRequired}
|
|
aria-invalid={showProviderValidation && selectedProviderApiKeyMissing}
|
|
onBlur={() => setShowProviderValidation(true)}
|
|
onChange={(event) => {
|
|
setRevealedProviderApiKey(null);
|
|
updateSelectedProviderCredential(selectedProviderCredential.id, {
|
|
api_key: event.target.value,
|
|
});
|
|
}}
|
|
/>
|
|
<Button
|
|
type="button"
|
|
size="icon-sm"
|
|
variant="ghost"
|
|
className="absolute right-1.5 top-1/2 -translate-y-1/2 text-muted-foreground"
|
|
title={t(showProviderApiKey ? "ai.hideApiKey" : "ai.showApiKey")}
|
|
aria-label={t(showProviderApiKey ? "ai.hideApiKey" : "ai.showApiKey")}
|
|
disabled={revealingProviderApiKey}
|
|
onClick={() => void toggleProviderApiKeyVisibility()}
|
|
>
|
|
{showProviderApiKey ? (
|
|
<MdVisibilityOff className="size-3.5" />
|
|
) : (
|
|
<MdVisibility className="size-3.5" />
|
|
)}
|
|
</Button>
|
|
</div>
|
|
{showProviderValidation && selectedProviderApiKeyMissing ? (
|
|
<p className="text-xs text-destructive">{t("ai.providerApiKeyRequired")}</p>
|
|
) : null}
|
|
</div>
|
|
</div>
|
|
<div className="grid gap-2 py-2.5 sm:grid-cols-[minmax(0,1fr)_minmax(0,2.2fr)] sm:items-center">
|
|
<Label className="text-xs font-normal">{t("ai.connectionTest")}</Label>
|
|
<div className="flex items-center justify-end gap-2">
|
|
{providerModelCount !== null ? (
|
|
<span className="text-xs text-muted-foreground">
|
|
{t("ai.connectionModelCount", { count: providerModelCount })}
|
|
</span>
|
|
) : null}
|
|
<span
|
|
role="img"
|
|
className={`size-2.5 rounded-full ${
|
|
selectedProviderConnectionStatus === "testing"
|
|
? "animate-pulse bg-primary"
|
|
: selectedProviderConnectionStatus === "success"
|
|
? "bg-emerald-500"
|
|
: selectedProviderConnectionStatus === "error"
|
|
? "bg-destructive"
|
|
: "bg-muted-foreground/30"
|
|
}`}
|
|
title={t(`ai.connectionStatus.${selectedProviderConnectionStatus}`)}
|
|
aria-label={t(`ai.connectionStatus.${selectedProviderConnectionStatus}`)}
|
|
/>
|
|
<Button
|
|
type="button"
|
|
className="h-9 rounded-lg px-3 text-sm"
|
|
disabled={
|
|
selectedProviderEndpointMissing ||
|
|
selectedProviderApiKeyMissing ||
|
|
refreshing ||
|
|
testingProviderId === selectedProviderCredential.id
|
|
}
|
|
onClick={() => void testProviderConnection()}
|
|
>
|
|
{t("ai.connectionTest")}
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
) : null}
|
|
</SettingSection>
|
|
) : null}
|
|
|
|
{enabledCredentials.length > 0 ? renderModelListCard() : null}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function ActionListEditor({
|
|
title,
|
|
actions,
|
|
onChange,
|
|
}: {
|
|
title: string;
|
|
actions: AICustomActionConfig[];
|
|
onChange: (actions: AICustomActionConfig[]) => void;
|
|
}) {
|
|
const { t } = useTranslation();
|
|
|
|
const updateAction = (id: string, patch: Partial<AICustomActionConfig>) => {
|
|
onChange(
|
|
actions.map((action) =>
|
|
action.id === id ? { ...action, ...patch } : action,
|
|
),
|
|
);
|
|
};
|
|
|
|
return (
|
|
<SettingSection
|
|
title={title}
|
|
action={
|
|
<Button
|
|
size="sm"
|
|
variant="outline"
|
|
onClick={() => onChange([...actions, newAction("ai-action")])}
|
|
>
|
|
<MdAdd />
|
|
{t("common.add")}
|
|
</Button>
|
|
}
|
|
contentClassName="space-y-4"
|
|
>
|
|
{actions.map((action) => (
|
|
<div key={action.id} className="rounded-md border border-border/70 bg-background/75 p-4">
|
|
<div className="mb-3 flex items-center justify-between gap-3">
|
|
<div className="min-w-0 truncate text-sm font-medium">{action.name}</div>
|
|
<div className="flex items-center gap-2">
|
|
<SettingSwitch
|
|
checked={action.enabled}
|
|
onChange={(enabled) => updateAction(action.id, { enabled })}
|
|
/>
|
|
<DeleteIconButton
|
|
title={t("common.delete")}
|
|
onDelete={() => onChange(actions.filter((item) => item.id !== action.id))}
|
|
/>
|
|
</div>
|
|
</div>
|
|
<div className="space-y-3">
|
|
<Input
|
|
value={action.name}
|
|
className="text-sm"
|
|
placeholder={t("ai.actionName")}
|
|
onChange={(event) => updateAction(action.id, { name: event.target.value })}
|
|
/>
|
|
<Textarea
|
|
value={action.prompt}
|
|
className="min-h-20 resize-y text-sm"
|
|
placeholder={t("ai.actionPrompt")}
|
|
onChange={(event) => updateAction(action.id, { prompt: event.target.value })}
|
|
/>
|
|
</div>
|
|
</div>
|
|
))}
|
|
</SettingSection>
|
|
);
|
|
}
|
|
|
|
export function AiRulesTab() {
|
|
const { t } = useTranslation();
|
|
const { appSettings, updateAppSettings } = useApp();
|
|
const ai = appSettings.ai;
|
|
const update = (patch: Partial<AISettings>) => updateAppSettings({ ai: { ...ai, ...patch } });
|
|
const MB = 1024 * 1024;
|
|
|
|
return (
|
|
<div className="space-y-5">
|
|
<SettingSection title={t("ai.rules")}>
|
|
<SettingNumberInput
|
|
label={`${t("ai.maxAiFileSize")} (MB)`}
|
|
desc={t("ai.maxAiFileSizeDesc")}
|
|
min={1}
|
|
max={256}
|
|
step={1}
|
|
value={Math.max(1, Math.round(ai.max_ai_file_size_bytes / MB))}
|
|
onChange={(value) => update({ max_ai_file_size_bytes: value * MB })}
|
|
/>
|
|
</SettingSection>
|
|
<ActionListEditor
|
|
title={t("ai.terminalActions")}
|
|
actions={ai.terminal_ai_actions}
|
|
onChange={(terminal_ai_actions) => update({ terminal_ai_actions })}
|
|
/>
|
|
<ActionListEditor
|
|
title={t("ai.fileActions")}
|
|
actions={ai.file_ai_actions}
|
|
onChange={(file_ai_actions) => update({ file_ai_actions })}
|
|
/>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
export function AiTab() {
|
|
return <AiGeneralTab />;
|
|
}
|