[Init] Initial commit - NetMesh terminal manager
Some checks failed
build-packages / resolve bundled mosh-client (push) Has been cancelled
build-packages / resolve bundled et-client (push) Has been cancelled
build-packages / build-macos (push) Has been cancelled
build-packages / build-windows (push) Has been cancelled
build-packages / build-linux-x64 (push) Has been cancelled
build-packages / build-linux-arm64 (push) Has been cancelled
build-packages / release (push) Has been cancelled
build-packages / update Nix release metadata (push) Has been cancelled
build-packages / bump homebrew tap (push) Has been cancelled
test / lint-and-test (push) Has been cancelled
AI automation / Route event (push) Has been cancelled
AI automation / Hand reopened issue to maintainers (push) Has been cancelled
AI automation / Clean source issue state (push) Has been cancelled
AI automation / Reconcile handoffs (push) Has been cancelled
AI automation / Classify issue (push) Has been cancelled
AI automation / Claude Code smoke (push) Has been cancelled
AI automation / Review issue follow-up (push) Has been cancelled
AI automation / Publish issue follow-up (push) Has been cancelled
AI automation / Implement with Claude Code (push) Has been cancelled
AI automation / Publish implement PR (push) Has been cancelled
AI automation / Continue queued issue comments (push) Has been cancelled
AI automation / Codex review loop (push) Has been cancelled
AI automation / Publish Codex fix (push) Has been cancelled
AI automation / Clear Codex dispatch marker (push) Has been cancelled
AI automation / Own PR re-request Codex (push) Has been cancelled
AI automation / External PR re-request Codex (push) Has been cancelled
AI automation / Poll Codex reaction / retry (push) Has been cancelled
build-et-binaries / build-linux-x64 (push) Has been cancelled
build-et-binaries / build-linux-arm64 (push) Has been cancelled
build-et-binaries / build-macos-universal (push) Has been cancelled
build-et-binaries / build-windows-x64 (push) Has been cancelled
build-et-binaries / release (push) Has been cancelled
Some checks failed
build-packages / resolve bundled mosh-client (push) Has been cancelled
build-packages / resolve bundled et-client (push) Has been cancelled
build-packages / build-macos (push) Has been cancelled
build-packages / build-windows (push) Has been cancelled
build-packages / build-linux-x64 (push) Has been cancelled
build-packages / build-linux-arm64 (push) Has been cancelled
build-packages / release (push) Has been cancelled
build-packages / update Nix release metadata (push) Has been cancelled
build-packages / bump homebrew tap (push) Has been cancelled
test / lint-and-test (push) Has been cancelled
AI automation / Route event (push) Has been cancelled
AI automation / Hand reopened issue to maintainers (push) Has been cancelled
AI automation / Clean source issue state (push) Has been cancelled
AI automation / Reconcile handoffs (push) Has been cancelled
AI automation / Classify issue (push) Has been cancelled
AI automation / Claude Code smoke (push) Has been cancelled
AI automation / Review issue follow-up (push) Has been cancelled
AI automation / Publish issue follow-up (push) Has been cancelled
AI automation / Implement with Claude Code (push) Has been cancelled
AI automation / Publish implement PR (push) Has been cancelled
AI automation / Continue queued issue comments (push) Has been cancelled
AI automation / Codex review loop (push) Has been cancelled
AI automation / Publish Codex fix (push) Has been cancelled
AI automation / Clear Codex dispatch marker (push) Has been cancelled
AI automation / Own PR re-request Codex (push) Has been cancelled
AI automation / External PR re-request Codex (push) Has been cancelled
AI automation / Poll Codex reaction / retry (push) Has been cancelled
build-et-binaries / build-linux-x64 (push) Has been cancelled
build-et-binaries / build-linux-arm64 (push) Has been cancelled
build-et-binaries / build-macos-universal (push) Has been cancelled
build-et-binaries / build-windows-x64 (push) Has been cancelled
build-et-binaries / release (push) Has been cancelled
This commit is contained in:
58
components/settings/tabs/ai/AddProviderDropdown.tsx
Normal file
58
components/settings/tabs/ai/AddProviderDropdown.tsx
Normal file
@@ -0,0 +1,58 @@
|
||||
import React, { useState } from "react";
|
||||
import { ChevronDown, Plus } from "lucide-react";
|
||||
import type { AIProviderId } from "../../../../infrastructure/ai/types";
|
||||
import { PROVIDER_PRESETS } from "../../../../infrastructure/ai/types";
|
||||
import { useI18n } from "../../../../application/i18n/I18nProvider";
|
||||
import { Button } from "../../../ui/button";
|
||||
import { cn } from "../../../../lib/utils";
|
||||
import { ProviderIconBadge } from "./ProviderIconBadge";
|
||||
|
||||
export const ADD_PROVIDER_MENU_CLASS =
|
||||
"absolute top-full right-0 mt-1 z-[101] min-w-[220px] max-w-[calc(100vw-2rem)] rounded-md border border-border bg-popover shadow-md py-1";
|
||||
|
||||
export const AddProviderDropdown: React.FC<{
|
||||
onAdd: (providerId: AIProviderId) => void;
|
||||
}> = ({ onAdd }) => {
|
||||
const { t } = useI18n();
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
|
||||
const providerIds = Object.keys(PROVIDER_PRESETS) as AIProviderId[];
|
||||
|
||||
return (
|
||||
<div className="relative">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setIsOpen(!isOpen)}
|
||||
className="gap-1.5"
|
||||
>
|
||||
<Plus size={14} />
|
||||
{t('ai.providers.add')}
|
||||
<ChevronDown size={12} className={cn("transition-transform", isOpen && "rotate-180")} />
|
||||
</Button>
|
||||
|
||||
{isOpen && (
|
||||
<>
|
||||
{/* Backdrop */}
|
||||
<div className="fixed inset-0 z-[100]" onClick={() => setIsOpen(false)} />
|
||||
{/* Menu */}
|
||||
<div className={ADD_PROVIDER_MENU_CLASS}>
|
||||
{providerIds.map((pid) => (
|
||||
<button
|
||||
key={pid}
|
||||
onClick={() => {
|
||||
onAdd(pid);
|
||||
setIsOpen(false);
|
||||
}}
|
||||
className="w-full flex items-center gap-2.5 px-3 py-2 text-sm hover:bg-accent hover:text-accent-foreground transition-colors text-left"
|
||||
>
|
||||
<ProviderIconBadge providerId={pid} size="sm" />
|
||||
{PROVIDER_PRESETS[pid].name}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
179
components/settings/tabs/ai/ClaudeCodeCard.tsx
Normal file
179
components/settings/tabs/ai/ClaudeCodeCard.tsx
Normal file
@@ -0,0 +1,179 @@
|
||||
import React, { useEffect, useState } from "react";
|
||||
import { ChevronDown, RefreshCw, RotateCcw } from "lucide-react";
|
||||
import { useI18n } from "../../../../application/i18n/I18nProvider";
|
||||
import { Button } from "../../../ui/button";
|
||||
import { cn } from "../../../../lib/utils";
|
||||
import type { AgentPathInfo } from "./types";
|
||||
import { parseEnvLines, serializeEnvLines } from "./claudeConfigEnv";
|
||||
|
||||
export const ClaudeCodeCard: React.FC<{
|
||||
pathInfo: AgentPathInfo | null;
|
||||
isResolvingPath: boolean;
|
||||
customPath: string;
|
||||
onCustomPathChange: (path: string) => void;
|
||||
onRecheckPath: () => void;
|
||||
onResetPath: () => void;
|
||||
configDir: string;
|
||||
onConfigDirChange: (value: string) => void;
|
||||
settingsPath: string;
|
||||
onSettingsPathChange: (value: string) => void;
|
||||
envText: string;
|
||||
onEnvTextChange: (value: string) => void;
|
||||
}> = ({
|
||||
pathInfo,
|
||||
isResolvingPath,
|
||||
customPath,
|
||||
onCustomPathChange,
|
||||
onRecheckPath,
|
||||
onResetPath,
|
||||
configDir,
|
||||
onConfigDirChange,
|
||||
settingsPath,
|
||||
onSettingsPathChange,
|
||||
envText,
|
||||
onEnvTextChange,
|
||||
}) => {
|
||||
const { t } = useI18n();
|
||||
const found = pathInfo?.available;
|
||||
// Collapsed by default; auto-expand when the user already has config so it
|
||||
// isn't hidden. Local UI state — not persisted.
|
||||
const [configOpen, setConfigOpen] = useState(
|
||||
() => Boolean(configDir.trim() || settingsPath.trim() || envText.trim()),
|
||||
);
|
||||
|
||||
// The env editor keeps the raw text the user types. Persisting parses it into
|
||||
// a record (dropping incomplete lines), so binding the textarea directly to
|
||||
// the persisted value would erase a key the moment it's typed before its "=".
|
||||
// Only resync from the persisted value when it changes for some reason other
|
||||
// than our own parse→serialize round-trip.
|
||||
const [envDraft, setEnvDraft] = useState(envText);
|
||||
useEffect(() => {
|
||||
setEnvDraft((prev) =>
|
||||
serializeEnvLines(parseEnvLines(prev)) === envText ? prev : envText,
|
||||
);
|
||||
}, [envText]);
|
||||
|
||||
const statusText = isResolvingPath
|
||||
? t('ai.claude.detecting')
|
||||
: found
|
||||
? t('ai.claude.detected')
|
||||
: t('ai.claude.notFound');
|
||||
|
||||
const statusClassName = isResolvingPath
|
||||
? "text-muted-foreground"
|
||||
: found
|
||||
? "text-emerald-500"
|
||||
: "text-amber-500";
|
||||
|
||||
return (
|
||||
<div className="rounded-lg border bg-card p-4 space-y-3">
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<p className="min-w-0 text-xs text-muted-foreground leading-5">
|
||||
{t('ai.claude.description')}
|
||||
</p>
|
||||
<div className={cn("text-xs font-medium shrink-0", statusClassName)}>
|
||||
{statusText}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{found && (
|
||||
<div className="flex items-center gap-2 text-xs">
|
||||
<span className="text-muted-foreground">{t('ai.claude.path')}</span>
|
||||
<span className="font-mono text-foreground truncate">{pathInfo.path}</span>
|
||||
{pathInfo.version && (
|
||||
<>
|
||||
<span className="text-muted-foreground">|</span>
|
||||
<span className="text-muted-foreground">{pathInfo.version}</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!isResolvingPath && (
|
||||
<div className="space-y-2">
|
||||
{!found && (
|
||||
<p className="text-xs text-amber-500">
|
||||
{t('ai.claude.notFoundHint')}
|
||||
</p>
|
||||
)}
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
type="text"
|
||||
value={customPath}
|
||||
onChange={(e) => onCustomPathChange(e.target.value)}
|
||||
placeholder={t('ai.claude.customPathPlaceholder')}
|
||||
className="flex-1 h-8 rounded-md border border-input bg-background px-3 text-sm font-mono placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
|
||||
/>
|
||||
<Button variant="outline" size="sm" onClick={onRecheckPath} disabled={!customPath.trim()}>
|
||||
<RefreshCw size={14} className="mr-1.5" />
|
||||
{t('ai.claude.check')}
|
||||
</Button>
|
||||
<Button variant="ghost" size="sm" onClick={onResetPath} disabled={!customPath.trim()}>
|
||||
<RotateCcw size={14} className="mr-1.5" />
|
||||
{t('ai.claude.resetPath')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Authentication & config (optional, collapsible) */}
|
||||
<div className="border-t border-border/60 pt-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setConfigOpen((v) => !v)}
|
||||
aria-expanded={configOpen}
|
||||
className="flex w-full items-center justify-between gap-2 text-left"
|
||||
>
|
||||
<span className="text-xs font-medium text-muted-foreground">
|
||||
{t('ai.claude.configSection')}
|
||||
</span>
|
||||
<ChevronDown
|
||||
size={14}
|
||||
className={cn("text-muted-foreground transition-transform", configOpen && "rotate-180")}
|
||||
/>
|
||||
</button>
|
||||
{configOpen && (
|
||||
<div className="space-y-3 mt-3">
|
||||
<div className="space-y-1.5">
|
||||
<label htmlFor="claude-config-dir" className="text-xs text-muted-foreground">{t('ai.claude.configDir')}</label>
|
||||
<input
|
||||
id="claude-config-dir"
|
||||
type="text"
|
||||
value={configDir}
|
||||
onChange={(e) => onConfigDirChange(e.target.value)}
|
||||
placeholder={t('ai.claude.configDir.placeholder')}
|
||||
className="w-full h-8 rounded-md border border-input bg-background px-3 text-sm font-mono placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
|
||||
/>
|
||||
<p className="text-[11px] text-muted-foreground leading-4">{t('ai.claude.configDir.hint')}</p>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<label htmlFor="claude-settings" className="text-xs text-muted-foreground">{t('ai.claude.settings')}</label>
|
||||
<input
|
||||
id="claude-settings"
|
||||
type="text"
|
||||
value={settingsPath}
|
||||
onChange={(e) => onSettingsPathChange(e.target.value)}
|
||||
placeholder={t('ai.claude.settings.placeholder')}
|
||||
className="w-full h-8 rounded-md border border-input bg-background px-3 text-sm font-mono placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
|
||||
/>
|
||||
<p className="text-[11px] text-muted-foreground leading-4">{t('ai.claude.settings.hint')}</p>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<label htmlFor="claude-env-vars" className="text-xs text-muted-foreground">{t('ai.claude.envVars')}</label>
|
||||
<textarea
|
||||
id="claude-env-vars"
|
||||
value={envDraft}
|
||||
onChange={(e) => { setEnvDraft(e.target.value); onEnvTextChange(e.target.value); }}
|
||||
placeholder={t('ai.claude.envVars.placeholder')}
|
||||
rows={3}
|
||||
spellCheck={false}
|
||||
className="w-full rounded-md border border-input bg-background px-3 py-2 text-sm font-mono placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring resize-y"
|
||||
/>
|
||||
<p className="text-[11px] text-muted-foreground leading-4">{t('ai.claude.envVars.hint')}</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
310
components/settings/tabs/ai/CodebuddyCard.tsx
Normal file
310
components/settings/tabs/ai/CodebuddyCard.tsx
Normal file
@@ -0,0 +1,310 @@
|
||||
import React, { useEffect, useState } from "react";
|
||||
import { ChevronDown, RefreshCw, RotateCcw } from "lucide-react";
|
||||
import { useI18n } from "../../../../application/i18n/I18nProvider";
|
||||
import { Button } from "../../../ui/button";
|
||||
import { cn } from "../../../../lib/utils";
|
||||
import type { AgentPathInfo } from "./types";
|
||||
import type { CodebuddyAdvancedOptions } from "../../../../infrastructure/ai/types";
|
||||
import { parseEnvLines, serializeEnvLines } from "./codebuddyConfigEnv";
|
||||
|
||||
const INTERNET_ENV_OPTIONS = [
|
||||
{ value: "", labelKey: "ai.codebuddy.internetEnv.default" },
|
||||
{ value: "internal", labelKey: "ai.codebuddy.internetEnv.internal" },
|
||||
{ value: "ioa", labelKey: "ai.codebuddy.internetEnv.ioa" },
|
||||
] as const;
|
||||
|
||||
const EFFORT_OPTIONS = [
|
||||
{ value: "", labelKey: "ai.codebuddy.effort.default" },
|
||||
{ value: "low", labelKey: "ai.codebuddy.effort.low" },
|
||||
{ value: "medium", labelKey: "ai.codebuddy.effort.medium" },
|
||||
{ value: "high", labelKey: "ai.codebuddy.effort.high" },
|
||||
{ value: "xhigh", labelKey: "ai.codebuddy.effort.xhigh" },
|
||||
] as const;
|
||||
|
||||
export const CodebuddyCard: React.FC<{
|
||||
pathInfo: AgentPathInfo | null;
|
||||
isResolvingPath: boolean;
|
||||
customPath: string;
|
||||
onCustomPathChange: (path: string) => void;
|
||||
onRecheckPath: () => void;
|
||||
onResetPath: () => void;
|
||||
internetEnv: string;
|
||||
onInternetEnvChange: (value: string) => void;
|
||||
envText: string;
|
||||
onEnvTextChange: (value: string) => void;
|
||||
advancedOptions?: CodebuddyAdvancedOptions;
|
||||
onAdvancedOptionsChange?: (options: CodebuddyAdvancedOptions | undefined) => void;
|
||||
}> = ({
|
||||
pathInfo,
|
||||
isResolvingPath,
|
||||
customPath,
|
||||
onCustomPathChange,
|
||||
onRecheckPath,
|
||||
onResetPath,
|
||||
internetEnv,
|
||||
onInternetEnvChange,
|
||||
envText,
|
||||
onEnvTextChange,
|
||||
advancedOptions,
|
||||
onAdvancedOptionsChange,
|
||||
}) => {
|
||||
const { t } = useI18n();
|
||||
const found = pathInfo?.available;
|
||||
// Collapsed by default; auto-expand when the user already has config so it
|
||||
// isn't hidden. Local UI state — not persisted.
|
||||
const [configOpen, setConfigOpen] = useState(
|
||||
() => Boolean(internetEnv.trim() || envText.trim()),
|
||||
);
|
||||
const [advancedOpen, setAdvancedOpen] = useState(
|
||||
() => Boolean(advancedOptions && Object.keys(advancedOptions).length > 0),
|
||||
);
|
||||
|
||||
const updateAdvanced = (patch: Partial<CodebuddyAdvancedOptions>) => {
|
||||
if (!onAdvancedOptionsChange) return;
|
||||
const next = { ...(advancedOptions || {}), ...patch };
|
||||
// Remove undefined/empty values to keep storage clean.
|
||||
const cleaned = Object.fromEntries(
|
||||
Object.entries(next).filter(([, v]) => v != null && v !== "" && v !== 0),
|
||||
) as CodebuddyAdvancedOptions;
|
||||
onAdvancedOptionsChange(Object.keys(cleaned).length > 0 ? cleaned : undefined);
|
||||
};
|
||||
|
||||
// The env editor keeps the raw text the user types. Persisting parses it into
|
||||
// a record (dropping incomplete lines), so binding the textarea directly to
|
||||
// the persisted value would erase a key the moment it's typed before its "=".
|
||||
// Only resync from the persisted value when it changes for some reason other
|
||||
// than our own parse→serialize round-trip.
|
||||
const [envDraft, setEnvDraft] = useState(envText);
|
||||
useEffect(() => {
|
||||
setEnvDraft((prev) =>
|
||||
serializeEnvLines(parseEnvLines(prev)) === envText ? prev : envText,
|
||||
);
|
||||
}, [envText]);
|
||||
|
||||
const statusText = isResolvingPath
|
||||
? t('ai.codebuddy.detecting')
|
||||
: found
|
||||
? t('ai.codebuddy.detected')
|
||||
: t('ai.codebuddy.notFound');
|
||||
|
||||
const statusClassName = isResolvingPath
|
||||
? "text-muted-foreground"
|
||||
: found
|
||||
? "text-emerald-500"
|
||||
: "text-amber-500";
|
||||
|
||||
return (
|
||||
<div className="rounded-lg border bg-card p-4 space-y-3">
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<p className="min-w-0 text-xs text-muted-foreground leading-5">
|
||||
{t('ai.codebuddy.description')}
|
||||
</p>
|
||||
<div className={cn("text-xs font-medium shrink-0", statusClassName)}>
|
||||
{statusText}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{found && (
|
||||
<div className="flex items-center gap-2 text-xs">
|
||||
<span className="text-muted-foreground">{t('ai.codebuddy.path')}</span>
|
||||
<span className="font-mono text-foreground truncate">{pathInfo.path}</span>
|
||||
{pathInfo.version && (
|
||||
<>
|
||||
<span className="text-muted-foreground">|</span>
|
||||
<span className="text-muted-foreground">{pathInfo.version}</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!isResolvingPath && (
|
||||
<div className="space-y-2">
|
||||
{!found && (
|
||||
<p className="text-xs text-amber-500">
|
||||
{t('ai.codebuddy.notFoundHint')}
|
||||
</p>
|
||||
)}
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
type="text"
|
||||
value={customPath}
|
||||
onChange={(e) => onCustomPathChange(e.target.value)}
|
||||
placeholder={t('ai.codebuddy.customPathPlaceholder')}
|
||||
className="flex-1 h-8 rounded-md border border-input bg-background px-3 text-sm font-mono placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
|
||||
/>
|
||||
<Button variant="outline" size="sm" onClick={onRecheckPath} disabled={!customPath.trim()}>
|
||||
<RefreshCw size={14} className="mr-1.5" />
|
||||
{t('ai.codebuddy.check')}
|
||||
</Button>
|
||||
<Button variant="ghost" size="sm" onClick={onResetPath} disabled={!customPath.trim()}>
|
||||
<RotateCcw size={14} className="mr-1.5" />
|
||||
{t('ai.codebuddy.resetPath')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Authentication & config (optional, collapsible) */}
|
||||
<div className="border-t border-border/60 pt-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setConfigOpen((v) => !v)}
|
||||
aria-expanded={configOpen}
|
||||
className="flex w-full items-center justify-between gap-2 text-left"
|
||||
>
|
||||
<span className="text-xs font-medium text-muted-foreground">
|
||||
{t('ai.codebuddy.configSection')}
|
||||
</span>
|
||||
<ChevronDown
|
||||
size={14}
|
||||
className={cn("text-muted-foreground transition-transform", configOpen && "rotate-180")}
|
||||
/>
|
||||
</button>
|
||||
{configOpen && (
|
||||
<div className="space-y-3 mt-3">
|
||||
<div className="space-y-1.5">
|
||||
<label htmlFor="codebuddy-internet-env" className="text-xs text-muted-foreground">{t('ai.codebuddy.internetEnv')}</label>
|
||||
<select
|
||||
id="codebuddy-internet-env"
|
||||
value={internetEnv}
|
||||
onChange={(e) => onInternetEnvChange(e.target.value)}
|
||||
className="w-full h-8 rounded-md border border-input bg-background px-3 text-sm font-mono focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
|
||||
>
|
||||
{INTERNET_ENV_OPTIONS.map((opt) => (
|
||||
<option key={opt.value} value={opt.value}>{t(opt.labelKey)}</option>
|
||||
))}
|
||||
</select>
|
||||
<p className="text-[11px] text-muted-foreground leading-4">{t('ai.codebuddy.internetEnv.hint')}</p>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<label htmlFor="codebuddy-env-vars" className="text-xs text-muted-foreground">{t('ai.codebuddy.envVars')}</label>
|
||||
<textarea
|
||||
id="codebuddy-env-vars"
|
||||
value={envDraft}
|
||||
onChange={(e) => { setEnvDraft(e.target.value); onEnvTextChange(e.target.value); }}
|
||||
placeholder={t('ai.codebuddy.envVars.placeholder')}
|
||||
rows={3}
|
||||
spellCheck={false}
|
||||
className="w-full rounded-md border border-input bg-background px-3 py-2 text-sm font-mono placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring resize-y"
|
||||
/>
|
||||
<p className="text-[11px] text-muted-foreground leading-4">{t('ai.codebuddy.envVars.hint')}</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Advanced SDK options (SDK 0.3.230) */}
|
||||
{onAdvancedOptionsChange && (
|
||||
<div className="border-t border-border/60 pt-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setAdvancedOpen((v) => !v)}
|
||||
aria-expanded={advancedOpen}
|
||||
className="flex w-full items-center justify-between gap-2 text-left"
|
||||
>
|
||||
<span className="text-xs font-medium text-muted-foreground">
|
||||
{t('ai.codebuddy.advancedSection')}
|
||||
</span>
|
||||
<ChevronDown
|
||||
size={14}
|
||||
className={cn("text-muted-foreground transition-transform", advancedOpen && "rotate-180")}
|
||||
/>
|
||||
</button>
|
||||
{advancedOpen && (
|
||||
<div className="space-y-3 mt-3">
|
||||
{/* Effort */}
|
||||
<div className="space-y-1.5">
|
||||
<label htmlFor="codebuddy-effort" className="text-xs text-muted-foreground">{t('ai.codebuddy.effort')}</label>
|
||||
<select
|
||||
id="codebuddy-effort"
|
||||
value={advancedOptions?.effort || ""}
|
||||
onChange={(e) => updateAdvanced({ effort: (e.target.value || undefined) as CodebuddyAdvancedOptions['effort'] })}
|
||||
className="w-full h-8 rounded-md border border-input bg-background px-3 text-sm focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
|
||||
>
|
||||
{EFFORT_OPTIONS.map((opt) => (
|
||||
<option key={opt.value} value={opt.value}>{t(opt.labelKey)}</option>
|
||||
))}
|
||||
</select>
|
||||
<p className="text-[11px] text-muted-foreground leading-4">{t('ai.codebuddy.effort.hint')}</p>
|
||||
</div>
|
||||
{/* Max Turns */}
|
||||
<div className="space-y-1.5">
|
||||
<label htmlFor="codebuddy-max-turns" className="text-xs text-muted-foreground">{t('ai.codebuddy.maxTurns')}</label>
|
||||
<input
|
||||
id="codebuddy-max-turns"
|
||||
type="number"
|
||||
min={1}
|
||||
max={200}
|
||||
value={advancedOptions?.maxTurns ?? ""}
|
||||
onChange={(e) => updateAdvanced({ maxTurns: e.target.value ? Number(e.target.value) : undefined })}
|
||||
placeholder="20"
|
||||
className="w-full h-8 rounded-md border border-input bg-background px-3 text-sm focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
|
||||
/>
|
||||
<p className="text-[11px] text-muted-foreground leading-4">{t('ai.codebuddy.maxTurns.hint')}</p>
|
||||
</div>
|
||||
{/* Max Budget USD */}
|
||||
<div className="space-y-1.5">
|
||||
<label htmlFor="codebuddy-max-budget" className="text-xs text-muted-foreground">{t('ai.codebuddy.maxBudget')}</label>
|
||||
<input
|
||||
id="codebuddy-max-budget"
|
||||
type="number"
|
||||
min={0.01}
|
||||
step={0.01}
|
||||
value={advancedOptions?.maxBudgetUsd ?? ""}
|
||||
onChange={(e) => updateAdvanced({ maxBudgetUsd: e.target.value ? Number(e.target.value) : undefined })}
|
||||
placeholder="0.50"
|
||||
className="w-full h-8 rounded-md border border-input bg-background px-3 text-sm focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
|
||||
/>
|
||||
<p className="text-[11px] text-muted-foreground leading-4">{t('ai.codebuddy.maxBudget.hint')}</p>
|
||||
</div>
|
||||
{/* Sandbox */}
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<div>
|
||||
<span className="text-xs text-muted-foreground">{t('ai.codebuddy.sandbox')}</span>
|
||||
<p className="text-[11px] text-muted-foreground leading-4">{t('ai.codebuddy.sandbox.hint')}</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
role="switch"
|
||||
aria-checked={Boolean(advancedOptions?.sandbox?.enabled)}
|
||||
onClick={() => updateAdvanced({ sandbox: advancedOptions?.sandbox?.enabled ? undefined : { enabled: true } })}
|
||||
className={cn(
|
||||
"relative inline-flex h-5 w-9 shrink-0 cursor-pointer items-center rounded-full transition-colors",
|
||||
advancedOptions?.sandbox?.enabled ? "bg-primary" : "bg-muted",
|
||||
)}
|
||||
>
|
||||
<span className={cn(
|
||||
"inline-block h-3.5 w-3.5 rounded-full bg-white transition-transform",
|
||||
advancedOptions?.sandbox?.enabled ? "translate-x-[18px]" : "translate-x-[3px]",
|
||||
)} />
|
||||
</button>
|
||||
</div>
|
||||
{/* File Checkpointing */}
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<div>
|
||||
<span className="text-xs text-muted-foreground">{t('ai.codebuddy.fileCheckpointing')}</span>
|
||||
<p className="text-[11px] text-muted-foreground leading-4">{t('ai.codebuddy.fileCheckpointing.hint')}</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
role="switch"
|
||||
aria-checked={Boolean(advancedOptions?.enableFileCheckpointing)}
|
||||
onClick={() => updateAdvanced({ enableFileCheckpointing: advancedOptions?.enableFileCheckpointing ? undefined : true })}
|
||||
className={cn(
|
||||
"relative inline-flex h-5 w-9 shrink-0 cursor-pointer items-center rounded-full transition-colors",
|
||||
advancedOptions?.enableFileCheckpointing ? "bg-primary" : "bg-muted",
|
||||
)}
|
||||
>
|
||||
<span className={cn(
|
||||
"inline-block h-3.5 w-3.5 rounded-full bg-white transition-transform",
|
||||
advancedOptions?.enableFileCheckpointing ? "translate-x-[18px]" : "translate-x-[3px]",
|
||||
)} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
40
components/settings/tabs/ai/CodexConnectionCard.test.tsx
Normal file
40
components/settings/tabs/ai/CodexConnectionCard.test.tsx
Normal file
@@ -0,0 +1,40 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import React from 'react';
|
||||
import { renderToStaticMarkup } from 'react-dom/server';
|
||||
import { I18nProvider } from '../../../../application/i18n/I18nProvider';
|
||||
import { CodexConnectionCard } from './CodexConnectionCard';
|
||||
|
||||
test('CodexConnectionCard surfaces the experimental App Server runtime', () => {
|
||||
const markup = renderToStaticMarkup(
|
||||
React.createElement(
|
||||
I18nProvider,
|
||||
{ locale: 'en' },
|
||||
React.createElement(CodexConnectionCard, {
|
||||
pathInfo: { path: '/usr/bin/codex', version: '0.144.3', available: true },
|
||||
isResolvingPath: false,
|
||||
customPath: '',
|
||||
onCustomPathChange: () => {},
|
||||
onRecheckPath: () => {},
|
||||
onResetPath: () => {},
|
||||
integration: null,
|
||||
loginSession: null,
|
||||
isLoading: false,
|
||||
error: null,
|
||||
onRefresh: () => {},
|
||||
onConnect: () => {},
|
||||
onCancel: () => {},
|
||||
onOpenUrl: () => {},
|
||||
onLogout: () => {},
|
||||
appServerRuntime: 'app-server',
|
||||
appServerStatus: { available: true },
|
||||
onAppServerRuntimeChange: () => {},
|
||||
}),
|
||||
),
|
||||
);
|
||||
assert.match(markup, /Use Codex App Server/);
|
||||
assert.match(markup, /Experimental/);
|
||||
assert.match(markup, /App Server is available/);
|
||||
assert.match(markup, /role="switch"/);
|
||||
assert.match(markup, /aria-checked="true"/);
|
||||
});
|
||||
249
components/settings/tabs/ai/CodexConnectionCard.tsx
Normal file
249
components/settings/tabs/ai/CodexConnectionCard.tsx
Normal file
@@ -0,0 +1,249 @@
|
||||
import React from "react";
|
||||
import { ExternalLink, LogIn, LogOut, RefreshCw, RotateCcw, X } from "lucide-react";
|
||||
import { useI18n } from "../../../../application/i18n/I18nProvider";
|
||||
import { Button } from "../../../ui/button";
|
||||
import { Switch } from "../../../ui/switch";
|
||||
import { cn } from "../../../../lib/utils";
|
||||
import type { AgentPathInfo, CodexAppServerStatus, CodexIntegrationStatus, CodexLoginSession } from "./types";
|
||||
|
||||
export const CodexConnectionCard: React.FC<{
|
||||
pathInfo: AgentPathInfo | null;
|
||||
isResolvingPath: boolean;
|
||||
customPath: string;
|
||||
onCustomPathChange: (path: string) => void;
|
||||
onRecheckPath: () => void;
|
||||
onResetPath: () => void;
|
||||
integration: CodexIntegrationStatus | null;
|
||||
loginSession: CodexLoginSession | null;
|
||||
isLoading: boolean;
|
||||
hasPendingCustomPath?: boolean;
|
||||
error: string | null;
|
||||
onRefresh: () => void;
|
||||
onConnect: () => void;
|
||||
onCancel: () => void;
|
||||
onOpenUrl: () => void;
|
||||
onLogout: () => void;
|
||||
appServerRuntime: 'sdk' | 'app-server';
|
||||
appServerStatus: CodexAppServerStatus | null;
|
||||
onAppServerRuntimeChange: (runtime: 'sdk' | 'app-server') => void;
|
||||
}> = ({
|
||||
pathInfo,
|
||||
isResolvingPath,
|
||||
customPath,
|
||||
onCustomPathChange,
|
||||
onRecheckPath,
|
||||
onResetPath,
|
||||
integration,
|
||||
loginSession,
|
||||
isLoading,
|
||||
hasPendingCustomPath = false,
|
||||
error,
|
||||
onRefresh,
|
||||
onConnect,
|
||||
onCancel,
|
||||
onOpenUrl,
|
||||
onLogout,
|
||||
appServerRuntime,
|
||||
appServerStatus,
|
||||
onAppServerRuntimeChange,
|
||||
}) => {
|
||||
const { t } = useI18n();
|
||||
const found = pathInfo?.available;
|
||||
|
||||
const customConfigIncomplete = Boolean(
|
||||
integration?.state === "connected_custom_config"
|
||||
&& integration.customConfig
|
||||
&& integration.customConfig.envKey
|
||||
&& !integration.customConfig.envKeyPresent
|
||||
&& !integration.customConfig.hasHardcodedApiKey,
|
||||
);
|
||||
|
||||
const status = isResolvingPath
|
||||
? t('ai.codex.detecting')
|
||||
: !found
|
||||
? t('ai.codex.notFound')
|
||||
: loginSession?.state === "running"
|
||||
? t('ai.codex.awaitingLogin')
|
||||
: integration?.state === "connected_chatgpt"
|
||||
? t('ai.codex.connectedChatGPT')
|
||||
: integration?.state === "connected_api_key"
|
||||
? t('ai.codex.connectedApiKey')
|
||||
: integration?.state === "connected_custom_config"
|
||||
? customConfigIncomplete
|
||||
? t('ai.codex.customConfigIncomplete')
|
||||
: t('ai.codex.connectedCustomConfig')
|
||||
: integration?.state === "not_logged_in"
|
||||
? t('ai.codex.notConnected')
|
||||
: t('ai.codex.statusUnknown');
|
||||
|
||||
const statusClassName = isResolvingPath
|
||||
? "text-muted-foreground"
|
||||
: !found
|
||||
? "text-amber-500"
|
||||
: loginSession?.state === "running"
|
||||
? "text-amber-500"
|
||||
: customConfigIncomplete
|
||||
? "text-amber-500"
|
||||
: integration?.isConnected
|
||||
? "text-emerald-500"
|
||||
: "text-muted-foreground";
|
||||
|
||||
const outputText = loginSession?.error
|
||||
? loginSession.error
|
||||
: loginSession?.output?.trim()
|
||||
? loginSession.output.trim()
|
||||
: integration?.rawOutput?.trim()
|
||||
? integration.rawOutput.trim()
|
||||
: "";
|
||||
|
||||
return (
|
||||
<div className="rounded-lg border bg-card p-4 space-y-3">
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<p className="min-w-0 text-xs text-muted-foreground leading-5">
|
||||
{t('ai.codex.description')}
|
||||
</p>
|
||||
<div className={cn("text-xs font-medium shrink-0", statusClassName)}>
|
||||
{status}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{found && (
|
||||
<div className="flex items-center gap-2 text-xs">
|
||||
<span className="text-muted-foreground">{t('ai.codex.path')}</span>
|
||||
<span className="font-mono text-foreground truncate">{pathInfo.path}</span>
|
||||
{pathInfo.version && (
|
||||
<>
|
||||
<span className="text-muted-foreground">|</span>
|
||||
<span className="text-muted-foreground">{pathInfo.version}</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!isResolvingPath && (
|
||||
<div className="space-y-2">
|
||||
{!found && (
|
||||
<p className="text-xs text-amber-500">
|
||||
{t('ai.codex.notFoundHint')}
|
||||
</p>
|
||||
)}
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
type="text"
|
||||
value={customPath}
|
||||
onChange={(e) => onCustomPathChange(e.target.value)}
|
||||
placeholder={t('ai.codex.customPathPlaceholder')}
|
||||
className="flex-1 h-8 rounded-md border border-input bg-background px-3 text-sm font-mono placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
|
||||
/>
|
||||
<Button variant="outline" size="sm" onClick={onRecheckPath} disabled={!customPath.trim()}>
|
||||
<RefreshCw size={14} className="mr-1.5" />
|
||||
{t('ai.codex.check')}
|
||||
</Button>
|
||||
<Button variant="ghost" size="sm" onClick={onResetPath} disabled={!customPath.trim()}>
|
||||
<RotateCcw size={14} className="mr-1.5" />
|
||||
{t('ai.codex.resetPath')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{found && (
|
||||
<div className="border-t border-border/40 pt-3 flex items-start justify-between gap-4">
|
||||
<div className="min-w-0 space-y-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm font-medium">{t('ai.codex.appServer.title')}</span>
|
||||
<span className="rounded border border-amber-500/30 bg-amber-500/10 px-1.5 py-0.5 text-[10px] font-medium text-amber-500">
|
||||
{t('ai.codex.appServer.experimental')}
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground leading-5">
|
||||
{t('ai.codex.appServer.description')}
|
||||
</p>
|
||||
{appServerStatus?.checking ? (
|
||||
<p className="text-xs text-muted-foreground">{t('ai.codex.appServer.checking')}</p>
|
||||
) : appServerStatus?.available ? (
|
||||
<p className="text-xs text-emerald-500">{t('ai.codex.appServer.available')}</p>
|
||||
) : appServerStatus?.error ? (
|
||||
<p className="text-xs text-amber-500">{appServerStatus.error}</p>
|
||||
) : null}
|
||||
</div>
|
||||
<Switch
|
||||
checked={appServerRuntime === 'app-server'}
|
||||
disabled={Boolean(appServerStatus?.checking) || (appServerRuntime === 'sdk' && appServerStatus?.available !== true)}
|
||||
aria-label={t('ai.codex.appServer.title')}
|
||||
onCheckedChange={(checked) => onAppServerRuntimeChange(checked ? 'app-server' : 'sdk')}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Connection & login UI -- only when codex is detected */}
|
||||
{found && (
|
||||
<>
|
||||
<div className="border-t border-border/40 pt-3 flex items-center gap-2 flex-wrap">
|
||||
{loginSession?.state === "running" ? (
|
||||
<>
|
||||
<Button variant="default" size="sm" onClick={onOpenUrl} disabled={!loginSession.url}>
|
||||
<ExternalLink size={14} className="mr-1.5" />
|
||||
{t('ai.codex.openLogin')}
|
||||
</Button>
|
||||
<Button variant="outline" size="sm" onClick={onCancel}>
|
||||
<X size={14} className="mr-1.5" />
|
||||
{t('common.cancel')}
|
||||
</Button>
|
||||
</>
|
||||
) : integration?.state === "connected_custom_config" ? (
|
||||
// Nothing to log out of; config.toml is user-owned state.
|
||||
null
|
||||
) : integration?.isConnected ? (
|
||||
<Button variant="outline" size="sm" onClick={onLogout} disabled={hasPendingCustomPath}>
|
||||
<LogOut size={14} className="mr-1.5" />
|
||||
{t('ai.codex.logout')}
|
||||
</Button>
|
||||
) : (
|
||||
<Button variant="default" size="sm" onClick={onConnect} disabled={hasPendingCustomPath}>
|
||||
<LogIn size={14} className="mr-1.5" />
|
||||
{t('ai.codex.connectChatGPT')}
|
||||
</Button>
|
||||
)}
|
||||
|
||||
<Button variant="outline" size="sm" onClick={onRefresh} disabled={isLoading || hasPendingCustomPath}>
|
||||
<RefreshCw size={14} className={cn("mr-1.5", isLoading && "animate-spin")} />
|
||||
{t('ai.codex.refreshStatus')}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{integration?.state === "connected_custom_config" && integration.customConfig && (
|
||||
<>
|
||||
<p className="text-xs text-emerald-500">
|
||||
{t('ai.codex.customConfigHint').replace(
|
||||
'{provider}',
|
||||
integration.customConfig.displayName || integration.customConfig.providerName,
|
||||
)}
|
||||
</p>
|
||||
{integration.customConfig.envKey && !integration.customConfig.envKeyPresent && !integration.customConfig.hasHardcodedApiKey && (
|
||||
<p className="text-xs text-amber-500">
|
||||
{t('ai.codex.customConfigMissingEnvKey').replace(
|
||||
'{envKey}',
|
||||
integration.customConfig.envKey,
|
||||
)}
|
||||
</p>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{error && (
|
||||
<p className="text-xs text-destructive">
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{found && outputText && (
|
||||
<pre className="rounded-md border border-border/60 bg-background px-3 py-2 text-[11px] leading-5 text-muted-foreground whitespace-pre-wrap max-h-40 overflow-auto">
|
||||
{outputText}
|
||||
</pre>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
75
components/settings/tabs/ai/CopilotCliCard.test.tsx
Normal file
75
components/settings/tabs/ai/CopilotCliCard.test.tsx
Normal file
@@ -0,0 +1,75 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import React from "react";
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
import { CopilotCliCard } from "./CopilotCliCard";
|
||||
|
||||
function firstButton(markup: string): string {
|
||||
const match = markup.match(/<button\b[^>]*>/);
|
||||
return match?.[0] ?? "";
|
||||
}
|
||||
|
||||
test("Cursor check button stays enabled without a custom path", () => {
|
||||
const markup = renderToStaticMarkup(
|
||||
<CopilotCliCard
|
||||
pathInfo={{ path: null, version: null, available: false }}
|
||||
isResolvingPath={false}
|
||||
customPath=""
|
||||
onCustomPathChange={() => {}}
|
||||
onRecheckPath={() => {}}
|
||||
i18nPrefix="ai.cursor"
|
||||
allowEmptyCheck
|
||||
/>,
|
||||
);
|
||||
|
||||
assert.equal(firstButton(markup).includes("disabled=\"\""), false);
|
||||
});
|
||||
|
||||
test("Copilot check button still requires a custom path", () => {
|
||||
const markup = renderToStaticMarkup(
|
||||
<CopilotCliCard
|
||||
pathInfo={{ path: null, version: null, available: false }}
|
||||
isResolvingPath={false}
|
||||
customPath=""
|
||||
onCustomPathChange={() => {}}
|
||||
onRecheckPath={() => {}}
|
||||
/>,
|
||||
);
|
||||
|
||||
assert.equal(firstButton(markup).includes("disabled=\"\""), true);
|
||||
});
|
||||
|
||||
test("Grok card surfaces ACP runtime toggle when detected", () => {
|
||||
const markup = renderToStaticMarkup(
|
||||
<CopilotCliCard
|
||||
pathInfo={{ path: "/usr/bin/grok", version: "0.2.118", available: true }}
|
||||
isResolvingPath={false}
|
||||
customPath=""
|
||||
onCustomPathChange={() => {}}
|
||||
onRecheckPath={() => {}}
|
||||
i18nPrefix="ai.grok"
|
||||
grokRuntime="acp"
|
||||
onGrokRuntimeChange={() => {}}
|
||||
/>,
|
||||
);
|
||||
|
||||
assert.match(markup, /ai\.grok\.runtime\.acp\.title|Use Grok ACP/);
|
||||
assert.match(markup, /role="switch"/);
|
||||
});
|
||||
|
||||
test("Grok card hides ACP toggle without runtime change handler", () => {
|
||||
const markup = renderToStaticMarkup(
|
||||
<CopilotCliCard
|
||||
pathInfo={{ path: "/usr/bin/grok", version: "0.2.118", available: true }}
|
||||
isResolvingPath={false}
|
||||
customPath=""
|
||||
onCustomPathChange={() => {}}
|
||||
onRecheckPath={() => {}}
|
||||
i18nPrefix="ai.grok"
|
||||
grokRuntime="acp"
|
||||
/>,
|
||||
);
|
||||
|
||||
assert.doesNotMatch(markup, /role="switch"/);
|
||||
assert.doesNotMatch(markup, /ai\.grok\.runtime\.acp\.title|Use Grok ACP \(agent stdio\)/);
|
||||
});
|
||||
136
components/settings/tabs/ai/CopilotCliCard.tsx
Normal file
136
components/settings/tabs/ai/CopilotCliCard.tsx
Normal file
@@ -0,0 +1,136 @@
|
||||
import React from "react";
|
||||
import { RefreshCw, RotateCcw } from "lucide-react";
|
||||
import { useI18n } from "../../../../application/i18n/I18nProvider";
|
||||
import { Button } from "../../../ui/button";
|
||||
import { Switch } from "../../../ui/switch";
|
||||
import { cn } from "../../../../lib/utils";
|
||||
import type { GrokRuntime } from "../../../../infrastructure/ai/types";
|
||||
import type { AgentPathInfo } from "./types";
|
||||
|
||||
export const CopilotCliCard: React.FC<{
|
||||
pathInfo: AgentPathInfo | null;
|
||||
isResolvingPath: boolean;
|
||||
customPath: string;
|
||||
onCustomPathChange: (path: string) => void;
|
||||
onRecheckPath: () => void;
|
||||
onResetPath?: () => void;
|
||||
i18nPrefix?: "ai.copilot" | "ai.cursor" | "ai.opencode" | "ai.grok";
|
||||
allowEmptyCheck?: boolean;
|
||||
showCustomPathInput?: boolean;
|
||||
/** Grok only: ACP (default) vs headless streaming-json. */
|
||||
grokRuntime?: GrokRuntime;
|
||||
onGrokRuntimeChange?: (runtime: GrokRuntime) => void;
|
||||
}> = ({
|
||||
pathInfo,
|
||||
isResolvingPath,
|
||||
customPath,
|
||||
onCustomPathChange,
|
||||
onRecheckPath,
|
||||
onResetPath,
|
||||
i18nPrefix = "ai.copilot",
|
||||
allowEmptyCheck = false,
|
||||
showCustomPathInput = true,
|
||||
grokRuntime = "acp",
|
||||
onGrokRuntimeChange,
|
||||
}) => {
|
||||
const { t } = useI18n();
|
||||
const found = pathInfo?.available;
|
||||
const showGrokRuntime = i18nPrefix === "ai.grok" && typeof onGrokRuntimeChange === "function";
|
||||
|
||||
const statusText = isResolvingPath
|
||||
? t(`${i18nPrefix}.detecting`)
|
||||
: found
|
||||
? t(`${i18nPrefix}.detected`)
|
||||
: t(`${i18nPrefix}.notFound`);
|
||||
|
||||
const statusClassName = isResolvingPath
|
||||
? "text-muted-foreground"
|
||||
: found
|
||||
? "text-emerald-500"
|
||||
: "text-amber-500";
|
||||
|
||||
return (
|
||||
<div className="rounded-lg border bg-card p-4 space-y-3">
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<p className="min-w-0 text-xs text-muted-foreground leading-5">
|
||||
{t(`${i18nPrefix}.description`)}
|
||||
</p>
|
||||
<div className={cn("text-xs font-medium shrink-0", statusClassName)}>
|
||||
{statusText}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{found && (
|
||||
<div className="flex items-center gap-2 text-xs">
|
||||
<span className="text-muted-foreground">{t(`${i18nPrefix}.path`)}</span>
|
||||
<span className="font-mono text-foreground truncate">{pathInfo.path}</span>
|
||||
{pathInfo.version && (
|
||||
<>
|
||||
<span className="text-muted-foreground">|</span>
|
||||
<span className="text-muted-foreground">{pathInfo.version}</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!isResolvingPath && (
|
||||
<div className="space-y-2">
|
||||
{!found && (
|
||||
<p className="text-xs text-amber-500">
|
||||
{t(`${i18nPrefix}.notFoundHint`)}
|
||||
</p>
|
||||
)}
|
||||
<div className={cn("flex items-center gap-2", showCustomPathInput ? "" : "justify-end")}>
|
||||
{showCustomPathInput && (
|
||||
<input
|
||||
type="text"
|
||||
value={customPath}
|
||||
onChange={(e) => onCustomPathChange(e.target.value)}
|
||||
placeholder={t(`${i18nPrefix}.customPathPlaceholder`)}
|
||||
className="flex-1 h-8 rounded-md border border-input bg-background px-3 text-sm font-mono placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
|
||||
/>
|
||||
)}
|
||||
<Button variant="outline" size="sm" onClick={onRecheckPath} disabled={!allowEmptyCheck && !customPath.trim()}>
|
||||
<RefreshCw size={14} className="mr-1.5" />
|
||||
{t(`${i18nPrefix}.check`)}
|
||||
</Button>
|
||||
{showCustomPathInput && onResetPath && (
|
||||
<Button variant="ghost" size="sm" onClick={onResetPath} disabled={!customPath.trim()}>
|
||||
<RotateCcw size={14} className="mr-1.5" />
|
||||
{t(`${i18nPrefix}.resetPath`)}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{showGrokRuntime && found && (
|
||||
<div className="border-t border-border/40 pt-3 flex items-start justify-between gap-4">
|
||||
<div className="min-w-0 space-y-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm font-medium">{t("ai.grok.runtime.acp.title")}</span>
|
||||
<span className="rounded border border-primary/30 bg-primary/10 px-1.5 py-0.5 text-[10px] font-medium text-primary">
|
||||
{t("ai.grok.runtime.acp.default")}
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground leading-5">
|
||||
{t("ai.grok.runtime.acp.description")}
|
||||
</p>
|
||||
{grokRuntime === "streaming-json" && (
|
||||
<p className="text-xs text-muted-foreground leading-5">
|
||||
{t("ai.grok.runtime.streamingJson.hint")}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<Switch
|
||||
checked={grokRuntime === "acp"}
|
||||
aria-label={t("ai.grok.runtime.acp.title")}
|
||||
onCheckedChange={(checked) =>
|
||||
onGrokRuntimeChange?.(checked ? "acp" : "streaming-json")
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
231
components/settings/tabs/ai/CursorSdkCard.tsx
Normal file
231
components/settings/tabs/ai/CursorSdkCard.tsx
Normal file
@@ -0,0 +1,231 @@
|
||||
import React, { useEffect, useState } from "react";
|
||||
import { Check, Eye, EyeOff, RefreshCw } from "lucide-react";
|
||||
import { useI18n } from "../../../../application/i18n/I18nProvider";
|
||||
import { decryptField } from "../../../../infrastructure/persistence/secureFieldAdapter";
|
||||
import type { CursorAuthMode } from "../../../../infrastructure/ai/types";
|
||||
import { Button } from "../../../ui/button";
|
||||
import { cn } from "../../../../lib/utils";
|
||||
import { isCursorRuntimeInstalled, type AgentPathInfo } from "./types";
|
||||
|
||||
export const CursorSdkCard: React.FC<{
|
||||
pathInfo: AgentPathInfo | null;
|
||||
isResolvingPath: boolean;
|
||||
encryptedApiKey?: string;
|
||||
authMode: CursorAuthMode;
|
||||
onAuthModeChange: (mode: CursorAuthMode) => void;
|
||||
onSaveApiKey: (apiKey: string) => Promise<void>;
|
||||
onRecheckPath: () => void;
|
||||
}> = ({
|
||||
pathInfo,
|
||||
isResolvingPath,
|
||||
encryptedApiKey,
|
||||
authMode,
|
||||
onAuthModeChange,
|
||||
onSaveApiKey,
|
||||
onRecheckPath,
|
||||
}) => {
|
||||
const { t } = useI18n();
|
||||
const [apiKeyDraft, setApiKeyDraft] = useState("");
|
||||
const [showApiKey, setShowApiKey] = useState(false);
|
||||
const [isDecrypting, setIsDecrypting] = useState(false);
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
const [saved, setSaved] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
setSaved(false);
|
||||
if (!encryptedApiKey) {
|
||||
setApiKeyDraft("");
|
||||
return;
|
||||
}
|
||||
setIsDecrypting(true);
|
||||
decryptField(encryptedApiKey)
|
||||
.then((value) => {
|
||||
if (!cancelled) setApiKeyDraft(value ?? "");
|
||||
})
|
||||
.catch(() => {
|
||||
if (!cancelled) setApiKeyDraft("");
|
||||
})
|
||||
.finally(() => {
|
||||
if (!cancelled) setIsDecrypting(false);
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [encryptedApiKey]);
|
||||
|
||||
const installed = isCursorRuntimeInstalled(pathInfo);
|
||||
const hasStoredApiKey = Boolean(encryptedApiKey);
|
||||
const usesEnvApiKey = pathInfo?.authSource === "CURSOR_API_KEY" || (
|
||||
pathInfo?.apiKeyOk && !hasStoredApiKey && pathInfo?.authSource !== "settings"
|
||||
);
|
||||
// CLI login is only proven by the dedicated probe — never generic `authenticated`
|
||||
// (which is also true for env/settings API keys).
|
||||
const hasCliLogin = Boolean(
|
||||
pathInfo?.cliLoginOk || pathInfo?.authSource === "cli-login",
|
||||
);
|
||||
const hasAnyApiKey = hasStoredApiKey || Boolean(pathInfo?.apiKeyOk) || usesEnvApiKey
|
||||
|| pathInfo?.authSource === "settings"
|
||||
|| pathInfo?.authSource === "CURSOR_API_KEY";
|
||||
const isApiKeyMode = authMode === "api-key";
|
||||
const isCliMode = authMode === "cli-login";
|
||||
const available = isCliMode
|
||||
? hasCliLogin
|
||||
: (hasAnyApiKey && Boolean(pathInfo?.sdkInstalled ?? true));
|
||||
const canSave = isApiKeyMode && !isSaving && !isDecrypting && (Boolean(apiKeyDraft.trim()) || hasStoredApiKey);
|
||||
|
||||
const installStatus = isResolvingPath
|
||||
? t("ai.cursor.detecting")
|
||||
: installed
|
||||
? t("ai.cursor.installed")
|
||||
: t("ai.cursor.notInstalled");
|
||||
|
||||
const authStatus = isCliMode
|
||||
? hasCliLogin
|
||||
? (pathInfo?.cliEmail
|
||||
? t("ai.cursor.cliLoginAs", { email: pathInfo.cliEmail })
|
||||
: t("ai.cursor.cliLoginOk"))
|
||||
: t("ai.cursor.cliLoginMissing")
|
||||
: hasAnyApiKey
|
||||
? usesEnvApiKey && !hasStoredApiKey
|
||||
? t("ai.cursor.apiKeyFromEnv")
|
||||
: t("ai.cursor.apiKeyConfigured")
|
||||
: t("ai.cursor.apiKeyMissing");
|
||||
|
||||
const installStatusClassName = isResolvingPath
|
||||
? "text-muted-foreground"
|
||||
: installed
|
||||
? "text-emerald-500"
|
||||
: "text-amber-500";
|
||||
const authStatusClassName = isCliMode
|
||||
? (hasCliLogin ? "text-emerald-500" : "text-amber-500")
|
||||
: (hasAnyApiKey ? "text-emerald-500" : "text-amber-500");
|
||||
|
||||
const handleSave = async () => {
|
||||
if (!isApiKeyMode) return;
|
||||
setIsSaving(true);
|
||||
setSaved(false);
|
||||
try {
|
||||
await onSaveApiKey(apiKeyDraft.trim());
|
||||
setSaved(true);
|
||||
} finally {
|
||||
setIsSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="rounded-lg border bg-card p-4 space-y-3">
|
||||
<div className="flex gap-1 rounded-md border border-border/60 p-0.5 bg-muted/30">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onAuthModeChange("cli-login")}
|
||||
className={cn(
|
||||
"flex-1 h-7 rounded text-xs font-medium transition-colors",
|
||||
isCliMode ? "bg-background text-foreground shadow-sm" : "text-muted-foreground hover:text-foreground",
|
||||
)}
|
||||
>
|
||||
{t("ai.cursor.modeCli")}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onAuthModeChange("api-key")}
|
||||
className={cn(
|
||||
"flex-1 h-7 rounded text-xs font-medium transition-colors",
|
||||
isApiKeyMode ? "bg-background text-foreground shadow-sm" : "text-muted-foreground hover:text-foreground",
|
||||
)}
|
||||
>
|
||||
{t("ai.cursor.modeApiKey")}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<p className="text-[11px] text-muted-foreground leading-4">
|
||||
{isCliMode ? t("ai.cursor.modeCliHint") : t("ai.cursor.modeApiKeyHint")}
|
||||
</p>
|
||||
|
||||
<div className="grid gap-2 text-xs">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<span className="text-muted-foreground">{t("ai.cursor.installStatus")}</span>
|
||||
<span className={cn("font-medium", installStatusClassName)}>{installStatus}</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<span className="text-muted-foreground">
|
||||
{isCliMode ? t("ai.cursor.cliLoginStatus") : t("ai.cursor.apiKeyStatus")}
|
||||
</span>
|
||||
<span className={cn("font-medium truncate max-w-[60%] text-right", authStatusClassName)}>
|
||||
{authStatus}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{!available && (
|
||||
<p className="text-xs text-amber-500">
|
||||
{isCliMode
|
||||
? t("ai.cursor.cliLoginHint")
|
||||
: (Boolean(pathInfo?.sdkInstalled) || installed)
|
||||
? t("ai.cursor.notFoundHint")
|
||||
: t("ai.cursor.notInstalledHint")}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{isApiKeyMode ? (
|
||||
<div className="space-y-1.5">
|
||||
<label className="text-xs font-medium text-muted-foreground">{t("ai.cursor.apiKey")}</label>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="relative flex-1">
|
||||
<input
|
||||
type={showApiKey ? "text" : "password"}
|
||||
value={isDecrypting ? "" : apiKeyDraft}
|
||||
onChange={(event) => {
|
||||
setSaved(false);
|
||||
setApiKeyDraft(event.target.value);
|
||||
}}
|
||||
placeholder={
|
||||
isDecrypting
|
||||
? t("ai.providers.apiKey.decrypting")
|
||||
: usesEnvApiKey && !hasStoredApiKey
|
||||
? t("ai.cursor.apiKeyPlaceholder.env")
|
||||
: t("ai.cursor.apiKeyPlaceholder")
|
||||
}
|
||||
disabled={isDecrypting}
|
||||
className="w-full h-8 rounded-md border border-input bg-background px-3 pr-9 text-sm placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:opacity-50"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowApiKey((value) => !value)}
|
||||
className="absolute right-2 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground"
|
||||
aria-label={showApiKey ? t("ai.cursor.hideApiKey") : t("ai.cursor.showApiKey")}
|
||||
>
|
||||
{showApiKey ? <EyeOff size={14} /> : <Eye size={14} />}
|
||||
</button>
|
||||
</div>
|
||||
<Button variant="outline" size="sm" onClick={handleSave} disabled={!canSave}>
|
||||
{saved ? <Check size={14} className="mr-1.5" /> : null}
|
||||
{saved ? t("ai.cursor.saved") : t("ai.cursor.saveApiKey")}
|
||||
</Button>
|
||||
<Button variant="outline" size="sm" onClick={onRecheckPath} disabled={isResolvingPath}>
|
||||
<RefreshCw size={14} className="mr-1.5" />
|
||||
{t("ai.cursor.check")}
|
||||
</Button>
|
||||
</div>
|
||||
{usesEnvApiKey && !hasStoredApiKey ? (
|
||||
<p className="text-[11px] text-muted-foreground leading-4">
|
||||
{t("ai.cursor.apiKeyEnvHint")}
|
||||
</p>
|
||||
) : null}
|
||||
{usesEnvApiKey && hasStoredApiKey ? (
|
||||
<p className="text-[11px] text-muted-foreground leading-4">
|
||||
{t("ai.cursor.apiKeyOverrideHint")}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex justify-end">
|
||||
<Button variant="outline" size="sm" onClick={onRecheckPath} disabled={isResolvingPath}>
|
||||
<RefreshCw size={14} className="mr-1.5" />
|
||||
{t("ai.cursor.check")}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
928
components/settings/tabs/ai/ExternalMcpCard.tsx
Normal file
928
components/settings/tabs/ai/ExternalMcpCard.tsx
Normal file
@@ -0,0 +1,928 @@
|
||||
import React, { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { Check, Copy, HelpCircle, RefreshCw } from "lucide-react";
|
||||
import { useI18n } from "../../../../application/i18n/I18nProvider";
|
||||
import {
|
||||
readExternalMcpFocusOnHostOpen,
|
||||
readExternalMcpIdleTimeoutMinutes,
|
||||
readExternalMcpMode,
|
||||
readExternalMcpSilentSessions,
|
||||
readSessionIdleTimeoutMinutes,
|
||||
writeExternalMcpFocusOnHostOpen,
|
||||
writeExternalMcpIdleTimeoutMinutes,
|
||||
writeExternalMcpMode,
|
||||
writeExternalMcpSilentSessions,
|
||||
writeSessionIdleTimeoutMinutes,
|
||||
type ExternalMcpMode,
|
||||
useExternalMcpToggleState,
|
||||
} from "../../../../application/state/useExternalMcpToggleState";
|
||||
import { cn } from "../../../../lib/utils";
|
||||
import { Button } from "../../../ui/button";
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from "../../../ui/tooltip";
|
||||
import { Select, SettingCard, SettingRow, Toggle } from "../../../settings/settings-ui";
|
||||
import { getBridge } from "./types";
|
||||
|
||||
type ExternalMcpClient = "codex" | "claude" | "grok" | "cursor";
|
||||
|
||||
const CLIENT_TABS: ExternalMcpClient[] = ["codex", "claude", "grok", "cursor"];
|
||||
|
||||
type CopyableCodeBlockProps = {
|
||||
label?: string;
|
||||
value: string;
|
||||
copyKey: string;
|
||||
copied: string | null;
|
||||
onCopy: (key: string, text: string) => void;
|
||||
copyLabel: string;
|
||||
copiedLabel: string;
|
||||
emptyLabel?: string;
|
||||
className?: string;
|
||||
};
|
||||
|
||||
const CopyableCodeBlock: React.FC<CopyableCodeBlockProps> = ({
|
||||
label,
|
||||
value,
|
||||
copyKey,
|
||||
copied,
|
||||
onCopy,
|
||||
copyLabel,
|
||||
copiedLabel,
|
||||
emptyLabel,
|
||||
className,
|
||||
}) => {
|
||||
const display = value || emptyLabel || "";
|
||||
const canCopy = Boolean(value);
|
||||
const isCopied = copied === copyKey;
|
||||
|
||||
return (
|
||||
<div className={cn("space-y-1.5", className)}>
|
||||
{label ? (
|
||||
<div className="text-xs font-medium text-muted-foreground">{label}</div>
|
||||
) : null}
|
||||
<div className="group relative rounded-md border border-border/60 bg-muted/20">
|
||||
<pre
|
||||
className={cn(
|
||||
"max-h-40 overflow-auto whitespace-pre-wrap break-all px-3 py-2.5 pr-11 font-mono text-xs leading-5",
|
||||
!value && "text-muted-foreground",
|
||||
)}
|
||||
>
|
||||
{display}
|
||||
</pre>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
disabled={!canCopy}
|
||||
className="absolute right-1.5 top-1.5 h-7 w-7 p-0 text-muted-foreground hover:text-foreground"
|
||||
onClick={() => void onCopy(copyKey, value)}
|
||||
aria-label={isCopied ? copiedLabel : copyLabel}
|
||||
title={isCopied ? copiedLabel : copyLabel}
|
||||
>
|
||||
{isCopied ? <Check size={14} className="text-emerald-500" /> : <Copy size={14} />}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
type ExternalMcpStatus = {
|
||||
ok: boolean;
|
||||
enabled?: boolean;
|
||||
state?: string;
|
||||
host?: string;
|
||||
port?: number | null;
|
||||
discoveryPath?: string | null;
|
||||
launcherPath?: string | null;
|
||||
exposedSessionCount?: number;
|
||||
mode?: ExternalMcpMode;
|
||||
idleTimeoutMinutes?: number;
|
||||
sessionIdleTimeoutMinutes?: number;
|
||||
permissionMode?: string;
|
||||
error?: string | null;
|
||||
};
|
||||
|
||||
type ClientSetupStatus = {
|
||||
ok: boolean;
|
||||
state?: string;
|
||||
launcherPath?: string | null;
|
||||
command?: string;
|
||||
existingCommand?: string | null;
|
||||
error?: string | null;
|
||||
};
|
||||
|
||||
type StatusView = {
|
||||
labelKey: string;
|
||||
className: string;
|
||||
};
|
||||
|
||||
function getBridgeStatusView(status: ExternalMcpStatus | null, enabled: boolean): StatusView {
|
||||
if (!enabled) {
|
||||
return { labelKey: "ai.externalMcp.status.disabled", className: "text-muted-foreground" };
|
||||
}
|
||||
if (!status || !status.ok) {
|
||||
return { labelKey: "ai.externalMcp.status.unavailable", className: "text-amber-500" };
|
||||
}
|
||||
if (status.state === "running") {
|
||||
return { labelKey: "ai.externalMcp.status.running", className: "text-emerald-500" };
|
||||
}
|
||||
if (status.state === "starting") {
|
||||
return { labelKey: "ai.externalMcp.status.starting", className: "text-amber-500" };
|
||||
}
|
||||
if (status.state === "error") {
|
||||
return { labelKey: "ai.externalMcp.status.error", className: "text-destructive" };
|
||||
}
|
||||
return { labelKey: "ai.externalMcp.status.disabled", className: "text-muted-foreground" };
|
||||
}
|
||||
|
||||
/** Map bridge permissionMode to a Safety i18n key for display. */
|
||||
function getPermissionModeLabelKey(mode: string | null | undefined): string {
|
||||
switch (mode) {
|
||||
case "observer":
|
||||
return "ai.safety.permissionMode.observer";
|
||||
case "auto":
|
||||
return "ai.safety.permissionMode.auto";
|
||||
case "confirm":
|
||||
return "ai.safety.permissionMode.confirm";
|
||||
default:
|
||||
return "ai.externalMcp.permissionMode.unknown";
|
||||
}
|
||||
}
|
||||
|
||||
function getPermissionModeToneClass(mode: string | null | undefined): string {
|
||||
switch (mode) {
|
||||
case "auto":
|
||||
return "text-emerald-500";
|
||||
case "observer":
|
||||
return "text-amber-500";
|
||||
case "confirm":
|
||||
return "text-foreground";
|
||||
default:
|
||||
return "text-muted-foreground";
|
||||
}
|
||||
}
|
||||
|
||||
function getCodexStatusView(status: ClientSetupStatus | null): StatusView {
|
||||
switch (status?.state) {
|
||||
case "configured":
|
||||
return { labelKey: "ai.externalMcp.status.configured", className: "text-emerald-500" };
|
||||
case "not_configured":
|
||||
return { labelKey: "ai.externalMcp.status.notConfigured", className: "text-muted-foreground" };
|
||||
case "codex_not_found":
|
||||
return { labelKey: "ai.externalMcp.status.codexNotFound", className: "text-amber-500" };
|
||||
case "conflict":
|
||||
return { labelKey: "ai.externalMcp.status.conflict", className: "text-destructive" };
|
||||
case "error":
|
||||
return { labelKey: "ai.externalMcp.status.error", className: "text-destructive" };
|
||||
default:
|
||||
return { labelKey: "ai.externalMcp.status.checking", className: "text-muted-foreground" };
|
||||
}
|
||||
}
|
||||
|
||||
function getClaudeStatusView(status: ClientSetupStatus | null): StatusView {
|
||||
switch (status?.state) {
|
||||
case "configured":
|
||||
return { labelKey: "ai.externalMcp.status.configured", className: "text-emerald-500" };
|
||||
case "not_configured":
|
||||
return { labelKey: "ai.externalMcp.status.notConfigured", className: "text-muted-foreground" };
|
||||
case "claude_not_found":
|
||||
return { labelKey: "ai.externalMcp.status.claudeNotFound", className: "text-amber-500" };
|
||||
case "conflict":
|
||||
return { labelKey: "ai.externalMcp.status.conflict", className: "text-destructive" };
|
||||
case "error":
|
||||
return { labelKey: "ai.externalMcp.status.error", className: "text-destructive" };
|
||||
default:
|
||||
return { labelKey: "ai.externalMcp.status.checking", className: "text-muted-foreground" };
|
||||
}
|
||||
}
|
||||
|
||||
function getGrokStatusView(status: ClientSetupStatus | null): StatusView {
|
||||
switch (status?.state) {
|
||||
case "configured":
|
||||
return { labelKey: "ai.externalMcp.status.configured", className: "text-emerald-500" };
|
||||
case "not_configured":
|
||||
return { labelKey: "ai.externalMcp.status.notConfigured", className: "text-muted-foreground" };
|
||||
case "grok_not_found":
|
||||
return { labelKey: "ai.externalMcp.status.grokNotFound", className: "text-amber-500" };
|
||||
case "conflict":
|
||||
return { labelKey: "ai.externalMcp.status.conflict", className: "text-destructive" };
|
||||
case "error":
|
||||
return { labelKey: "ai.externalMcp.status.error", className: "text-destructive" };
|
||||
default:
|
||||
return { labelKey: "ai.externalMcp.status.checking", className: "text-muted-foreground" };
|
||||
}
|
||||
}
|
||||
|
||||
function escapeTomlBasicString(value: string) {
|
||||
return value.replaceAll("\\", "\\\\").replaceAll("\"", "\\\"");
|
||||
}
|
||||
|
||||
function quoteShellArg(value: string) {
|
||||
if (!value) return '""';
|
||||
if (!/[\s"'\\]/.test(value)) return value;
|
||||
return `"${value.replaceAll("\\", "\\\\").replaceAll("\"", "\\\"")}"`;
|
||||
}
|
||||
|
||||
export const EXTERNAL_MCP_DISCOVERY_ENV_VAR = "NETCATTY_EXTERNAL_MCP_DISCOVERY_FILE";
|
||||
|
||||
export function formatCodexAddCommand(launcherPath: string, discoveryPath?: string | null) {
|
||||
const envFlags = discoveryPath
|
||||
? ` --env ${EXTERNAL_MCP_DISCOVERY_ENV_VAR}=${quoteShellArg(discoveryPath)}`
|
||||
: "";
|
||||
return `codex mcp add netcatty-external${envFlags} -- ${quoteShellArg(launcherPath)}`;
|
||||
}
|
||||
|
||||
export function formatClaudeAddCommand(launcherPath: string, discoveryPath?: string | null) {
|
||||
const envFlags = discoveryPath
|
||||
? ` -e ${EXTERNAL_MCP_DISCOVERY_ENV_VAR}=${quoteShellArg(discoveryPath)}`
|
||||
: "";
|
||||
return `claude mcp add -s user netcatty-external${envFlags} -- ${quoteShellArg(launcherPath)}`;
|
||||
}
|
||||
|
||||
export function formatGrokAddCommand(launcherPath: string, discoveryPath?: string | null) {
|
||||
const envFlags = discoveryPath
|
||||
? ` -e ${EXTERNAL_MCP_DISCOVERY_ENV_VAR}=${quoteShellArg(discoveryPath)}`
|
||||
: "";
|
||||
return `grok mcp add netcatty-external${envFlags} -- ${quoteShellArg(launcherPath)}`;
|
||||
}
|
||||
|
||||
function buildTomlEnvBlock(discoveryPath?: string | null) {
|
||||
if (!discoveryPath) return "";
|
||||
return `\nenv = { ${EXTERNAL_MCP_DISCOVERY_ENV_VAR} = "${escapeTomlBasicString(discoveryPath)}" }`;
|
||||
}
|
||||
|
||||
export function buildCodexTomlSnippet(launcherPath: string, discoveryPath?: string | null) {
|
||||
return `[mcp_servers.netcatty-external]
|
||||
command = "${escapeTomlBasicString(launcherPath)}"
|
||||
args = []${buildTomlEnvBlock(discoveryPath)}`;
|
||||
}
|
||||
|
||||
export function buildGrokTomlSnippet(launcherPath: string, discoveryPath?: string | null) {
|
||||
return `[mcp_servers.netcatty-external]
|
||||
command = "${escapeTomlBasicString(launcherPath)}"
|
||||
args = []${buildTomlEnvBlock(discoveryPath)}`;
|
||||
}
|
||||
|
||||
function buildJsonServerEntry(launcherPath: string, discoveryPath?: string | null) {
|
||||
const entry: {
|
||||
command: string;
|
||||
args: string[];
|
||||
env?: Record<string, string>;
|
||||
} = {
|
||||
command: launcherPath,
|
||||
args: [],
|
||||
};
|
||||
if (discoveryPath) {
|
||||
entry.env = { [EXTERNAL_MCP_DISCOVERY_ENV_VAR]: discoveryPath };
|
||||
}
|
||||
return entry;
|
||||
}
|
||||
|
||||
export function buildClaudeSnippet(launcherPath: string, discoveryPath?: string | null) {
|
||||
return JSON.stringify({
|
||||
mcpServers: {
|
||||
"netcatty-external": buildJsonServerEntry(launcherPath, discoveryPath),
|
||||
},
|
||||
}, null, 2);
|
||||
}
|
||||
|
||||
export function buildCursorSnippet(launcherPath: string, discoveryPath?: string | null) {
|
||||
return JSON.stringify({
|
||||
mcpServers: {
|
||||
"netcatty-external": buildJsonServerEntry(launcherPath, discoveryPath),
|
||||
},
|
||||
}, null, 2);
|
||||
}
|
||||
|
||||
export const ExternalMcpCard: React.FC = () => {
|
||||
const { t } = useI18n();
|
||||
const { enabled, setEnabled } = useExternalMcpToggleState();
|
||||
const [mode, setModeRaw] = useState<ExternalMcpMode>(() => readExternalMcpMode());
|
||||
const [idleTimeoutMinutes, setIdleTimeoutRaw] = useState<number>(() => readExternalMcpIdleTimeoutMinutes());
|
||||
const [focusOnHostOpen, setFocusOnHostOpenRaw] = useState<boolean>(() => readExternalMcpFocusOnHostOpen());
|
||||
const [sessionIdleTimeoutMinutes, setSessionIdleTimeoutRaw] = useState<number>(() => readSessionIdleTimeoutMinutes());
|
||||
const [silentSessions, setSilentSessionsRaw] = useState<boolean>(() => readExternalMcpSilentSessions());
|
||||
const [status, setStatus] = useState<ExternalMcpStatus | null>(null);
|
||||
const [selectedClient, setSelectedClient] = useState<ExternalMcpClient>("codex");
|
||||
const [codexStatus, setCodexStatus] = useState<ClientSetupStatus | null>(null);
|
||||
const [claudeStatus, setClaudeStatus] = useState<ClientSetupStatus | null>(null);
|
||||
const [grokStatus, setGrokStatus] = useState<ClientSetupStatus | null>(null);
|
||||
const [isRefreshing, setIsRefreshing] = useState(false);
|
||||
const [isAddingCodex, setIsAddingCodex] = useState(false);
|
||||
const [isAddingClaude, setIsAddingClaude] = useState(false);
|
||||
const [isAddingGrok, setIsAddingGrok] = useState(false);
|
||||
const [copied, setCopied] = useState<string | null>(null);
|
||||
const [actionMessage, setActionMessage] = useState<{ tone: "error" | "warning" | "success"; text: string } | null>(null);
|
||||
const bridgeUnavailableMessage = t("ai.externalMcp.bridgeUnavailable");
|
||||
|
||||
const pushConfig = useCallback((nextMode: ExternalMcpMode, nextIdle: number, nextSessionIdle: number) => {
|
||||
void getBridge()?.externalMcpSetConfig?.({
|
||||
mode: nextMode,
|
||||
idleTimeoutMinutes: nextIdle,
|
||||
sessionIdleTimeoutMinutes: nextSessionIdle,
|
||||
});
|
||||
}, []);
|
||||
|
||||
const setMode = useCallback((nextMode: ExternalMcpMode) => {
|
||||
const normalized = writeExternalMcpMode(nextMode);
|
||||
setModeRaw(normalized);
|
||||
pushConfig(normalized, idleTimeoutMinutes, sessionIdleTimeoutMinutes);
|
||||
}, [idleTimeoutMinutes, pushConfig, sessionIdleTimeoutMinutes]);
|
||||
|
||||
const setIdleTimeoutMinutes = useCallback((minutes: number) => {
|
||||
const normalized = writeExternalMcpIdleTimeoutMinutes(minutes);
|
||||
setIdleTimeoutRaw(normalized);
|
||||
pushConfig(mode, normalized, sessionIdleTimeoutMinutes);
|
||||
}, [mode, pushConfig, sessionIdleTimeoutMinutes]);
|
||||
|
||||
const setSessionIdleTimeoutMinutes = useCallback((minutes: number) => {
|
||||
const normalized = writeSessionIdleTimeoutMinutes(minutes);
|
||||
setSessionIdleTimeoutRaw(normalized);
|
||||
pushConfig(mode, idleTimeoutMinutes, normalized);
|
||||
}, [idleTimeoutMinutes, mode, pushConfig]);
|
||||
|
||||
const setFocusOnHostOpen = useCallback((nextFocusOnHostOpen: boolean) => {
|
||||
setFocusOnHostOpenRaw(nextFocusOnHostOpen);
|
||||
writeExternalMcpFocusOnHostOpen(nextFocusOnHostOpen);
|
||||
}, []);
|
||||
|
||||
const setSilentSessions = useCallback((nextSilentSessions: boolean) => {
|
||||
setSilentSessionsRaw(nextSilentSessions);
|
||||
writeExternalMcpSilentSessions(nextSilentSessions);
|
||||
}, []);
|
||||
|
||||
const refreshStatus = useCallback(async (options?: { quiet?: boolean; clients?: boolean }) => {
|
||||
const bridge = getBridge();
|
||||
const includeClients = options?.clients !== false;
|
||||
if (
|
||||
!bridge?.externalMcpGetStatus
|
||||
|| !bridge?.externalMcpCodexGetStatus
|
||||
|| !bridge?.externalMcpClaudeGetStatus
|
||||
|| !bridge?.externalMcpGrokGetStatus
|
||||
) {
|
||||
setStatus({
|
||||
ok: false,
|
||||
enabled,
|
||||
state: "unavailable",
|
||||
discoveryPath: null,
|
||||
launcherPath: null,
|
||||
exposedSessionCount: 0,
|
||||
// Bridge default when IPC is missing; keeps the permission row from
|
||||
// looking blank while Safety settings remain the source of truth.
|
||||
permissionMode: "confirm",
|
||||
error: bridgeUnavailableMessage,
|
||||
});
|
||||
const unavailableClientStatus: ClientSetupStatus = {
|
||||
ok: true,
|
||||
state: "error",
|
||||
launcherPath: null,
|
||||
command: "",
|
||||
existingCommand: null,
|
||||
error: bridgeUnavailableMessage,
|
||||
};
|
||||
setCodexStatus(unavailableClientStatus);
|
||||
setClaudeStatus(unavailableClientStatus);
|
||||
setGrokStatus(unavailableClientStatus);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!options?.quiet) setIsRefreshing(true);
|
||||
try {
|
||||
if (includeClients) {
|
||||
const [nextStatus, nextCodexStatus, nextClaudeStatus, nextGrokStatus] = await Promise.all([
|
||||
bridge.externalMcpGetStatus(),
|
||||
bridge.externalMcpCodexGetStatus(),
|
||||
bridge.externalMcpClaudeGetStatus(),
|
||||
bridge.externalMcpGrokGetStatus(),
|
||||
]);
|
||||
setStatus(nextStatus as ExternalMcpStatus);
|
||||
if (enabled && nextStatus?.ok && !nextStatus.enabled) {
|
||||
setEnabled(false);
|
||||
}
|
||||
setCodexStatus(nextCodexStatus as ClientSetupStatus);
|
||||
setClaudeStatus(nextClaudeStatus as ClientSetupStatus);
|
||||
setGrokStatus(nextGrokStatus as ClientSetupStatus);
|
||||
} else {
|
||||
const nextStatus = await bridge.externalMcpGetStatus();
|
||||
setStatus(nextStatus as ExternalMcpStatus);
|
||||
if (enabled && nextStatus?.ok && !nextStatus.enabled) {
|
||||
setEnabled(false);
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
if (!options?.quiet) setIsRefreshing(false);
|
||||
}
|
||||
}, [bridgeUnavailableMessage, enabled, setEnabled]);
|
||||
|
||||
useEffect(() => {
|
||||
void refreshStatus();
|
||||
}, [refreshStatus]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!enabled) return;
|
||||
// Quiet polling only refreshes bridge runtime status. Spawning Codex/Claude/Grok
|
||||
// CLIs every few seconds is too expensive for a settings page keep-alive.
|
||||
const intervalId = window.setInterval(() => {
|
||||
void refreshStatus({ quiet: true, clients: false });
|
||||
}, 3000);
|
||||
return () => window.clearInterval(intervalId);
|
||||
}, [enabled, refreshStatus]);
|
||||
|
||||
useEffect(() => {
|
||||
pushConfig(mode, idleTimeoutMinutes, sessionIdleTimeoutMinutes);
|
||||
}, []); // eslint-disable-line react-hooks/exhaustive-deps -- sync stored config once on mount
|
||||
|
||||
const bridgeStatusView = useMemo(() => getBridgeStatusView(status, enabled), [enabled, status]);
|
||||
const exposedSessionCount = enabled ? status?.exposedSessionCount ?? 0 : 0;
|
||||
const codexStatusView = useMemo(() => getCodexStatusView(codexStatus), [codexStatus]);
|
||||
const claudeStatusView = useMemo(() => getClaudeStatusView(claudeStatus), [claudeStatus]);
|
||||
const grokStatusView = useMemo(() => getGrokStatusView(grokStatus), [grokStatus]);
|
||||
|
||||
const launcherPath = status?.launcherPath
|
||||
|| codexStatus?.launcherPath
|
||||
|| claudeStatus?.launcherPath
|
||||
|| grokStatus?.launcherPath
|
||||
|| null;
|
||||
const discoveryPath = status?.discoveryPath || null;
|
||||
// Prefer backend status.command so desktop-resolved absolute CLI paths
|
||||
// (outside PATH) survive into the copyable setup command.
|
||||
const codexCommand = (codexStatus?.command || "").trim()
|
||||
|| (launcherPath ? formatCodexAddCommand(launcherPath, discoveryPath) : "");
|
||||
const claudeCommand = (claudeStatus?.command || "").trim()
|
||||
|| (launcherPath ? formatClaudeAddCommand(launcherPath, discoveryPath) : "");
|
||||
const grokCommand = (grokStatus?.command || "").trim()
|
||||
|| (launcherPath ? formatGrokAddCommand(launcherPath, discoveryPath) : "");
|
||||
const codexTomlSnippet = launcherPath ? buildCodexTomlSnippet(launcherPath, discoveryPath) : "";
|
||||
const grokTomlSnippet = launcherPath ? buildGrokTomlSnippet(launcherPath, discoveryPath) : "";
|
||||
const claudeSnippet = launcherPath ? buildClaudeSnippet(launcherPath, discoveryPath) : "";
|
||||
const cursorSnippet = launcherPath ? buildCursorSnippet(launcherPath, discoveryPath) : "";
|
||||
const canAddToCodex = codexStatus?.state === "not_configured";
|
||||
const canAddToClaude = claudeStatus?.state === "not_configured";
|
||||
const canAddToGrok = grokStatus?.state === "not_configured";
|
||||
|
||||
const copyText = useCallback(async (key: string, text: string) => {
|
||||
if (!text) return;
|
||||
try {
|
||||
await navigator.clipboard.writeText(text);
|
||||
setCopied(key);
|
||||
window.setTimeout(() => {
|
||||
setCopied((current) => (current === key ? null : current));
|
||||
}, 1200);
|
||||
} catch {
|
||||
setActionMessage({ tone: "error", text: t("ai.externalMcp.copyFailed") });
|
||||
}
|
||||
}, [t]);
|
||||
|
||||
const handleAddToCodex = useCallback(async () => {
|
||||
const bridge = getBridge();
|
||||
if (!bridge?.externalMcpCodexAdd) return;
|
||||
setActionMessage(null);
|
||||
setIsAddingCodex(true);
|
||||
try {
|
||||
const result = await bridge.externalMcpCodexAdd() as ClientSetupStatus;
|
||||
setCodexStatus(result);
|
||||
if (result.state === "configured") {
|
||||
setActionMessage({ tone: "success", text: t("ai.externalMcp.codexAdded") });
|
||||
} else if (result.state === "codex_not_found") {
|
||||
setActionMessage({ tone: "warning", text: t("ai.externalMcp.installCodex") });
|
||||
} else if (result.state === "conflict") {
|
||||
setActionMessage({ tone: "error", text: t("ai.externalMcp.conflict.description") });
|
||||
} else if (result.state === "error" && result.error) {
|
||||
setActionMessage({ tone: "error", text: result.error });
|
||||
}
|
||||
await refreshStatus({ quiet: true });
|
||||
} finally {
|
||||
setIsAddingCodex(false);
|
||||
}
|
||||
}, [refreshStatus, t]);
|
||||
|
||||
const handleAddToClaude = useCallback(async () => {
|
||||
const bridge = getBridge();
|
||||
if (!bridge?.externalMcpClaudeAdd) return;
|
||||
setActionMessage(null);
|
||||
setIsAddingClaude(true);
|
||||
try {
|
||||
const result = await bridge.externalMcpClaudeAdd() as ClientSetupStatus;
|
||||
setClaudeStatus(result);
|
||||
if (result.state === "configured") {
|
||||
setActionMessage({ tone: "success", text: t("ai.externalMcp.claudeAdded") });
|
||||
} else if (result.state === "claude_not_found") {
|
||||
setActionMessage({ tone: "warning", text: t("ai.externalMcp.installClaude") });
|
||||
} else if (result.state === "conflict") {
|
||||
setActionMessage({ tone: "error", text: t("ai.externalMcp.conflict.description") });
|
||||
} else if (result.state === "error" && result.error) {
|
||||
setActionMessage({ tone: "error", text: result.error });
|
||||
}
|
||||
await refreshStatus({ quiet: true });
|
||||
} finally {
|
||||
setIsAddingClaude(false);
|
||||
}
|
||||
}, [refreshStatus, t]);
|
||||
|
||||
const handleAddToGrok = useCallback(async () => {
|
||||
const bridge = getBridge();
|
||||
if (!bridge?.externalMcpGrokAdd) return;
|
||||
setActionMessage(null);
|
||||
setIsAddingGrok(true);
|
||||
try {
|
||||
const result = await bridge.externalMcpGrokAdd() as ClientSetupStatus;
|
||||
setGrokStatus(result);
|
||||
if (result.state === "configured") {
|
||||
setActionMessage({ tone: "success", text: t("ai.externalMcp.grokAdded") });
|
||||
} else if (result.state === "grok_not_found") {
|
||||
setActionMessage({ tone: "warning", text: t("ai.externalMcp.installGrok") });
|
||||
} else if (result.state === "conflict") {
|
||||
setActionMessage({ tone: "error", text: t("ai.externalMcp.conflict.description") });
|
||||
} else if (result.state === "error" && result.error) {
|
||||
setActionMessage({ tone: "error", text: result.error });
|
||||
}
|
||||
await refreshStatus({ quiet: true });
|
||||
} finally {
|
||||
setIsAddingGrok(false);
|
||||
}
|
||||
}, [refreshStatus, t]);
|
||||
|
||||
const selectedClientMeta = useMemo(() => {
|
||||
if (selectedClient === "cursor") {
|
||||
return {
|
||||
kind: "snippet" as const,
|
||||
statusView: null as StatusView | null,
|
||||
command: "",
|
||||
snippet: cursorSnippet,
|
||||
canAdd: false,
|
||||
isAdding: false,
|
||||
addLabelKey: "",
|
||||
onAdd: null as (() => void) | null,
|
||||
};
|
||||
}
|
||||
if (selectedClient === "claude") {
|
||||
return {
|
||||
kind: "installable" as const,
|
||||
statusView: claudeStatusView,
|
||||
command: claudeCommand,
|
||||
snippet: claudeSnippet,
|
||||
canAdd: canAddToClaude,
|
||||
isAdding: isAddingClaude,
|
||||
addLabelKey: "ai.externalMcp.addToClaude",
|
||||
onAdd: () => { void handleAddToClaude(); },
|
||||
};
|
||||
}
|
||||
if (selectedClient === "grok") {
|
||||
return {
|
||||
kind: "installable" as const,
|
||||
statusView: grokStatusView,
|
||||
command: grokCommand,
|
||||
snippet: grokTomlSnippet,
|
||||
canAdd: canAddToGrok,
|
||||
isAdding: isAddingGrok,
|
||||
addLabelKey: "ai.externalMcp.addToGrok",
|
||||
onAdd: () => { void handleAddToGrok(); },
|
||||
};
|
||||
}
|
||||
return {
|
||||
kind: "installable" as const,
|
||||
statusView: codexStatusView,
|
||||
command: codexCommand,
|
||||
snippet: codexTomlSnippet,
|
||||
canAdd: canAddToCodex,
|
||||
isAdding: isAddingCodex,
|
||||
addLabelKey: "ai.externalMcp.addToCodex",
|
||||
onAdd: () => { void handleAddToCodex(); },
|
||||
};
|
||||
}, [
|
||||
canAddToClaude,
|
||||
canAddToCodex,
|
||||
canAddToGrok,
|
||||
claudeCommand,
|
||||
claudeSnippet,
|
||||
claudeStatusView,
|
||||
codexCommand,
|
||||
codexStatusView,
|
||||
codexTomlSnippet,
|
||||
cursorSnippet,
|
||||
grokCommand,
|
||||
grokStatusView,
|
||||
grokTomlSnippet,
|
||||
handleAddToClaude,
|
||||
handleAddToCodex,
|
||||
handleAddToGrok,
|
||||
isAddingClaude,
|
||||
isAddingCodex,
|
||||
isAddingGrok,
|
||||
selectedClient,
|
||||
]);
|
||||
|
||||
return (
|
||||
<div className="rounded-lg border bg-card p-4 space-y-3">
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div className="min-w-0 flex items-start gap-1.5">
|
||||
<p className="min-w-0 text-xs text-muted-foreground leading-5">
|
||||
{t("ai.externalMcp.description")}
|
||||
</p>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
className="relative -top-px mt-0.5 flex h-5 w-5 shrink-0 items-center justify-center rounded-full text-muted-foreground transition-colors hover:bg-secondary hover:text-foreground"
|
||||
aria-label={t("ai.externalMcp.help.ariaLabel")}
|
||||
>
|
||||
<HelpCircle size={13} />
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent
|
||||
side="bottom"
|
||||
align="start"
|
||||
className="max-w-[320px] space-y-1.5 bg-popover text-popover-foreground border border-border px-3 py-2.5 text-left text-xs leading-relaxed shadow-md"
|
||||
>
|
||||
<div className="font-medium text-foreground">{t("ai.externalMcp.usage.title")}</div>
|
||||
<p>{t("ai.externalMcp.usage.keepRunning")}</p>
|
||||
<p>{t("ai.externalMcp.usage.localhost")}</p>
|
||||
<p>{t("ai.externalMcp.usage.permissions")}</p>
|
||||
<p>{t("ai.externalMcp.usage.capabilities")}</p>
|
||||
<div className="pt-1 font-medium text-foreground">{t("ai.externalMcp.security")}</div>
|
||||
<p>{t("ai.externalMcp.security.description")}</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
<div className={cn("text-xs font-medium shrink-0", bridgeStatusView.className)}>
|
||||
{t(bridgeStatusView.labelKey)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between gap-4 rounded-md border border-border/60 bg-background/70 px-3 py-2">
|
||||
<div className="min-w-0">
|
||||
<div className="text-sm font-medium">{t("ai.externalMcp.title")}</div>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
{t("ai.externalMcp.sessionsExposed", { count: String(exposedSessionCount) })}
|
||||
</div>
|
||||
</div>
|
||||
<Toggle
|
||||
checked={enabled}
|
||||
onChange={(nextEnabled) => {
|
||||
setActionMessage(null);
|
||||
setEnabled(nextEnabled);
|
||||
window.setTimeout(() => { void refreshStatus(); }, 0);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Permission mode is controlled in Safety settings; surface it here so External MCP
|
||||
users see why write tools may still prompt (confirm) or run freely (auto). */}
|
||||
<div className="flex items-start justify-between gap-4 rounded-md border border-border/60 bg-background/70 px-3 py-2">
|
||||
<div className="min-w-0 space-y-1">
|
||||
<div className="text-sm font-medium">{t("ai.externalMcp.permissionMode.label")}</div>
|
||||
<div className="text-xs text-muted-foreground leading-5">
|
||||
{t("ai.externalMcp.permissionMode.hint")}
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
className={cn(
|
||||
"shrink-0 text-xs font-medium text-right max-w-[12rem]",
|
||||
getPermissionModeToneClass(status?.permissionMode),
|
||||
)}
|
||||
data-testid="external-mcp-permission-mode"
|
||||
>
|
||||
{t(getPermissionModeLabelKey(status?.permissionMode))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<SettingCard divided className="rounded-md border-border/60 bg-background/70">
|
||||
<SettingRow
|
||||
label={t("ai.externalMcp.mode")}
|
||||
description={t("ai.externalMcp.mode.description")}
|
||||
>
|
||||
<Select
|
||||
value={mode}
|
||||
options={[
|
||||
{ value: "temporary", label: t("ai.externalMcp.mode.temporary") },
|
||||
{ value: "persistent", label: t("ai.externalMcp.mode.persistent") },
|
||||
]}
|
||||
onChange={(value) => setMode(value === "persistent" ? "persistent" : "temporary")}
|
||||
className="w-36"
|
||||
/>
|
||||
</SettingRow>
|
||||
{mode === "temporary" ? (
|
||||
<SettingRow
|
||||
label={t("ai.externalMcp.idleTimeout")}
|
||||
description={t("ai.externalMcp.idleTimeout.description")}
|
||||
>
|
||||
<div className="flex items-center gap-2 shrink-0">
|
||||
<input
|
||||
type="number"
|
||||
aria-label={t("ai.externalMcp.idleTimeout")}
|
||||
min={1}
|
||||
max={24 * 60}
|
||||
value={idleTimeoutMinutes}
|
||||
onChange={(event) => {
|
||||
const minutes = Number.parseInt(event.currentTarget.value, 10);
|
||||
if (!Number.isFinite(minutes)) return;
|
||||
setIdleTimeoutMinutes(minutes);
|
||||
}}
|
||||
className="w-20 rounded-md border border-border/60 bg-background px-2 py-1 text-sm"
|
||||
/>
|
||||
<span className="text-xs text-muted-foreground">{t("ai.externalMcp.idleTimeout.minutes")}</span>
|
||||
</div>
|
||||
</SettingRow>
|
||||
) : null}
|
||||
<SettingRow
|
||||
label={t("ai.externalMcp.focusOnHostOpen")}
|
||||
description={t("ai.externalMcp.focusOnHostOpen.description")}
|
||||
>
|
||||
<Toggle
|
||||
checked={focusOnHostOpen}
|
||||
onChange={setFocusOnHostOpen}
|
||||
ariaLabel={t("ai.externalMcp.focusOnHostOpen")}
|
||||
/>
|
||||
</SettingRow>
|
||||
<SettingRow
|
||||
label={t("ai.externalMcp.silentSessions")}
|
||||
description={t("ai.externalMcp.silentSessions.description")}
|
||||
>
|
||||
<Toggle
|
||||
checked={silentSessions}
|
||||
onChange={setSilentSessions}
|
||||
ariaLabel={t("ai.externalMcp.silentSessions")}
|
||||
/>
|
||||
</SettingRow>
|
||||
<SettingRow
|
||||
label={t("ai.externalMcp.sessionIdleTimeout")}
|
||||
description={t("ai.externalMcp.sessionIdleTimeout.description")}
|
||||
>
|
||||
<div className="flex items-center gap-2 shrink-0">
|
||||
<input
|
||||
type="number"
|
||||
aria-label={t("ai.externalMcp.sessionIdleTimeout")}
|
||||
min={1}
|
||||
max={24 * 60}
|
||||
value={sessionIdleTimeoutMinutes}
|
||||
onChange={(event) => {
|
||||
const minutes = Number.parseInt(event.currentTarget.value, 10);
|
||||
if (!Number.isFinite(minutes)) return;
|
||||
setSessionIdleTimeoutMinutes(minutes);
|
||||
}}
|
||||
className="w-20 rounded-md border border-border/60 bg-background px-2 py-1 text-sm"
|
||||
/>
|
||||
<span className="text-xs text-muted-foreground">{t("ai.externalMcp.idleTimeout.minutes")}</span>
|
||||
</div>
|
||||
</SettingRow>
|
||||
</SettingCard>
|
||||
|
||||
<div className="space-y-2">
|
||||
<div className="flex min-h-8 items-center justify-between gap-2">
|
||||
<div className="text-sm font-semibold text-foreground">{t("ai.externalMcp.discovery")}</div>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => void refreshStatus()}
|
||||
disabled={isRefreshing}
|
||||
>
|
||||
<RefreshCw size={14} className={cn("mr-1.5", isRefreshing && "animate-spin")} />
|
||||
{t("ai.externalMcp.refresh")}
|
||||
</Button>
|
||||
</div>
|
||||
<div className="space-y-2.5 rounded-md border border-border/60 bg-background/50 p-3">
|
||||
<CopyableCodeBlock
|
||||
label={t("ai.externalMcp.launcher")}
|
||||
value={launcherPath || ""}
|
||||
copyKey="launcher"
|
||||
copied={copied}
|
||||
onCopy={copyText}
|
||||
copyLabel={t("ai.externalMcp.copy")}
|
||||
copiedLabel={t("ai.externalMcp.copied")}
|
||||
emptyLabel={t("ai.externalMcp.unavailable")}
|
||||
/>
|
||||
<CopyableCodeBlock
|
||||
label={t("ai.externalMcp.discovery")}
|
||||
value={status?.discoveryPath || ""}
|
||||
copyKey="discovery"
|
||||
copied={copied}
|
||||
onCopy={copyText}
|
||||
copyLabel={t("ai.externalMcp.copy")}
|
||||
copiedLabel={t("ai.externalMcp.copied")}
|
||||
emptyLabel={t("ai.externalMcp.unavailable")}
|
||||
/>
|
||||
{!enabled ? (
|
||||
<p className="text-xs text-amber-500">{t("ai.externalMcp.enableForLauncher")}</p>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<div className="space-y-1">
|
||||
<div className="text-sm font-semibold text-foreground">
|
||||
{t("ai.externalMcp.clientConfiguration")}
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground leading-5">
|
||||
{t("ai.externalMcp.clientConfiguration.description")}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div
|
||||
role="tablist"
|
||||
aria-label={t("ai.externalMcp.clientConfiguration")}
|
||||
className="grid grid-cols-4 gap-1 rounded-md bg-muted p-1"
|
||||
>
|
||||
{CLIENT_TABS.map((client) => {
|
||||
const active = selectedClient === client;
|
||||
return (
|
||||
<button
|
||||
key={client}
|
||||
type="button"
|
||||
role="tab"
|
||||
aria-selected={active}
|
||||
onClick={() => {
|
||||
setSelectedClient(client);
|
||||
setActionMessage(null);
|
||||
}}
|
||||
className={cn(
|
||||
"inline-flex h-8 items-center justify-center rounded-sm px-2 text-xs font-medium transition-colors",
|
||||
"focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring",
|
||||
active
|
||||
? "bg-background text-foreground shadow-sm"
|
||||
: "text-muted-foreground hover:text-foreground",
|
||||
)}
|
||||
>
|
||||
{t(`ai.externalMcp.client.${client}`)}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
<div className="space-y-3 rounded-md border border-border/60 bg-background/50 p-3">
|
||||
{selectedClientMeta.kind === "installable" && selectedClientMeta.statusView ? (
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div className={cn("text-xs font-medium", selectedClientMeta.statusView.className)}>
|
||||
{t(selectedClientMeta.statusView.labelKey)}
|
||||
</div>
|
||||
<Button
|
||||
size="sm"
|
||||
disabled={
|
||||
!selectedClientMeta.canAdd
|
||||
|| selectedClientMeta.isAdding
|
||||
|| !enabled
|
||||
|| !launcherPath
|
||||
}
|
||||
onClick={() => selectedClientMeta.onAdd?.()}
|
||||
>
|
||||
{t(selectedClientMeta.addLabelKey)}
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-xs text-muted-foreground leading-5">
|
||||
{t("ai.externalMcp.cursor.description")}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{selectedClientMeta.kind === "installable" ? (
|
||||
<>
|
||||
<CopyableCodeBlock
|
||||
label={t("ai.externalMcp.cliCommand")}
|
||||
value={selectedClientMeta.command}
|
||||
copyKey="command"
|
||||
copied={copied}
|
||||
onCopy={copyText}
|
||||
copyLabel={t("ai.externalMcp.copy")}
|
||||
copiedLabel={t("ai.externalMcp.copied")}
|
||||
emptyLabel={t("ai.externalMcp.unavailable")}
|
||||
/>
|
||||
<CopyableCodeBlock
|
||||
label={t("ai.externalMcp.configSnippet")}
|
||||
value={selectedClientMeta.snippet}
|
||||
copyKey="snippet"
|
||||
copied={copied}
|
||||
onCopy={copyText}
|
||||
copyLabel={t("ai.externalMcp.copy")}
|
||||
copiedLabel={t("ai.externalMcp.copied")}
|
||||
emptyLabel={t("ai.externalMcp.unavailable")}
|
||||
/>
|
||||
</>
|
||||
) : (
|
||||
<CopyableCodeBlock
|
||||
label={t("ai.externalMcp.configSnippet")}
|
||||
value={selectedClientMeta.snippet}
|
||||
copyKey="cursor"
|
||||
copied={copied}
|
||||
onCopy={copyText}
|
||||
copyLabel={t("ai.externalMcp.copy")}
|
||||
copiedLabel={t("ai.externalMcp.copied")}
|
||||
emptyLabel={t("ai.externalMcp.unavailable")}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{actionMessage ? (
|
||||
<div
|
||||
className={cn(
|
||||
"text-xs",
|
||||
actionMessage.tone === "success" && "text-emerald-500",
|
||||
actionMessage.tone === "warning" && "text-amber-500",
|
||||
actionMessage.tone === "error" && "text-destructive",
|
||||
)}
|
||||
>
|
||||
{actionMessage.text}
|
||||
</div>
|
||||
) : null}
|
||||
{status?.error ? (
|
||||
<div className="text-xs text-destructive">{status.error}</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
310
components/settings/tabs/ai/ModelSelector.tsx
Normal file
310
components/settings/tabs/ai/ModelSelector.tsx
Normal file
@@ -0,0 +1,310 @@
|
||||
import React, { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { Check, ChevronDown, RefreshCw } from "lucide-react";
|
||||
import type { AIProviderId, ProviderStyle } from "../../../../infrastructure/ai/types";
|
||||
import { resolveProviderStyle } from "../../../../infrastructure/ai/types";
|
||||
import { buildModelDiscoveryHeaders, resolveModelsDiscoveryEndpoint } from "../../../../infrastructure/ai/modelDiscoveryHeaders";
|
||||
import { buildProviderProbeUrl } from "../../../../infrastructure/ai/providerConnectionProbe";
|
||||
import { useI18n } from "../../../../application/i18n/I18nProvider";
|
||||
import { Button } from "../../../ui/button";
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from "../../../ui/tooltip";
|
||||
import { cn } from "../../../../lib/utils";
|
||||
import type { FetchedModel } from "./types";
|
||||
import { getFetchBridge } from "./types";
|
||||
import { parseFetchedModels } from "./modelMetadata";
|
||||
|
||||
export function buildModelSuggestions({
|
||||
presetModels,
|
||||
fetchedModels,
|
||||
hasFetched,
|
||||
value,
|
||||
}: {
|
||||
presetModels?: readonly string[];
|
||||
fetchedModels: FetchedModel[];
|
||||
hasFetched: boolean;
|
||||
value: string;
|
||||
}): FetchedModel[] {
|
||||
const byId = new Map<string, FetchedModel>();
|
||||
for (const modelId of presetModels ?? []) {
|
||||
const id = modelId.trim();
|
||||
if (id) byId.set(id, { id });
|
||||
}
|
||||
if (hasFetched) {
|
||||
for (const model of fetchedModels) {
|
||||
byId.set(model.id, model);
|
||||
}
|
||||
}
|
||||
|
||||
const allSuggestions = Array.from(byId.values());
|
||||
if (!value.trim()) return allSuggestions;
|
||||
const q = value.toLowerCase();
|
||||
return allSuggestions.filter((m) =>
|
||||
m.id.toLowerCase().includes(q) || (m.name && m.name.toLowerCase().includes(q)),
|
||||
);
|
||||
}
|
||||
|
||||
export function getModelSuggestionsPresentation({
|
||||
suggestionsLength,
|
||||
isLoading,
|
||||
error,
|
||||
hasFetched,
|
||||
hasPresetModels,
|
||||
}: {
|
||||
suggestionsLength: number;
|
||||
isLoading: boolean;
|
||||
error: string | null;
|
||||
hasFetched: boolean;
|
||||
hasPresetModels: boolean;
|
||||
}): {
|
||||
showSuggestions: boolean;
|
||||
emptyState: "loading" | "error" | "noMatches" | "loadPrompt" | null;
|
||||
footerState: "loading" | "error" | null;
|
||||
} {
|
||||
if (suggestionsLength > 0) {
|
||||
return {
|
||||
showSuggestions: true,
|
||||
emptyState: null,
|
||||
footerState: isLoading ? "loading" : error ? "error" : null,
|
||||
};
|
||||
}
|
||||
|
||||
if (isLoading) {
|
||||
return { showSuggestions: false, emptyState: "loading", footerState: null };
|
||||
}
|
||||
if (error) {
|
||||
return { showSuggestions: false, emptyState: "error", footerState: null };
|
||||
}
|
||||
return {
|
||||
showSuggestions: false,
|
||||
emptyState: hasFetched || hasPresetModels ? "noMatches" : "loadPrompt",
|
||||
footerState: null,
|
||||
};
|
||||
}
|
||||
|
||||
export function getModelSuggestionClassName(isSelected: boolean): string {
|
||||
return cn(
|
||||
"w-full text-left px-3 py-1.5 text-xs hover:bg-accent hover:text-accent-foreground transition-colors flex items-center justify-between gap-2",
|
||||
isSelected && "bg-accent text-accent-foreground",
|
||||
);
|
||||
}
|
||||
|
||||
export const ModelSelector: React.FC<{
|
||||
value: string;
|
||||
onChange: (value: string) => void;
|
||||
baseURL: string;
|
||||
modelsEndpoint?: string;
|
||||
presetModels?: readonly string[];
|
||||
placeholder?: string;
|
||||
apiKey?: string;
|
||||
providerId?: AIProviderId;
|
||||
/** Optional protocol-family override; falls back to `providerId` via {@link resolveProviderStyle}. */
|
||||
style?: ProviderStyle;
|
||||
skipTLSVerify?: boolean;
|
||||
onModelMetadata?: (model: FetchedModel) => void;
|
||||
}> = ({ value, onChange, baseURL, modelsEndpoint, presetModels, placeholder, apiKey, providerId, style, skipTLSVerify, onModelMetadata }) => {
|
||||
const { t } = useI18n();
|
||||
const [models, setModels] = useState<FetchedModel[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const [hasFetched, setHasFetched] = useState(false);
|
||||
|
||||
// Resolve the wire-protocol family: prefer an explicit style override (set in
|
||||
// the form), then fall back to the providerId-derived default.
|
||||
const resolvedStyle: ProviderStyle = style
|
||||
?? (providerId ? resolveProviderStyle({ providerId }) : "openai");
|
||||
// Endpoint follows the resolved style so a providerId+style mismatch (e.g.
|
||||
// Anthropic providerId switched to OpenAI style) still hits the right path.
|
||||
const effectiveModelsEndpoint = resolveModelsDiscoveryEndpoint(resolvedStyle, modelsEndpoint);
|
||||
// Ollama runs locally without auth; all other providers need an API key to list models
|
||||
const needsApiKey = providerId !== "ollama";
|
||||
const canFetch = !!effectiveModelsEndpoint && (!needsApiKey || !!apiKey);
|
||||
const hasPresetModels = (presetModels?.length ?? 0) > 0;
|
||||
const canSuggest = canFetch || hasPresetModels;
|
||||
const discoveryKey = JSON.stringify({
|
||||
baseURL,
|
||||
effectiveModelsEndpoint,
|
||||
apiKey,
|
||||
resolvedStyle,
|
||||
skipTLSVerify,
|
||||
});
|
||||
const discoveryKeyRef = useRef(discoveryKey);
|
||||
|
||||
useEffect(() => {
|
||||
discoveryKeyRef.current = discoveryKey;
|
||||
setModels([]);
|
||||
setHasFetched(false);
|
||||
setError(null);
|
||||
setIsLoading(false);
|
||||
}, [discoveryKey]);
|
||||
|
||||
const fetchModels = useCallback(async () => {
|
||||
if (!effectiveModelsEndpoint) return;
|
||||
const bridge = getFetchBridge();
|
||||
if (!bridge?.aiFetch) return;
|
||||
const requestKey = discoveryKey;
|
||||
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
// Temporarily allow the provider's host in the backend fetch allowlist
|
||||
// so model listing works for URLs not yet synced from the main window.
|
||||
if (bridge.aiAllowlistAddHost && baseURL) {
|
||||
await bridge.aiAllowlistAddHost(baseURL);
|
||||
}
|
||||
const url = buildProviderProbeUrl(baseURL, effectiveModelsEndpoint);
|
||||
const headers = buildModelDiscoveryHeaders(resolvedStyle, apiKey);
|
||||
const result = await bridge.aiFetch(url, "GET", headers, undefined, undefined, undefined, undefined, skipTLSVerify);
|
||||
if (!result.ok) {
|
||||
if (discoveryKeyRef.current !== requestKey) return;
|
||||
setError(`Failed to fetch models (${result.error || "unknown error"})`);
|
||||
return;
|
||||
}
|
||||
const parsed = JSON.parse(result.data);
|
||||
const list = parseFetchedModels(parsed);
|
||||
list.sort((a, b) => (a.name || a.id).localeCompare(b.name || b.id));
|
||||
if (discoveryKeyRef.current !== requestKey) return;
|
||||
setModels(list);
|
||||
setHasFetched(true);
|
||||
} catch (err) {
|
||||
if (discoveryKeyRef.current !== requestKey) return;
|
||||
setError(err instanceof Error ? err.message : "Failed to parse response");
|
||||
} finally {
|
||||
if (discoveryKeyRef.current === requestKey) setIsLoading(false);
|
||||
}
|
||||
}, [baseURL, effectiveModelsEndpoint, apiKey, resolvedStyle, skipTLSVerify, discoveryKey]);
|
||||
|
||||
// Auto-fetch when dropdown first opens
|
||||
useEffect(() => {
|
||||
if (isOpen && canFetch && !hasFetched && !isLoading) {
|
||||
void fetchModels();
|
||||
}
|
||||
}, [isOpen, canFetch, hasFetched, isLoading, fetchModels]);
|
||||
|
||||
// Filter preset and discovered models by current input value (inline autocomplete).
|
||||
const suggestions = useMemo(() => {
|
||||
return buildModelSuggestions({
|
||||
presetModels,
|
||||
fetchedModels: models,
|
||||
hasFetched,
|
||||
value,
|
||||
});
|
||||
}, [models, presetModels, value, hasFetched]);
|
||||
|
||||
const showSuggestions = isOpen && canSuggest;
|
||||
const presentation = getModelSuggestionsPresentation({
|
||||
suggestionsLength: suggestions.length,
|
||||
isLoading,
|
||||
error,
|
||||
hasFetched,
|
||||
hasPresetModels,
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="relative">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="relative flex-1">
|
||||
<input
|
||||
type="text"
|
||||
value={value}
|
||||
onChange={(e) => {
|
||||
onChange(e.target.value);
|
||||
if (canSuggest && !isOpen) setIsOpen(true);
|
||||
}}
|
||||
onFocus={() => { if (canSuggest) setIsOpen(true); }}
|
||||
onBlur={() => { setIsOpen(false); }}
|
||||
placeholder={placeholder ?? (canSuggest ? t('ai.providers.searchModel') : t('ai.providers.defaultModel.placeholder'))}
|
||||
className={cn(
|
||||
"w-full h-8 rounded-md border border-input bg-background px-3 text-sm placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring",
|
||||
canSuggest && "pr-8",
|
||||
)}
|
||||
/>
|
||||
{canSuggest && (
|
||||
<button
|
||||
type="button"
|
||||
onMouseDown={(e) => { e.preventDefault(); setIsOpen(!isOpen); }}
|
||||
className="absolute right-2 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
<ChevronDown size={14} className={cn("transition-transform", isOpen && "rotate-180")} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
{canFetch && (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => { setHasFetched(false); void fetchModels(); }}
|
||||
disabled={isLoading}
|
||||
className="shrink-0 px-2"
|
||||
>
|
||||
<RefreshCw size={14} className={isLoading ? "animate-spin" : ""} />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>{t('ai.providers.refreshModels')}</TooltipContent>
|
||||
</Tooltip>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Suggestions dropdown */}
|
||||
{showSuggestions && (
|
||||
<div className="absolute top-full left-0 right-0 mt-1 z-[101] rounded-md border border-border bg-popover shadow-md">
|
||||
<div className="max-h-60 overflow-y-auto">
|
||||
{!presentation.showSuggestions ? (
|
||||
<div className="px-3 py-3 text-center text-xs text-muted-foreground">
|
||||
{presentation.emptyState === "loading" ? (
|
||||
<>
|
||||
<RefreshCw size={14} className="animate-spin inline mr-1.5" />
|
||||
{t('ai.providers.loadingModels')}
|
||||
</>
|
||||
) : presentation.emptyState === "error" ? (
|
||||
<span className="text-destructive">{error}</span>
|
||||
) : presentation.emptyState === "noMatches" ? (
|
||||
t('ai.providers.noMatchingModels')
|
||||
) : (
|
||||
t('ai.providers.clickToLoadModels')
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
suggestions.slice(0, 100).map((m) => (
|
||||
<button
|
||||
key={m.id}
|
||||
onMouseDown={(e) => {
|
||||
e.preventDefault();
|
||||
onChange(m.id);
|
||||
onModelMetadata?.(m);
|
||||
setIsOpen(false);
|
||||
}}
|
||||
className={getModelSuggestionClassName(m.id === value)}
|
||||
>
|
||||
<span className="font-mono truncate">{m.id}</span>
|
||||
{m.id === value && <Check size={12} className="text-accent-foreground shrink-0" />}
|
||||
</button>
|
||||
))
|
||||
)}
|
||||
{presentation.footerState && (
|
||||
<div className={cn(
|
||||
"px-3 py-2 text-center text-[10px] border-t border-border/40",
|
||||
presentation.footerState === "error" ? "text-destructive" : "text-muted-foreground",
|
||||
)}>
|
||||
{presentation.footerState === "loading" ? (
|
||||
<>
|
||||
<RefreshCw size={12} className="animate-spin inline mr-1" />
|
||||
{t('ai.providers.loadingModels')}
|
||||
</>
|
||||
) : (
|
||||
error
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{suggestions.length > 100 && (
|
||||
<div className="px-3 py-2 text-center text-[10px] text-muted-foreground border-t border-border/40">
|
||||
{t('ai.providers.showingModels').replace('{count}', String(suggestions.length))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
255
components/settings/tabs/ai/PermissionGrantsSettings.tsx
Normal file
255
components/settings/tabs/ai/PermissionGrantsSettings.tsx
Normal file
@@ -0,0 +1,255 @@
|
||||
import React, { useCallback, useMemo, useRef, useState } from 'react';
|
||||
import { Download, Plus, Trash2, Upload } from 'lucide-react';
|
||||
import { useI18n } from '../../../../application/i18n/I18nProvider';
|
||||
import { Button } from '../../../ui/button';
|
||||
import { SettingCard, SettingsSection } from '../../settings-ui';
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '../../../ui/tooltip';
|
||||
import type { PermissionGrantRule } from '../../../../infrastructure/ai/harness/permissionGrants';
|
||||
import {
|
||||
capabilitySupportsCommandPatternGrant,
|
||||
createPermissionGrantId,
|
||||
listGrantableCapabilityIds,
|
||||
} from '../../../../infrastructure/ai/harness/permissionGrants';
|
||||
|
||||
const cellInputClass =
|
||||
'w-full min-w-0 max-w-full h-7 rounded border border-input bg-background px-2 text-xs font-mono focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring overflow-x-auto whitespace-nowrap scrollbar-thin';
|
||||
|
||||
const cellSelectClass =
|
||||
`${cellInputClass} font-sans truncate pr-6`;
|
||||
|
||||
const GrantCellInput: React.FC<{
|
||||
value: string;
|
||||
placeholder?: string;
|
||||
mono?: boolean;
|
||||
onChange: (value: string) => void;
|
||||
}> = ({ value, placeholder, mono = true, onChange }) => (
|
||||
<input
|
||||
type="text"
|
||||
value={value}
|
||||
placeholder={placeholder}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
className={mono ? cellInputClass : `${cellInputClass} font-sans whitespace-normal`}
|
||||
title={value}
|
||||
/>
|
||||
);
|
||||
|
||||
const GrantCapabilitySelect: React.FC<{
|
||||
value: string;
|
||||
options: readonly string[];
|
||||
onChange: (value: string) => void;
|
||||
}> = ({ value, options, onChange }) => {
|
||||
const selectOptions = useMemo(() => {
|
||||
if (options.includes(value)) return options;
|
||||
return [value, ...options];
|
||||
}, [options, value]);
|
||||
|
||||
return (
|
||||
<select
|
||||
value={value}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
className={cellSelectClass}
|
||||
title={value}
|
||||
>
|
||||
{selectOptions.map((capabilityId) => (
|
||||
<option key={capabilityId} value={capabilityId}>
|
||||
{capabilityId}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
);
|
||||
};
|
||||
|
||||
export const PermissionGrantsSettings: React.FC<{
|
||||
grants: PermissionGrantRule[];
|
||||
addGrant: (rule: PermissionGrantRule) => void;
|
||||
updateGrant: (id: string, updates: Partial<Omit<PermissionGrantRule, 'id' | 'createdAt'>>) => void;
|
||||
removeGrant: (id: string) => void;
|
||||
importGrants: (raw: unknown, mode?: 'merge' | 'replace') => void;
|
||||
exportGrants: () => PermissionGrantRule[];
|
||||
}> = ({
|
||||
grants,
|
||||
addGrant,
|
||||
updateGrant,
|
||||
removeGrant,
|
||||
importGrants,
|
||||
exportGrants,
|
||||
}) => {
|
||||
const { t } = useI18n();
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
const [importError, setImportError] = useState<string | null>(null);
|
||||
const grantableCapabilityIds = useMemo(() => listGrantableCapabilityIds(), []);
|
||||
|
||||
const handleAdd = useCallback(() => {
|
||||
addGrant({
|
||||
id: createPermissionGrantId(),
|
||||
capabilityId: grantableCapabilityIds[0] ?? 'terminal.execute',
|
||||
sessionPattern: '*',
|
||||
createdAt: Date.now(),
|
||||
});
|
||||
}, [addGrant, grantableCapabilityIds]);
|
||||
|
||||
const handleExport = useCallback(() => {
|
||||
const payload = exportGrants();
|
||||
const blob = new Blob([JSON.stringify(payload, null, 2)], { type: 'application/json' });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const anchor = document.createElement('a');
|
||||
anchor.href = url;
|
||||
anchor.download = 'netcatty-permission-grants.json';
|
||||
anchor.click();
|
||||
URL.revokeObjectURL(url);
|
||||
}, [exportGrants]);
|
||||
|
||||
const handleImportFile = useCallback(async (file: File) => {
|
||||
setImportError(null);
|
||||
try {
|
||||
const text = await file.text();
|
||||
const parsed = JSON.parse(text) as unknown;
|
||||
importGrants(parsed, 'replace');
|
||||
} catch (error) {
|
||||
setImportError(error instanceof Error ? error.message : String(error));
|
||||
}
|
||||
}, [importGrants]);
|
||||
|
||||
return (
|
||||
<SettingsSection title={t('ai.safety.grants.title')} anchorId="ai-safety-grants">
|
||||
<SettingCard padded className="space-y-3 min-w-0 max-w-full overflow-hidden">
|
||||
<div className="space-y-3">
|
||||
<div className="min-w-0">
|
||||
<p className="text-sm font-medium">{t('ai.safety.grants.heading')}</p>
|
||||
<p className="text-xs text-muted-foreground mt-1">{t('ai.safety.grants.description')}</p>
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center gap-1.5">
|
||||
<Button variant="outline" size="sm" className="h-7 text-xs" onClick={handleAdd}>
|
||||
<Plus size={14} className="mr-1" />
|
||||
{t('ai.safety.grants.add')}
|
||||
</Button>
|
||||
<Button variant="outline" size="sm" className="h-7 text-xs" onClick={handleExport}>
|
||||
<Download size={14} className="mr-1" />
|
||||
{t('ai.safety.grants.export')}
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="h-7 text-xs"
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
>
|
||||
<Upload size={14} className="mr-1" />
|
||||
{t('ai.safety.grants.import')}
|
||||
</Button>
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
accept="application/json,.json"
|
||||
className="hidden"
|
||||
onChange={(event) => {
|
||||
const file = event.target.files?.[0];
|
||||
event.target.value = '';
|
||||
if (file) void handleImportFile(file);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{importError && (
|
||||
<p className="text-[11px] text-destructive">{importError}</p>
|
||||
)}
|
||||
|
||||
{grants.length === 0 ? (
|
||||
<p className="text-xs text-muted-foreground py-6 text-center border border-dashed border-border/50 rounded-lg">
|
||||
{t('ai.safety.grants.empty')}
|
||||
</p>
|
||||
) : (
|
||||
<div className="w-full max-w-full min-w-0 overflow-x-auto overscroll-x-contain rounded-lg border border-border/40 bg-card">
|
||||
<table className="w-full max-w-full table-fixed text-sm border-collapse">
|
||||
<colgroup>
|
||||
<col className="w-[28%]" />
|
||||
<col className="w-[42%]" />
|
||||
<col className="w-[24%]" />
|
||||
<col className="w-[6%]" />
|
||||
</colgroup>
|
||||
<thead>
|
||||
<tr className="bg-muted/50 border-b border-border">
|
||||
<th className="text-left px-2 py-2 text-xs font-medium text-muted-foreground truncate">
|
||||
{t('ai.safety.grants.capability')}
|
||||
</th>
|
||||
<th className="text-left px-2 py-2 text-xs font-medium text-muted-foreground truncate">
|
||||
{t('ai.safety.grants.commandPattern')}
|
||||
</th>
|
||||
<th className="text-left px-2 py-2 text-xs font-medium text-muted-foreground truncate">
|
||||
{t('ai.safety.grants.note')}
|
||||
</th>
|
||||
<th className="px-1 py-2" aria-hidden />
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{grants.map((grant) => {
|
||||
const supportsCommandPattern = capabilitySupportsCommandPatternGrant(grant.capabilityId);
|
||||
|
||||
return (
|
||||
<tr
|
||||
key={grant.id}
|
||||
className="border-b border-border/60 last:border-b-0 hover:bg-muted/20"
|
||||
>
|
||||
<td className="px-2 py-2 align-middle max-w-0">
|
||||
<GrantCapabilitySelect
|
||||
value={grant.capabilityId}
|
||||
options={grantableCapabilityIds}
|
||||
onChange={(capabilityId) => {
|
||||
const updates: Partial<Omit<PermissionGrantRule, 'id' | 'createdAt'>> = {
|
||||
capabilityId,
|
||||
};
|
||||
if (!capabilitySupportsCommandPatternGrant(capabilityId)) {
|
||||
updates.commandPattern = undefined;
|
||||
}
|
||||
updateGrant(grant.id, updates);
|
||||
}}
|
||||
/>
|
||||
</td>
|
||||
<td className="px-2 py-2 align-middle max-w-0">
|
||||
{supportsCommandPattern ? (
|
||||
<GrantCellInput
|
||||
value={grant.commandPattern ?? ''}
|
||||
placeholder="lscpu *"
|
||||
onChange={(commandPattern) => updateGrant(grant.id, {
|
||||
commandPattern: commandPattern.trim() || undefined,
|
||||
})}
|
||||
/>
|
||||
) : (
|
||||
<span className="text-xs text-muted-foreground px-1">—</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-2 py-2 align-middle max-w-0">
|
||||
<GrantCellInput
|
||||
value={grant.note ?? ''}
|
||||
mono={false}
|
||||
onChange={(note) => updateGrant(grant.id, {
|
||||
note: note.trim() || undefined,
|
||||
})}
|
||||
/>
|
||||
</td>
|
||||
<td className="px-1 py-2 align-middle text-center">
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-7 w-7 text-muted-foreground hover:text-destructive hover:bg-destructive/10"
|
||||
onClick={() => removeGrant(grant.id)}
|
||||
>
|
||||
<Trash2 size={14} />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>{t('ai.safety.grants.remove')}</TooltipContent>
|
||||
</Tooltip>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</SettingCard>
|
||||
</SettingsSection>
|
||||
);
|
||||
};
|
||||
104
components/settings/tabs/ai/ProviderCard.tsx
Normal file
104
components/settings/tabs/ai/ProviderCard.tsx
Normal file
@@ -0,0 +1,104 @@
|
||||
import React from "react";
|
||||
import { Pencil, Trash2 } from "lucide-react";
|
||||
import type { ProviderConfig } from "../../../../infrastructure/ai/types";
|
||||
import { useI18n } from "../../../../application/i18n/I18nProvider";
|
||||
import { Toggle } from "../../settings-ui";
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from "../../../ui/tooltip";
|
||||
import { cn } from "../../../../lib/utils";
|
||||
import { ProviderIconBadge } from "./ProviderIconBadge";
|
||||
import { ProviderConfigForm } from "./ProviderConfigForm";
|
||||
|
||||
export const ProviderCard: React.FC<{
|
||||
provider: ProviderConfig;
|
||||
isActive: boolean;
|
||||
onToggleEnabled: (enabled: boolean) => void;
|
||||
onEdit: () => void;
|
||||
onRemove: () => void;
|
||||
onUpdate: (updates: Partial<ProviderConfig>) => void;
|
||||
isEditing: boolean;
|
||||
onCancelEdit: () => void;
|
||||
}> = ({ provider, isActive, onToggleEnabled, onEdit, onRemove, onUpdate, isEditing, onCancelEdit }) => {
|
||||
const { t } = useI18n();
|
||||
const hasApiKey = !!provider.apiKey;
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"rounded-lg border p-4 transition-colors",
|
||||
isActive ? "border-primary/50 bg-primary/5" : "border-border bg-card",
|
||||
)}
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
{/* Provider icon */}
|
||||
<ProviderIconBadge provider={provider} />
|
||||
|
||||
{/* Info */}
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm font-medium truncate">{provider.name}</span>
|
||||
{isActive && (
|
||||
<span className="text-[10px] px-1.5 py-0.5 rounded-full bg-primary/20 text-primary font-medium">
|
||||
{t('ai.providers.active')}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-2 mt-0.5">
|
||||
<span
|
||||
className={cn(
|
||||
"text-xs",
|
||||
hasApiKey ? "text-emerald-500" : "text-muted-foreground",
|
||||
)}
|
||||
>
|
||||
{hasApiKey ? t('ai.providers.apiKeyConfigured') : t('ai.providers.noApiKey')}
|
||||
</span>
|
||||
{provider.defaultModel && (
|
||||
<>
|
||||
<span className="text-muted-foreground text-xs">|</span>
|
||||
<span className="text-xs text-muted-foreground truncate">{provider.defaultModel}</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex items-center gap-1 shrink-0">
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
onClick={onEdit}
|
||||
className="p-1.5 rounded-md text-muted-foreground hover:text-foreground hover:bg-muted transition-colors"
|
||||
>
|
||||
<Pencil size={14} />
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>{t('ai.providers.configure')}</TooltipContent>
|
||||
</Tooltip>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
onClick={onRemove}
|
||||
className="p-1.5 rounded-md text-muted-foreground hover:text-destructive hover:bg-destructive/10 transition-colors"
|
||||
>
|
||||
<Trash2 size={14} />
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>{t('ai.providers.remove')}</TooltipContent>
|
||||
</Tooltip>
|
||||
<Toggle checked={provider.enabled} onChange={onToggleEnabled} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Expandable config form */}
|
||||
{isEditing && (
|
||||
<ProviderConfigForm
|
||||
provider={provider}
|
||||
onSave={(updates) => {
|
||||
onUpdate(updates);
|
||||
onCancelEdit();
|
||||
}}
|
||||
onCancel={onCancelEdit}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
749
components/settings/tabs/ai/ProviderConfigForm.tsx
Normal file
749
components/settings/tabs/ai/ProviderConfigForm.tsx
Normal file
@@ -0,0 +1,749 @@
|
||||
import React, { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { Check, ChevronDown, ChevronRight, Eye, EyeOff, Pencil, Upload, RotateCcw, X, RefreshCw } from "lucide-react";
|
||||
import type { ProviderConfig, ProviderAdvancedParams, OpenAIApiFormat, ProviderStyle } from "../../../../infrastructure/ai/types";
|
||||
import { PROVIDER_PRESETS, resolveOpenAIApi, resolveProviderStyle } from "../../../../infrastructure/ai/types";
|
||||
import { normalizeOllamaSdkBaseURL } from "../../../../infrastructure/ai/ollamaCompatBaseUrl";
|
||||
import { sanitizeContextWindow } from "../../../../infrastructure/ai/contextCompaction";
|
||||
import {
|
||||
probeProviderConnection,
|
||||
validateProviderProbeInputs,
|
||||
type ProviderProbeHealth,
|
||||
} from "../../../../infrastructure/ai/providerConnectionProbe";
|
||||
import { encryptField, decryptField } from "../../../../infrastructure/persistence/secureFieldAdapter";
|
||||
import { useI18n } from "../../../../application/i18n/I18nProvider";
|
||||
import { Button } from "../../../ui/button";
|
||||
import { cn } from "../../../../lib/utils";
|
||||
import type { BuiltinProviderIcon } from "./types";
|
||||
import { BUILTIN_PROVIDER_ICONS, getFetchBridge } from "./types";
|
||||
import type { ProviderFormState } from "./types";
|
||||
import { ModelSelector } from "./ModelSelector";
|
||||
import { mergeModelContextWindow } from "./modelMetadata";
|
||||
import { ProviderIconBadge } from "./ProviderIconBadge";
|
||||
|
||||
const ICON_PIXEL_SIZE = 64;
|
||||
const ICON_WEBP_QUALITY = 0.85;
|
||||
const MAX_UPLOAD_BYTES = 5 * 1024 * 1024;
|
||||
|
||||
async function compressIconFileToDataUrl(file: File): Promise<string> {
|
||||
if (file.size > MAX_UPLOAD_BYTES) {
|
||||
throw new Error("Image too large; please use an image under 5 MB.");
|
||||
}
|
||||
const sourceUrl = await new Promise<string>((resolve, reject) => {
|
||||
const reader = new FileReader();
|
||||
reader.onload = () => resolve(reader.result as string);
|
||||
reader.onerror = () => reject(reader.error ?? new Error("Failed to read file"));
|
||||
reader.readAsDataURL(file);
|
||||
});
|
||||
const img = await new Promise<HTMLImageElement>((resolve, reject) => {
|
||||
const el = new Image();
|
||||
el.onload = () => resolve(el);
|
||||
el.onerror = () => reject(new Error("Failed to decode image"));
|
||||
el.src = sourceUrl;
|
||||
});
|
||||
const canvas = document.createElement("canvas");
|
||||
canvas.width = ICON_PIXEL_SIZE;
|
||||
canvas.height = ICON_PIXEL_SIZE;
|
||||
const ctx = canvas.getContext("2d");
|
||||
if (!ctx) throw new Error("Canvas 2D context unavailable");
|
||||
ctx.clearRect(0, 0, ICON_PIXEL_SIZE, ICON_PIXEL_SIZE);
|
||||
const scale = Math.min(ICON_PIXEL_SIZE / img.width, ICON_PIXEL_SIZE / img.height);
|
||||
const w = img.width * scale;
|
||||
const h = img.height * scale;
|
||||
ctx.drawImage(img, (ICON_PIXEL_SIZE - w) / 2, (ICON_PIXEL_SIZE - h) / 2, w, h);
|
||||
return canvas.toDataURL("image/webp", ICON_WEBP_QUALITY);
|
||||
}
|
||||
|
||||
const STYLE_OPTIONS: ReadonlyArray<ProviderStyle> = ["anthropic", "openai", "google"];
|
||||
const OPENAI_API_OPTIONS: ReadonlyArray<OpenAIApiFormat> = ["chat", "responses"];
|
||||
|
||||
/** Same box as the h-8 fields above. Transparent border keeps primary aligned with outline. */
|
||||
const PROVIDER_ACTION_CLASS = "box-border h-8 px-3 gap-1.5 text-sm font-medium leading-none";
|
||||
|
||||
export const ProviderConfigForm: React.FC<{
|
||||
provider: ProviderConfig;
|
||||
onSave: (updates: Partial<ProviderConfig>) => void;
|
||||
onCancel: () => void;
|
||||
}> = ({ provider, onSave, onCancel }) => {
|
||||
const { t } = useI18n();
|
||||
const fileInputRef = useRef<HTMLInputElement | null>(null);
|
||||
|
||||
const [form, setForm] = useState<ProviderFormState>({
|
||||
name: provider.name ?? PROVIDER_PRESETS[provider.providerId]?.name ?? "",
|
||||
apiKey: "",
|
||||
baseURL: provider.baseURL ?? PROVIDER_PRESETS[provider.providerId]?.defaultBaseURL ?? "",
|
||||
defaultModel: provider.defaultModel ?? "",
|
||||
contextWindow: provider.contextWindow != null ? String(provider.contextWindow) : "",
|
||||
modelContextWindows: provider.modelContextWindows ?? {},
|
||||
skipTLSVerify: provider.skipTLSVerify ?? false,
|
||||
advancedParams: provider.advancedParams ?? {},
|
||||
style: provider.style ?? "",
|
||||
openaiApi: resolveOpenAIApi(provider),
|
||||
iconId: provider.iconId ?? "",
|
||||
iconDataUrl: provider.iconDataUrl ?? "",
|
||||
});
|
||||
const [showApiKey, setShowApiKey] = useState(false);
|
||||
const [isDecrypting, setIsDecrypting] = useState(false);
|
||||
const [showAdvanced, setShowAdvanced] = useState(false);
|
||||
const [showIconPicker, setShowIconPicker] = useState(false);
|
||||
const [iconError, setIconError] = useState<string | null>(null);
|
||||
const [contextWindowError, setContextWindowError] = useState<string | null>(null);
|
||||
const [apiKeySourceVersion, setApiKeySourceVersion] = useState(0);
|
||||
const [isTesting, setIsTesting] = useState(false);
|
||||
const [probeResult, setProbeResult] = useState<{
|
||||
health: ProviderProbeHealth;
|
||||
message: string;
|
||||
} | null>(null);
|
||||
const probeRequestIdRef = useRef(0);
|
||||
|
||||
const preset = PROVIDER_PRESETS[provider.providerId];
|
||||
const resolvedStyle: ProviderStyle = form.style || resolveProviderStyle({ providerId: provider.providerId });
|
||||
const resolvedBaseURL = provider.providerId === "ollama"
|
||||
? normalizeOllamaSdkBaseURL(form.baseURL || preset?.defaultBaseURL || "")
|
||||
: (form.baseURL || preset?.defaultBaseURL || "");
|
||||
const modelMetadataSourceKey = useMemo(() => JSON.stringify({
|
||||
providerId: provider.providerId,
|
||||
baseURL: form.baseURL || preset?.defaultBaseURL || "",
|
||||
modelsEndpoint: preset?.modelsEndpoint ?? "",
|
||||
apiKeySourceVersion,
|
||||
style: resolvedStyle,
|
||||
skipTLSVerify: form.skipTLSVerify,
|
||||
}), [
|
||||
provider.providerId,
|
||||
form.baseURL,
|
||||
apiKeySourceVersion,
|
||||
form.skipTLSVerify,
|
||||
preset?.defaultBaseURL,
|
||||
preset?.modelsEndpoint,
|
||||
resolvedStyle,
|
||||
]);
|
||||
const probeFingerprint = useMemo(() => JSON.stringify({
|
||||
baseURL: form.baseURL || preset?.defaultBaseURL || "",
|
||||
apiKey: form.apiKey,
|
||||
style: resolvedStyle,
|
||||
skipTLSVerify: form.skipTLSVerify,
|
||||
modelsEndpoint: preset?.modelsEndpoint ?? "",
|
||||
}), [
|
||||
form.apiKey,
|
||||
form.baseURL,
|
||||
form.skipTLSVerify,
|
||||
preset?.defaultBaseURL,
|
||||
preset?.modelsEndpoint,
|
||||
resolvedStyle,
|
||||
]);
|
||||
const modelMetadataSourceKeyRef = useRef<string | null>(null);
|
||||
const probeFingerprintRef = useRef<string | null>(null);
|
||||
const previewProvider: Pick<ProviderConfig, "providerId" | "name" | "iconId" | "iconDataUrl"> = {
|
||||
providerId: provider.providerId,
|
||||
name: form.name,
|
||||
iconId: form.iconId || undefined,
|
||||
iconDataUrl: form.iconDataUrl || undefined,
|
||||
};
|
||||
|
||||
// Decrypt and load existing API key on mount
|
||||
useEffect(() => {
|
||||
if (provider.apiKey) {
|
||||
setIsDecrypting(true);
|
||||
decryptField(provider.apiKey)
|
||||
.then((decrypted) => {
|
||||
setForm((prev) => ({ ...prev, apiKey: decrypted ?? "" }));
|
||||
})
|
||||
.catch(() => {
|
||||
// If decryption fails, show raw value
|
||||
setForm((prev) => ({ ...prev, apiKey: provider.apiKey ?? "" }));
|
||||
})
|
||||
.finally(() => setIsDecrypting(false));
|
||||
}
|
||||
}, [provider.apiKey]);
|
||||
|
||||
useEffect(() => {
|
||||
if (modelMetadataSourceKeyRef.current == null) {
|
||||
modelMetadataSourceKeyRef.current = modelMetadataSourceKey;
|
||||
return;
|
||||
}
|
||||
if (modelMetadataSourceKeyRef.current === modelMetadataSourceKey) return;
|
||||
|
||||
modelMetadataSourceKeyRef.current = modelMetadataSourceKey;
|
||||
setForm((prev) => Object.keys(prev.modelContextWindows).length > 0
|
||||
? { ...prev, modelContextWindows: {} }
|
||||
: prev);
|
||||
}, [modelMetadataSourceKey]);
|
||||
|
||||
useEffect(() => {
|
||||
if (probeFingerprintRef.current == null) {
|
||||
probeFingerprintRef.current = probeFingerprint;
|
||||
return;
|
||||
}
|
||||
if (probeFingerprintRef.current === probeFingerprint) return;
|
||||
|
||||
probeFingerprintRef.current = probeFingerprint;
|
||||
probeRequestIdRef.current += 1;
|
||||
setProbeResult(null);
|
||||
setIsTesting(false);
|
||||
}, [probeFingerprint]);
|
||||
|
||||
const [advancedParamRaw, setAdvancedParamRaw] = useState<Record<string, string>>({});
|
||||
const handleAdvancedParam = useCallback((key: keyof ProviderAdvancedParams, raw: string) => {
|
||||
setAdvancedParamRaw((prev) => ({ ...prev, [key]: raw }));
|
||||
setForm((prev) => {
|
||||
const next = { ...prev.advancedParams };
|
||||
if (raw.trim() === "" || raw.trim() === "-") {
|
||||
delete next[key];
|
||||
} else {
|
||||
const num = Number(raw);
|
||||
if (!Number.isNaN(num)) {
|
||||
next[key] = num;
|
||||
}
|
||||
}
|
||||
return { ...prev, advancedParams: next };
|
||||
});
|
||||
}, []);
|
||||
|
||||
const handleIconFileSelect = useCallback(async (file: File | null) => {
|
||||
setIconError(null);
|
||||
if (!file) return;
|
||||
if (!/^image\//.test(file.type)) {
|
||||
setIconError(t("ai.providers.icon.errorType"));
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const dataUrl = await compressIconFileToDataUrl(file);
|
||||
setForm((prev) => ({ ...prev, iconDataUrl: dataUrl, iconId: "" }));
|
||||
} catch (err) {
|
||||
setIconError(err instanceof Error ? err.message : String(err));
|
||||
}
|
||||
}, [t]);
|
||||
|
||||
const handlePickBuiltin = useCallback((icon: BuiltinProviderIcon) => {
|
||||
setIconError(null);
|
||||
setForm((prev) => ({ ...prev, iconId: icon.id, iconDataUrl: "", name: icon.name }));
|
||||
}, []);
|
||||
|
||||
const handleResetIcon = useCallback(() => {
|
||||
setIconError(null);
|
||||
setForm((prev) => ({ ...prev, iconId: "", iconDataUrl: "" }));
|
||||
}, []);
|
||||
|
||||
const handleApiKeyChange = useCallback((value: string) => {
|
||||
setApiKeySourceVersion((version) => version + 1);
|
||||
setForm((prev) => ({ ...prev, apiKey: value }));
|
||||
}, []);
|
||||
|
||||
const handleTestConnection = useCallback(async () => {
|
||||
const baseURL = resolvedBaseURL;
|
||||
const inputCheck = validateProviderProbeInputs({
|
||||
baseURL,
|
||||
apiKey: form.apiKey,
|
||||
providerId: provider.providerId,
|
||||
});
|
||||
if (!inputCheck.ok) {
|
||||
probeRequestIdRef.current += 1;
|
||||
setIsTesting(false);
|
||||
setProbeResult({
|
||||
health: "error",
|
||||
message: t(
|
||||
inputCheck.reason === "missing_base_url"
|
||||
? "ai.providers.test.missingBaseUrl"
|
||||
: "ai.providers.test.missingApiKey",
|
||||
),
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const requestId = ++probeRequestIdRef.current;
|
||||
setIsTesting(true);
|
||||
setProbeResult(null);
|
||||
try {
|
||||
const run = await probeProviderConnection({
|
||||
bridge: getFetchBridge(),
|
||||
baseURL,
|
||||
apiKey: form.apiKey,
|
||||
providerId: provider.providerId,
|
||||
style: resolvedStyle,
|
||||
presetModelsEndpoint: preset?.modelsEndpoint,
|
||||
skipTLSVerify: form.skipTLSVerify,
|
||||
});
|
||||
if (probeRequestIdRef.current !== requestId) return;
|
||||
if (!run.ok) {
|
||||
setProbeResult({
|
||||
health: "error",
|
||||
message: t(
|
||||
run.reason === "missing_base_url"
|
||||
? "ai.providers.test.missingBaseUrl"
|
||||
: run.reason === "missing_api_key"
|
||||
? "ai.providers.test.missingApiKey"
|
||||
: "ai.providers.test.unavailable",
|
||||
),
|
||||
});
|
||||
return;
|
||||
}
|
||||
const classified = run.classification;
|
||||
const latency = String(classified.latencyMs);
|
||||
if (classified.health === "ok") {
|
||||
setProbeResult({
|
||||
health: "ok",
|
||||
message: t("ai.providers.test.ok", { latency }),
|
||||
});
|
||||
} else if (classified.health === "warn") {
|
||||
const warnKey = classified.modelCount === 0 || classified.error
|
||||
? "ai.providers.test.warn"
|
||||
: "ai.providers.test.warnSlow";
|
||||
setProbeResult({
|
||||
health: "warn",
|
||||
message: t(warnKey, { latency }),
|
||||
});
|
||||
} else {
|
||||
const detail = classified.error
|
||||
|| (classified.statusCode ? `HTTP ${classified.statusCode}` : "error");
|
||||
setProbeResult({
|
||||
health: "error",
|
||||
message: t("ai.providers.test.error", { detail }),
|
||||
});
|
||||
}
|
||||
} catch (err) {
|
||||
if (probeRequestIdRef.current !== requestId) return;
|
||||
setProbeResult({
|
||||
health: "error",
|
||||
message: t("ai.providers.test.error", {
|
||||
detail: err instanceof Error ? err.message : String(err),
|
||||
}),
|
||||
});
|
||||
} finally {
|
||||
if (probeRequestIdRef.current === requestId) setIsTesting(false);
|
||||
}
|
||||
}, [form.apiKey, form.skipTLSVerify, preset?.modelsEndpoint, provider.providerId, resolvedBaseURL, resolvedStyle, t]);
|
||||
|
||||
const handleSave = useCallback(async () => {
|
||||
const cleanedParams: ProviderAdvancedParams = {};
|
||||
const ap = form.advancedParams;
|
||||
if (ap.maxTokens != null && Number.isFinite(ap.maxTokens) && ap.maxTokens > 0) cleanedParams.maxTokens = Math.max(1, Math.round(ap.maxTokens));
|
||||
if (ap.temperature != null) cleanedParams.temperature = Math.min(2, Math.max(0, ap.temperature));
|
||||
if (ap.topP != null) cleanedParams.topP = Math.min(1, Math.max(0, ap.topP));
|
||||
if (ap.frequencyPenalty != null) cleanedParams.frequencyPenalty = Math.min(2, Math.max(-2, ap.frequencyPenalty));
|
||||
if (ap.presencePenalty != null) cleanedParams.presencePenalty = Math.min(2, Math.max(-2, ap.presencePenalty));
|
||||
|
||||
const trimmedName = form.name.trim();
|
||||
const defaultName = PROVIDER_PRESETS[provider.providerId]?.name ?? "";
|
||||
const rawContextWindow = form.contextWindow.trim();
|
||||
const rawContextWindowNumber = Number(rawContextWindow);
|
||||
if (rawContextWindow && (!Number.isInteger(rawContextWindowNumber) || rawContextWindowNumber <= 0)) {
|
||||
setContextWindowError(t("ai.providers.contextWindow.error"));
|
||||
return;
|
||||
}
|
||||
const manualContextWindow = rawContextWindow ? sanitizeContextWindow(rawContextWindow) : undefined;
|
||||
if (rawContextWindow && manualContextWindow == null) {
|
||||
setContextWindowError(t("ai.providers.contextWindow.error"));
|
||||
return;
|
||||
}
|
||||
setContextWindowError(null);
|
||||
|
||||
const updates: Partial<ProviderConfig> = {
|
||||
name: trimmedName || defaultName,
|
||||
baseURL: provider.providerId === "ollama"
|
||||
? resolvedBaseURL
|
||||
: (form.baseURL || undefined),
|
||||
defaultModel: form.defaultModel || undefined,
|
||||
contextWindow: manualContextWindow,
|
||||
modelContextWindows: Object.keys(form.modelContextWindows).length > 0 ? form.modelContextWindows : undefined,
|
||||
skipTLSVerify: form.skipTLSVerify || undefined,
|
||||
advancedParams: Object.keys(cleanedParams).length > 0 ? cleanedParams : undefined,
|
||||
style: form.style || undefined,
|
||||
openaiApi: resolvedStyle === "openai" && form.openaiApi === "responses" ? "responses" : undefined,
|
||||
iconId: form.iconId || undefined,
|
||||
iconDataUrl: form.iconDataUrl || undefined,
|
||||
};
|
||||
|
||||
// Encrypt API key before saving
|
||||
if (form.apiKey) {
|
||||
updates.apiKey = await encryptField(form.apiKey);
|
||||
} else {
|
||||
updates.apiKey = undefined;
|
||||
}
|
||||
|
||||
onSave(updates);
|
||||
}, [form, onSave, provider.providerId, resolvedBaseURL, resolvedStyle, t]);
|
||||
|
||||
return (
|
||||
<div className="mt-3 space-y-3 border-t border-border/40 pt-3">
|
||||
{/* Display: icon + name */}
|
||||
<div className="space-y-1.5">
|
||||
<label className="text-xs font-medium text-muted-foreground">{t('ai.providers.name')}</label>
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowIconPicker((v) => !v)}
|
||||
className="group relative shrink-0 rounded-md transition-all hover:brightness-110 hover:ring-2 hover:ring-primary/45 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/60"
|
||||
aria-label={t('ai.providers.icon.change')}
|
||||
title={t('ai.providers.icon.change')}
|
||||
>
|
||||
<ProviderIconBadge provider={previewProvider} />
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className="pointer-events-none absolute -bottom-1 -right-1 flex h-4 w-4 items-center justify-center rounded-full border border-background bg-primary text-primary-foreground opacity-0 shadow-sm transition-opacity group-hover:opacity-100 group-focus-visible:opacity-100"
|
||||
>
|
||||
<Pencil size={9} strokeWidth={2.5} />
|
||||
</span>
|
||||
</button>
|
||||
<input
|
||||
type="text"
|
||||
value={form.name}
|
||||
onChange={(e) => setForm((prev) => ({ ...prev, name: e.target.value }))}
|
||||
placeholder={t('ai.providers.name.placeholder')}
|
||||
className="flex-1 h-8 rounded-md border border-input bg-background px-3 text-sm placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
|
||||
/>
|
||||
</div>
|
||||
{showIconPicker && (
|
||||
<div className="rounded-md border border-border/50 bg-muted/20 p-2 space-y-2">
|
||||
<div className="grid grid-cols-[repeat(auto-fill,minmax(120px,1fr))] gap-1.5">
|
||||
{BUILTIN_PROVIDER_ICONS.map((icon) => {
|
||||
const isSelected = form.iconId === icon.id && !form.iconDataUrl;
|
||||
return (
|
||||
<button
|
||||
key={icon.id}
|
||||
type="button"
|
||||
onClick={() => (isSelected ? handleResetIcon() : handlePickBuiltin(icon))}
|
||||
title={icon.label}
|
||||
aria-label={icon.label}
|
||||
aria-pressed={isSelected}
|
||||
className={cn(
|
||||
"flex items-center gap-2 px-2 py-1.5 rounded-md border text-left transition-colors min-w-0",
|
||||
isSelected
|
||||
? "border-primary/70 bg-primary/15"
|
||||
: "border-transparent hover:border-border hover:bg-muted/40",
|
||||
)}
|
||||
>
|
||||
<ProviderIconBadge
|
||||
provider={{ providerId: provider.providerId, name: icon.label, iconId: icon.id }}
|
||||
size="md"
|
||||
/>
|
||||
<span className="text-xs text-foreground/85 truncate">{icon.label}</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<div className="flex items-center gap-2 pt-2 border-t border-border/40">
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
accept="image/*"
|
||||
className="hidden"
|
||||
onChange={(e) => void handleIconFileSelect(e.target.files?.[0] ?? null)}
|
||||
/>
|
||||
<Button variant="ghost" size="sm" onClick={() => fileInputRef.current?.click()}>
|
||||
<Upload size={12} className="mr-1.5" />
|
||||
{t('ai.providers.icon.upload')}
|
||||
</Button>
|
||||
<Button variant="ghost" size="sm" onClick={handleResetIcon}>
|
||||
<RotateCcw size={12} className="mr-1.5" />
|
||||
{t('ai.providers.icon.reset')}
|
||||
</Button>
|
||||
{form.iconDataUrl && (
|
||||
<span className="text-[10px] text-muted-foreground">{t('ai.providers.icon.uploadedNote')}</span>
|
||||
)}
|
||||
<div className="ml-auto" />
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => setShowIconPicker(false)}
|
||||
aria-label={t('ai.providers.icon.close')}
|
||||
title={t('ai.providers.icon.close')}
|
||||
>
|
||||
<X size={12} className="mr-1.5" />
|
||||
{t('ai.providers.icon.close')}
|
||||
</Button>
|
||||
</div>
|
||||
{iconError && <p className="text-[11px] text-destructive">{iconError}</p>}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Provider style */}
|
||||
<div className="space-y-1.5">
|
||||
<label className="text-xs font-medium text-muted-foreground">{t('ai.providers.style')}</label>
|
||||
<div className="flex items-center gap-1.5">
|
||||
{STYLE_OPTIONS.map((style) => {
|
||||
const isSelected = resolvedStyle === style;
|
||||
const isInherited = !form.style && isSelected;
|
||||
return (
|
||||
<button
|
||||
key={style}
|
||||
type="button"
|
||||
onClick={() => setForm((prev) => ({ ...prev, style: prev.style === style ? "" : style }))}
|
||||
className={cn(
|
||||
"h-7 px-2.5 rounded-md text-xs border transition-colors",
|
||||
isSelected
|
||||
? "border-primary/70 bg-primary/15 text-foreground"
|
||||
: "border-border/50 bg-background text-muted-foreground hover:text-foreground hover:bg-muted/40",
|
||||
)}
|
||||
aria-pressed={isSelected}
|
||||
>
|
||||
{t(`ai.providers.style.${style}`)}
|
||||
{isInherited && (
|
||||
<span className="ml-1 text-[9px] text-muted-foreground/70">({t('ai.providers.style.inherited')})</span>
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<p className="text-[11px] text-muted-foreground/70">{t('ai.providers.style.help')}</p>
|
||||
</div>
|
||||
|
||||
{resolvedStyle === "openai" && (
|
||||
<div className="space-y-1.5">
|
||||
<label className="text-xs font-medium text-muted-foreground">{t('ai.providers.openaiApi')}</label>
|
||||
<div className="flex items-center gap-1.5">
|
||||
{OPENAI_API_OPTIONS.map((format) => {
|
||||
const isSelected = form.openaiApi === format;
|
||||
return (
|
||||
<button
|
||||
key={format}
|
||||
type="button"
|
||||
onClick={() => setForm((prev) => ({ ...prev, openaiApi: format }))}
|
||||
className={cn(
|
||||
"h-7 px-2.5 rounded-md text-xs border transition-colors",
|
||||
isSelected
|
||||
? "border-primary/70 bg-primary/15 text-foreground"
|
||||
: "border-border/50 bg-background text-muted-foreground hover:text-foreground hover:bg-muted/40",
|
||||
)}
|
||||
aria-pressed={isSelected}
|
||||
>
|
||||
{t(`ai.providers.openaiApi.${format}`)}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<p className="text-[11px] text-muted-foreground/70">{t('ai.providers.openaiApi.help')}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* API Key */}
|
||||
<div className="space-y-1.5">
|
||||
<label className="text-xs font-medium text-muted-foreground">{t('ai.providers.apiKey')}</label>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="relative flex-1">
|
||||
<input
|
||||
type={showApiKey ? "text" : "password"}
|
||||
value={isDecrypting ? "" : form.apiKey}
|
||||
onChange={(e) => handleApiKeyChange(e.target.value)}
|
||||
placeholder={isDecrypting ? t('ai.providers.apiKey.decrypting') : t('ai.providers.apiKey.placeholder')}
|
||||
disabled={isDecrypting}
|
||||
className="w-full h-8 rounded-md border border-input bg-background px-3 pr-9 text-sm placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:opacity-50"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowApiKey(!showApiKey)}
|
||||
className="absolute right-2 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
{showApiKey ? <EyeOff size={14} /> : <Eye size={14} />}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Base URL */}
|
||||
<div className="space-y-1.5">
|
||||
<label className="text-xs font-medium text-muted-foreground">{t('ai.providers.baseUrl')}</label>
|
||||
<input
|
||||
type="text"
|
||||
value={form.baseURL}
|
||||
onChange={(e) => setForm((prev) => ({ ...prev, baseURL: e.target.value }))}
|
||||
placeholder={preset?.defaultBaseURL || "https://"}
|
||||
className="w-full h-8 rounded-md border border-input bg-background px-3 text-sm placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
|
||||
/>
|
||||
{resolvedStyle === "anthropic" ? (
|
||||
<p className="text-[11px] text-muted-foreground/70">{t('ai.providers.baseUrl.anthropicHelp')}</p>
|
||||
) : null}
|
||||
{provider.providerId === "ollama" ? (
|
||||
<p className="text-[11px] text-muted-foreground/70">{t('ai.providers.baseUrl.ollamaHelp')}</p>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{/* Default Model */}
|
||||
<div className="space-y-1.5">
|
||||
<label className="text-xs font-medium text-muted-foreground">{t('ai.providers.defaultModel')}</label>
|
||||
<ModelSelector
|
||||
value={form.defaultModel}
|
||||
onChange={(val) => setForm((prev) => ({ ...prev, defaultModel: val }))}
|
||||
onModelMetadata={(model) => {
|
||||
setForm((prev) => ({
|
||||
...prev,
|
||||
modelContextWindows: mergeModelContextWindow(prev.modelContextWindows, model.id, model.contextWindow) ?? prev.modelContextWindows,
|
||||
}));
|
||||
}}
|
||||
baseURL={resolvedBaseURL}
|
||||
modelsEndpoint={preset?.modelsEndpoint}
|
||||
presetModels={preset?.defaultModels}
|
||||
apiKey={form.apiKey}
|
||||
providerId={provider.providerId}
|
||||
style={resolvedStyle}
|
||||
skipTLSVerify={form.skipTLSVerify}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Context window */}
|
||||
<div className="space-y-1.5">
|
||||
<label className="text-xs font-medium text-muted-foreground">{t('ai.providers.contextWindow')}</label>
|
||||
<input
|
||||
type="number"
|
||||
min={1}
|
||||
step={1}
|
||||
value={form.contextWindow}
|
||||
onChange={(e) => {
|
||||
setContextWindowError(null);
|
||||
setForm((prev) => ({ ...prev, contextWindow: e.target.value }));
|
||||
}}
|
||||
placeholder={
|
||||
form.defaultModel && form.modelContextWindows[form.defaultModel]
|
||||
? String(form.modelContextWindows[form.defaultModel])
|
||||
: t('ai.providers.contextWindow.placeholder')
|
||||
}
|
||||
className={cn(
|
||||
"w-full h-8 rounded-md border border-input bg-background px-3 text-sm placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring",
|
||||
contextWindowError && "border-destructive focus-visible:ring-destructive",
|
||||
)}
|
||||
/>
|
||||
{contextWindowError && <p className="text-[11px] text-destructive">{contextWindowError}</p>}
|
||||
<p className="text-[11px] text-muted-foreground/70">{t('ai.providers.contextWindow.help')}</p>
|
||||
</div>
|
||||
|
||||
{/* Skip TLS Verification */}
|
||||
<label className="flex items-center gap-2 cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={form.skipTLSVerify}
|
||||
onChange={(e) => setForm((prev) => ({ ...prev, skipTLSVerify: e.target.checked }))}
|
||||
className="rounded border-input"
|
||||
/>
|
||||
<span className="text-xs text-muted-foreground">{t('ai.providers.skipTLSVerify')}</span>
|
||||
</label>
|
||||
|
||||
{/* Advanced Parameters */}
|
||||
<div className="space-y-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowAdvanced(!showAdvanced)}
|
||||
className="flex items-center gap-1.5 text-xs font-medium text-muted-foreground hover:text-foreground transition-colors"
|
||||
>
|
||||
{showAdvanced ? <ChevronDown size={14} /> : <ChevronRight size={14} />}
|
||||
{t('ai.providers.advancedParams')}
|
||||
</button>
|
||||
{showAdvanced && (
|
||||
<div className="space-y-2.5 pl-1 border-l-2 border-border/40 ml-1">
|
||||
<p className="text-[11px] text-muted-foreground/70 pl-3">{t('ai.providers.advancedParams.hint')}</p>
|
||||
{/* max_tokens */}
|
||||
<div className="space-y-1 pl-3">
|
||||
<label className="text-xs text-muted-foreground">max_tokens</label>
|
||||
<input
|
||||
type="number"
|
||||
min={1}
|
||||
step={1}
|
||||
value={advancedParamRaw.maxTokens ?? (form.advancedParams.maxTokens != null ? String(form.advancedParams.maxTokens) : "")}
|
||||
onChange={(e) => handleAdvancedParam("maxTokens", e.target.value)}
|
||||
placeholder={t('ai.providers.advancedParams.maxTokens.placeholder')}
|
||||
className="w-full h-8 rounded-md border border-input bg-background px-3 text-sm placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
|
||||
/>
|
||||
</div>
|
||||
{/* temperature */}
|
||||
<div className="space-y-1 pl-3">
|
||||
<label className="text-xs text-muted-foreground">temperature <span className="text-muted-foreground/50">(0–2)</span></label>
|
||||
<input
|
||||
type="number"
|
||||
min={0}
|
||||
max={2}
|
||||
step={0.1}
|
||||
value={advancedParamRaw.temperature ?? (form.advancedParams.temperature != null ? String(form.advancedParams.temperature) : "")}
|
||||
onChange={(e) => handleAdvancedParam("temperature", e.target.value)}
|
||||
placeholder={t('ai.providers.advancedParams.default')}
|
||||
className="w-full h-8 rounded-md border border-input bg-background px-3 text-sm placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
|
||||
/>
|
||||
</div>
|
||||
{/* top_p */}
|
||||
<div className="space-y-1 pl-3">
|
||||
<label className="text-xs text-muted-foreground">top_p <span className="text-muted-foreground/50">(0–1)</span></label>
|
||||
<input
|
||||
type="number"
|
||||
min={0}
|
||||
max={1}
|
||||
step={0.05}
|
||||
value={advancedParamRaw.topP ?? (form.advancedParams.topP != null ? String(form.advancedParams.topP) : "")}
|
||||
onChange={(e) => handleAdvancedParam("topP", e.target.value)}
|
||||
placeholder={t('ai.providers.advancedParams.default')}
|
||||
className="w-full h-8 rounded-md border border-input bg-background px-3 text-sm placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
|
||||
/>
|
||||
</div>
|
||||
{/* frequency_penalty */}
|
||||
<div className="space-y-1 pl-3">
|
||||
<label className="text-xs text-muted-foreground">frequency_penalty <span className="text-muted-foreground/50">(-2–2)</span></label>
|
||||
<input
|
||||
type="number"
|
||||
min={-2}
|
||||
max={2}
|
||||
step={0.1}
|
||||
value={advancedParamRaw.frequencyPenalty ?? (form.advancedParams.frequencyPenalty != null ? String(form.advancedParams.frequencyPenalty) : "")}
|
||||
onChange={(e) => handleAdvancedParam("frequencyPenalty", e.target.value)}
|
||||
placeholder={t('ai.providers.advancedParams.default')}
|
||||
className="w-full h-8 rounded-md border border-input bg-background px-3 text-sm placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
|
||||
/>
|
||||
</div>
|
||||
{/* presence_penalty */}
|
||||
<div className="space-y-1 pl-3">
|
||||
<label className="text-xs text-muted-foreground">presence_penalty <span className="text-muted-foreground/50">(-2–2)</span></label>
|
||||
<input
|
||||
type="number"
|
||||
min={-2}
|
||||
max={2}
|
||||
step={0.1}
|
||||
value={advancedParamRaw.presencePenalty ?? (form.advancedParams.presencePenalty != null ? String(form.advancedParams.presencePenalty) : "")}
|
||||
onChange={(e) => handleAdvancedParam("presencePenalty", e.target.value)}
|
||||
placeholder={t('ai.providers.advancedParams.default')}
|
||||
className="w-full h-8 rounded-md border border-input bg-background px-3 text-sm placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex flex-col gap-2 pt-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
variant="default"
|
||||
size="sm"
|
||||
className={cn(PROVIDER_ACTION_CLASS, "border border-transparent")}
|
||||
onClick={() => void handleSave()}
|
||||
>
|
||||
<Check size={14} className="size-3.5 shrink-0" />
|
||||
{t('common.save')}
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className={PROVIDER_ACTION_CLASS}
|
||||
onClick={() => void handleTestConnection()}
|
||||
disabled={isTesting || isDecrypting}
|
||||
>
|
||||
<RefreshCw size={14} className={cn("size-3.5 shrink-0", isTesting && "animate-spin")} />
|
||||
{isTesting ? t('ai.providers.test.testing') : t('ai.providers.test')}
|
||||
</Button>
|
||||
<Button variant="ghost" size="sm" className={PROVIDER_ACTION_CLASS} onClick={onCancel}>
|
||||
{t('common.cancel')}
|
||||
</Button>
|
||||
</div>
|
||||
{(isTesting || probeResult) && (
|
||||
<p
|
||||
className={cn(
|
||||
"text-[11px]",
|
||||
isTesting && "text-muted-foreground",
|
||||
probeResult?.health === "ok" && "text-emerald-500",
|
||||
probeResult?.health === "warn" && "text-amber-500",
|
||||
probeResult?.health === "error" && "text-destructive",
|
||||
)}
|
||||
role="status"
|
||||
aria-live="polite"
|
||||
>
|
||||
{isTesting ? t('ai.providers.test.testing') : probeResult?.message}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
117
components/settings/tabs/ai/ProviderIconBadge.tsx
Normal file
117
components/settings/tabs/ai/ProviderIconBadge.tsx
Normal file
@@ -0,0 +1,117 @@
|
||||
import React from "react";
|
||||
import { cn } from "../../../../lib/utils";
|
||||
import type { ProviderConfig } from "../../../../infrastructure/ai/types";
|
||||
import type { SettingsIconId } from "./types";
|
||||
import {
|
||||
BUILTIN_PROVIDER_ICON_BY_ID,
|
||||
SETTINGS_ICON_PATHS,
|
||||
SETTINGS_ICON_COLORS,
|
||||
} from "./types";
|
||||
|
||||
/**
|
||||
* Optional ProviderConfig-like shape for per-provider customization. Only the
|
||||
* fields used by the badge are listed so non-provider call sites (Claude/Copilot
|
||||
* agent cards) can still pass a bare `providerId`.
|
||||
*/
|
||||
type ProviderLike = Pick<ProviderConfig, "providerId" | "name" | "iconId" | "iconDataUrl">;
|
||||
|
||||
interface BaseProps {
|
||||
size?: "xs" | "sm" | "md";
|
||||
}
|
||||
|
||||
type Props =
|
||||
| (BaseProps & { providerId: SettingsIconId; provider?: undefined })
|
||||
| (BaseProps & { provider: ProviderLike; providerId?: undefined });
|
||||
|
||||
const BADGE_DIMENSIONS = {
|
||||
xs: "w-4 h-4",
|
||||
sm: "w-5 h-5",
|
||||
md: "w-8 h-8",
|
||||
} as const;
|
||||
|
||||
const IMG_DIMENSIONS = {
|
||||
xs: "w-2.5 h-2.5",
|
||||
sm: "w-3 h-3",
|
||||
md: "w-4 h-4",
|
||||
} as const;
|
||||
|
||||
const UPLOAD_IMG_DIMENSIONS = {
|
||||
xs: "w-4 h-4",
|
||||
sm: "w-5 h-5",
|
||||
md: "w-8 h-8",
|
||||
} as const;
|
||||
|
||||
export const ProviderIconBadge: React.FC<Props> = (props) => {
|
||||
const size = props.size ?? "md";
|
||||
const dim = BADGE_DIMENSIONS[size];
|
||||
|
||||
// Branch 1: user-uploaded data URL — render verbatim, no filter, neutral bg.
|
||||
if (props.provider?.iconDataUrl) {
|
||||
return (
|
||||
<div className={cn("rounded-md flex items-center justify-center shrink-0 overflow-hidden bg-zinc-900/40", dim)}>
|
||||
<img
|
||||
src={props.provider.iconDataUrl}
|
||||
alt=""
|
||||
aria-hidden="true"
|
||||
draggable={false}
|
||||
className={cn("object-contain", UPLOAD_IMG_DIMENSIONS[size])}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Branch 2: built-in iconId (lobe-icons subset).
|
||||
const iconId = props.provider?.iconId;
|
||||
if (iconId) {
|
||||
const builtin = BUILTIN_PROVIDER_ICON_BY_ID[iconId];
|
||||
if (builtin) {
|
||||
return (
|
||||
<div className={cn("rounded-md flex items-center justify-center shrink-0 overflow-hidden", dim, builtin.bgColor)}>
|
||||
<img
|
||||
src={builtin.path}
|
||||
alt=""
|
||||
aria-hidden="true"
|
||||
draggable={false}
|
||||
className={cn("object-contain brightness-0 invert", IMG_DIMENSIONS[size])}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Branch 3: providerId → existing built-in fallback table.
|
||||
const fallbackId: SettingsIconId | undefined =
|
||||
props.providerId ?? (props.provider ? (props.provider.providerId as SettingsIconId) : undefined);
|
||||
if (fallbackId && fallbackId in SETTINGS_ICON_PATHS) {
|
||||
return (
|
||||
<div className={cn("rounded-md flex items-center justify-center shrink-0 overflow-hidden", dim, SETTINGS_ICON_COLORS[fallbackId])}>
|
||||
<img
|
||||
src={SETTINGS_ICON_PATHS[fallbackId]}
|
||||
alt=""
|
||||
aria-hidden="true"
|
||||
draggable={false}
|
||||
className={cn(
|
||||
"object-contain",
|
||||
fallbackId === "copilot" ? "brightness-0" : "brightness-0 invert",
|
||||
IMG_DIMENSIONS[size],
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Branch 4: letter avatar from the provider name.
|
||||
const letter = (props.provider?.name?.trim().charAt(0) ?? "?").toUpperCase();
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"rounded-md flex items-center justify-center shrink-0 overflow-hidden bg-zinc-600 text-white font-medium",
|
||||
dim,
|
||||
size === "md" ? "text-sm" : size === "sm" ? "text-[10px]" : "text-[9px]",
|
||||
)}
|
||||
aria-hidden="true"
|
||||
>
|
||||
{letter}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
305
components/settings/tabs/ai/QuickMessagesSettings.tsx
Normal file
305
components/settings/tabs/ai/QuickMessagesSettings.tsx
Normal file
@@ -0,0 +1,305 @@
|
||||
import { MessageSquare, Pencil, Plus, Trash2, X } from "lucide-react";
|
||||
import React, { useCallback, useMemo, useState } from "react";
|
||||
import type { AIQuickMessage } from "../../../../infrastructure/ai/quickMessages";
|
||||
import {
|
||||
createQuickMessageId,
|
||||
isValidQuickMessageSlug,
|
||||
normalizeQuickMessageSlug,
|
||||
QUICK_MESSAGE_LIMITS,
|
||||
slugFromQuickMessageName,
|
||||
} from "../../../../infrastructure/ai/quickMessages";
|
||||
import { useI18n } from "../../../../application/i18n/I18nProvider";
|
||||
import { Button } from "../../../ui/button";
|
||||
import { SettingCard, SettingsSection } from "../../settings-ui";
|
||||
|
||||
interface QuickMessagesSettingsProps {
|
||||
quickMessages: AIQuickMessage[];
|
||||
setQuickMessages: (value: AIQuickMessage[] | ((prev: AIQuickMessage[]) => AIQuickMessage[])) => void;
|
||||
reservedUserSkillSlugs?: string[];
|
||||
}
|
||||
|
||||
type DraftQuickMessage = {
|
||||
name: string;
|
||||
slug: string;
|
||||
content: string;
|
||||
description: string;
|
||||
};
|
||||
|
||||
const emptyDraft = (): DraftQuickMessage => ({
|
||||
name: "",
|
||||
slug: "",
|
||||
content: "",
|
||||
description: "",
|
||||
});
|
||||
|
||||
export const QuickMessagesSettings: React.FC<QuickMessagesSettingsProps> = ({
|
||||
quickMessages,
|
||||
setQuickMessages,
|
||||
reservedUserSkillSlugs = [],
|
||||
}) => {
|
||||
const { t } = useI18n();
|
||||
const [editingId, setEditingId] = useState<string | null>(null);
|
||||
const [isCreating, setIsCreating] = useState(false);
|
||||
const [draft, setDraft] = useState<DraftQuickMessage>(emptyDraft);
|
||||
const [slugTouched, setSlugTouched] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const sortedMessages = useMemo(
|
||||
() => [...quickMessages].sort((a, b) => a.name.localeCompare(b.name)),
|
||||
[quickMessages],
|
||||
);
|
||||
|
||||
const resetEditor = useCallback(() => {
|
||||
setEditingId(null);
|
||||
setIsCreating(false);
|
||||
setDraft(emptyDraft());
|
||||
setSlugTouched(false);
|
||||
setError(null);
|
||||
}, []);
|
||||
|
||||
const beginCreate = useCallback(() => {
|
||||
setEditingId(null);
|
||||
setIsCreating(true);
|
||||
setDraft(emptyDraft());
|
||||
setSlugTouched(false);
|
||||
setError(null);
|
||||
}, []);
|
||||
|
||||
const beginEdit = useCallback((message: AIQuickMessage) => {
|
||||
setIsCreating(false);
|
||||
setEditingId(message.id);
|
||||
setDraft({
|
||||
name: message.name,
|
||||
slug: message.slug,
|
||||
content: message.content,
|
||||
description: message.description ?? "",
|
||||
});
|
||||
setSlugTouched(true);
|
||||
setError(null);
|
||||
}, []);
|
||||
|
||||
const handleNameChange = useCallback((name: string) => {
|
||||
setDraft((prev) => ({
|
||||
...prev,
|
||||
name,
|
||||
slug: slugTouched ? prev.slug : slugFromQuickMessageName(name),
|
||||
}));
|
||||
}, [slugTouched]);
|
||||
|
||||
const handleSlugChange = useCallback((slug: string) => {
|
||||
setSlugTouched(true);
|
||||
setDraft((prev) => ({ ...prev, slug: normalizeQuickMessageSlug(slug) }));
|
||||
}, []);
|
||||
|
||||
const validateDraft = useCallback((nextDraft: DraftQuickMessage, excludeId?: string | null): string | null => {
|
||||
const name = nextDraft.name.trim();
|
||||
const slug = normalizeQuickMessageSlug(nextDraft.slug);
|
||||
const content = nextDraft.content.trim();
|
||||
|
||||
if (!name) return t("ai.quickMessages.error.nameRequired");
|
||||
if (!isValidQuickMessageSlug(slug)) return t("ai.quickMessages.error.invalidSlug");
|
||||
if (!content) return t("ai.quickMessages.error.contentRequired");
|
||||
|
||||
if (!excludeId && quickMessages.length >= QUICK_MESSAGE_LIMITS.maxItems) {
|
||||
return t("ai.quickMessages.error.maxItems", { max: String(QUICK_MESSAGE_LIMITS.maxItems) });
|
||||
}
|
||||
|
||||
const slugTaken = quickMessages.some(
|
||||
(message) => message.slug === slug && message.id !== excludeId,
|
||||
);
|
||||
if (slugTaken) return t("ai.quickMessages.error.slugTaken");
|
||||
|
||||
const skillConflict = reservedUserSkillSlugs.some((skillSlug) => skillSlug === slug);
|
||||
if (skillConflict) {
|
||||
return t("ai.quickMessages.error.slugConflictsWithSkill", { slug });
|
||||
}
|
||||
|
||||
return null;
|
||||
}, [quickMessages, reservedUserSkillSlugs, t]);
|
||||
|
||||
const handleSave = useCallback(() => {
|
||||
const validationError = validateDraft(draft, editingId);
|
||||
if (validationError) {
|
||||
setError(validationError);
|
||||
return;
|
||||
}
|
||||
|
||||
const payload: AIQuickMessage = {
|
||||
id: editingId ?? createQuickMessageId(),
|
||||
name: draft.name.trim(),
|
||||
slug: normalizeQuickMessageSlug(draft.slug),
|
||||
content: draft.content.trim(),
|
||||
description: draft.description.trim() || undefined,
|
||||
};
|
||||
|
||||
if (editingId) {
|
||||
setQuickMessages((prev) => prev.map((message) => (
|
||||
message.id === editingId ? payload : message
|
||||
)));
|
||||
} else {
|
||||
setQuickMessages((prev) => [...prev, payload]);
|
||||
}
|
||||
resetEditor();
|
||||
}, [draft, editingId, resetEditor, setQuickMessages, validateDraft]);
|
||||
|
||||
const handleDelete = useCallback((message: AIQuickMessage) => {
|
||||
const ok = window.confirm(t("ai.quickMessages.confirmDelete", { name: message.name }));
|
||||
if (!ok) return;
|
||||
setQuickMessages((prev) => prev.filter((item) => item.id !== message.id));
|
||||
if (editingId === message.id) {
|
||||
resetEditor();
|
||||
}
|
||||
}, [editingId, resetEditor, setQuickMessages, t]);
|
||||
|
||||
const showEditor = isCreating || editingId != null;
|
||||
|
||||
return (
|
||||
<SettingsSection
|
||||
anchorId="ai-quick-messages"
|
||||
title={t("ai.quickMessages.title")}
|
||||
actions={(
|
||||
<Button variant="outline" size="sm" onClick={beginCreate} disabled={showEditor}>
|
||||
<Plus size={14} className="mr-2" />
|
||||
{t("ai.quickMessages.add")}
|
||||
</Button>
|
||||
)}
|
||||
>
|
||||
<SettingCard padded className="space-y-3">
|
||||
<p className="text-xs text-muted-foreground/80 leading-5">
|
||||
{t("ai.quickMessages.description")}
|
||||
</p>
|
||||
|
||||
{showEditor ? (
|
||||
<div className="rounded-md border border-border/60 bg-background/40 p-4 space-y-3">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div className="text-sm font-medium">
|
||||
{isCreating ? t("ai.quickMessages.createTitle") : t("ai.quickMessages.editTitle")}
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={resetEditor}
|
||||
className="inline-flex h-7 w-7 items-center justify-center rounded-md text-muted-foreground hover:bg-muted/30 hover:text-foreground transition-colors"
|
||||
aria-label={t("common.cancel")}
|
||||
>
|
||||
<X size={14} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-3 sm:grid-cols-2">
|
||||
<label className="space-y-1.5 text-sm">
|
||||
<span className="text-muted-foreground">{t("ai.quickMessages.name")}</span>
|
||||
<input
|
||||
value={draft.name}
|
||||
onChange={(e) => handleNameChange(e.target.value)}
|
||||
placeholder={t("ai.quickMessages.name.placeholder")}
|
||||
maxLength={QUICK_MESSAGE_LIMITS.name}
|
||||
className="w-full rounded-md border border-input bg-background px-3 py-2 text-sm"
|
||||
/>
|
||||
</label>
|
||||
<label className="space-y-1.5 text-sm">
|
||||
<span className="text-muted-foreground">{t("ai.quickMessages.slug")}</span>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-muted-foreground/70">/</span>
|
||||
<input
|
||||
value={draft.slug}
|
||||
onChange={(e) => handleSlugChange(e.target.value)}
|
||||
placeholder={t("ai.quickMessages.slug.placeholder")}
|
||||
maxLength={QUICK_MESSAGE_LIMITS.slug}
|
||||
className="w-full rounded-md border border-input bg-background px-3 py-2 text-sm font-mono"
|
||||
/>
|
||||
</div>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<label className="block space-y-1.5 text-sm">
|
||||
<span className="text-muted-foreground">{t("ai.quickMessages.descriptionField")}</span>
|
||||
<input
|
||||
value={draft.description}
|
||||
onChange={(e) => setDraft((prev) => ({ ...prev, description: e.target.value }))}
|
||||
placeholder={t("ai.quickMessages.descriptionField.placeholder")}
|
||||
maxLength={QUICK_MESSAGE_LIMITS.description}
|
||||
className="w-full rounded-md border border-input bg-background px-3 py-2 text-sm"
|
||||
/>
|
||||
</label>
|
||||
|
||||
<label className="block space-y-1.5 text-sm">
|
||||
<span className="text-muted-foreground">{t("ai.quickMessages.content")}</span>
|
||||
<textarea
|
||||
value={draft.content}
|
||||
onChange={(e) => setDraft((prev) => ({ ...prev, content: e.target.value }))}
|
||||
placeholder={t("ai.quickMessages.content.placeholder")}
|
||||
rows={5}
|
||||
maxLength={QUICK_MESSAGE_LIMITS.content}
|
||||
className="w-full rounded-md border border-input bg-background px-3 py-2 text-sm font-mono resize-y min-h-[120px]"
|
||||
/>
|
||||
</label>
|
||||
|
||||
{error ? (
|
||||
<p className="text-sm text-destructive">{error}</p>
|
||||
) : null}
|
||||
|
||||
<div className="flex justify-end gap-2">
|
||||
<Button variant="outline" size="sm" onClick={resetEditor}>
|
||||
{t("common.cancel")}
|
||||
</Button>
|
||||
<Button size="sm" onClick={handleSave}>
|
||||
{t("common.save")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{sortedMessages.length > 0 ? (
|
||||
<div className="border-t border-border/60 divide-y divide-border/60">
|
||||
{sortedMessages.map((message) => (
|
||||
<div
|
||||
key={message.id}
|
||||
className="py-3"
|
||||
>
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="min-w-0 space-y-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<MessageSquare size={14} className="text-muted-foreground shrink-0" />
|
||||
<span className="text-sm font-medium">{message.name}</span>
|
||||
<span className="text-xs font-mono text-muted-foreground/80">/{message.slug}</span>
|
||||
</div>
|
||||
{message.description ? (
|
||||
<p className="text-xs text-muted-foreground leading-5">{message.description}</p>
|
||||
) : null}
|
||||
<p className="text-xs text-muted-foreground/70 line-clamp-2 whitespace-pre-wrap">
|
||||
{message.content}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-1 shrink-0">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-7 w-7 text-muted-foreground hover:text-foreground"
|
||||
onClick={() => beginEdit(message)}
|
||||
aria-label={t("ai.quickMessages.editTitle")}
|
||||
>
|
||||
<Pencil size={14} />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-7 w-7 text-muted-foreground hover:text-destructive"
|
||||
onClick={() => handleDelete(message)}
|
||||
aria-label={t("ai.quickMessages.confirmDelete", { name: message.name })}
|
||||
>
|
||||
<Trash2 size={14} />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : !showEditor ? (
|
||||
<div className="border-t border-border/60 pt-3 text-sm text-muted-foreground">
|
||||
<p className="text-sm text-muted-foreground">{t("ai.quickMessages.empty")}</p>
|
||||
</div>
|
||||
) : null}
|
||||
</SettingCard>
|
||||
</SettingsSection>
|
||||
);
|
||||
};
|
||||
235
components/settings/tabs/ai/SafetySettings.tsx
Normal file
235
components/settings/tabs/ai/SafetySettings.tsx
Normal file
@@ -0,0 +1,235 @@
|
||||
import React, { useCallback, useState } from "react";
|
||||
import { Plus, X } from "lucide-react";
|
||||
import type { AIPermissionMode } from "../../../../infrastructure/ai/types";
|
||||
import {
|
||||
DEFAULT_COMMAND_BLOCKLIST,
|
||||
MAX_COMMAND_TIMEOUT_SECONDS,
|
||||
MAX_RESPONSE_IDLE_TIMEOUT_SECONDS,
|
||||
} from "../../../../infrastructure/ai/types";
|
||||
import { useI18n } from "../../../../application/i18n/I18nProvider";
|
||||
import { Button } from "../../../ui/button";
|
||||
import { Select, SettingCard, SettingRow, SettingsAnchor, SettingsSection } from "../../settings-ui";
|
||||
|
||||
export const SafetySettings: React.FC<{
|
||||
globalPermissionMode: AIPermissionMode;
|
||||
setGlobalPermissionMode: (mode: AIPermissionMode) => void;
|
||||
commandBlocklist: string[];
|
||||
setCommandBlocklist: (value: string[]) => void;
|
||||
commandTimeout: number;
|
||||
setCommandTimeout: (value: number) => void;
|
||||
responseIdleTimeout: number;
|
||||
setResponseIdleTimeout: (value: number) => void;
|
||||
maxIterations: number;
|
||||
setMaxIterations: (value: number) => void;
|
||||
}> = ({
|
||||
globalPermissionMode,
|
||||
setGlobalPermissionMode,
|
||||
commandBlocklist,
|
||||
setCommandBlocklist,
|
||||
commandTimeout,
|
||||
setCommandTimeout,
|
||||
responseIdleTimeout,
|
||||
setResponseIdleTimeout,
|
||||
maxIterations,
|
||||
setMaxIterations,
|
||||
}) => {
|
||||
const { t } = useI18n();
|
||||
const [regexErrors, setRegexErrors] = useState<Record<number, string>>({});
|
||||
|
||||
const validatePattern = useCallback((pattern: string, idx: number): boolean => {
|
||||
if (!pattern) {
|
||||
setRegexErrors((prev) => {
|
||||
const next = { ...prev };
|
||||
delete next[idx];
|
||||
return next;
|
||||
});
|
||||
return true;
|
||||
}
|
||||
try {
|
||||
new RegExp(pattern);
|
||||
setRegexErrors((prev) => {
|
||||
const next = { ...prev };
|
||||
delete next[idx];
|
||||
return next;
|
||||
});
|
||||
return true;
|
||||
} catch (e) {
|
||||
setRegexErrors((prev) => ({
|
||||
...prev,
|
||||
[idx]: e instanceof Error ? e.message : String(e),
|
||||
}));
|
||||
return false;
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handlePatternChange = useCallback((value: string, idx: number) => {
|
||||
const next = [...commandBlocklist];
|
||||
next[idx] = value;
|
||||
validatePattern(value, idx);
|
||||
setCommandBlocklist(next);
|
||||
}, [commandBlocklist, setCommandBlocklist, validatePattern]);
|
||||
|
||||
const permissionModeOptions = [
|
||||
{ value: "observer", label: t('ai.safety.permissionMode.observer') },
|
||||
{ value: "confirm", label: t('ai.safety.permissionMode.confirm') },
|
||||
{ value: "auto", label: t('ai.safety.permissionMode.auto') },
|
||||
];
|
||||
|
||||
return (
|
||||
<SettingsSection title={t('ai.safety.title')}>
|
||||
<div className="flex flex-col gap-4">
|
||||
<SettingCard divided>
|
||||
<SettingRow
|
||||
anchorId="ai-safety-permission-mode"
|
||||
label={t('ai.safety.permissionMode')}
|
||||
description={t('ai.safety.permissionMode.description')}
|
||||
>
|
||||
<Select
|
||||
value={globalPermissionMode}
|
||||
options={permissionModeOptions}
|
||||
onChange={(val) => setGlobalPermissionMode(val as AIPermissionMode)}
|
||||
className="w-64"
|
||||
/>
|
||||
</SettingRow>
|
||||
|
||||
<SettingRow
|
||||
anchorId="ai-safety-response-idle-timeout"
|
||||
label={t('ai.safety.responseIdleTimeout')}
|
||||
description={t('ai.safety.responseIdleTimeout.description')}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
type="number"
|
||||
aria-label={t('ai.safety.responseIdleTimeout')}
|
||||
value={responseIdleTimeout}
|
||||
onChange={(e) => {
|
||||
const val = parseInt(e.target.value, 10);
|
||||
if (!isNaN(val)) setResponseIdleTimeout(val);
|
||||
}}
|
||||
min={1}
|
||||
max={MAX_RESPONSE_IDLE_TIMEOUT_SECONDS}
|
||||
className="w-20 h-9 rounded-md border border-input bg-background px-3 text-sm text-right focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
|
||||
/>
|
||||
<span className="text-xs text-muted-foreground">{t('ai.safety.responseIdleTimeout.unit')}</span>
|
||||
</div>
|
||||
</SettingRow>
|
||||
|
||||
<SettingRow
|
||||
anchorId="ai-safety-command-timeout"
|
||||
label={t('ai.safety.commandTimeout')}
|
||||
description={t('ai.safety.commandTimeout.description')}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
type="number"
|
||||
value={commandTimeout}
|
||||
onChange={(e) => {
|
||||
const val = parseInt(e.target.value, 10);
|
||||
if (!isNaN(val)) setCommandTimeout(val);
|
||||
}}
|
||||
min={1}
|
||||
max={MAX_COMMAND_TIMEOUT_SECONDS}
|
||||
className="w-20 h-9 rounded-md border border-input bg-background px-3 text-sm text-right focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
|
||||
/>
|
||||
<span className="text-xs text-muted-foreground">{t('ai.safety.commandTimeout.unit')}</span>
|
||||
</div>
|
||||
</SettingRow>
|
||||
|
||||
<SettingRow
|
||||
label={t('ai.safety.maxIterations')}
|
||||
description={t('ai.safety.maxIterations.description')}
|
||||
>
|
||||
<input
|
||||
type="number"
|
||||
value={maxIterations}
|
||||
onChange={(e) => {
|
||||
const val = parseInt(e.target.value, 10);
|
||||
if (!isNaN(val) && val > 0) setMaxIterations(val);
|
||||
}}
|
||||
min={1}
|
||||
max={100}
|
||||
className="w-20 h-9 rounded-md border border-input bg-background px-3 text-sm text-right focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
|
||||
/>
|
||||
</SettingRow>
|
||||
</SettingCard>
|
||||
|
||||
{/* Command Blocklist */}
|
||||
<SettingsAnchor anchorId="ai-safety-blocklist">
|
||||
<SettingCard padded className="space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm font-medium">{t('ai.safety.blocklist')}</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t('ai.safety.blocklist.description')}
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="text-xs"
|
||||
onClick={() => { setCommandBlocklist([...DEFAULT_COMMAND_BLOCKLIST]); setRegexErrors({}); }}
|
||||
>
|
||||
{t('ai.safety.blocklist.reset')}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
{commandBlocklist.map((pattern, idx) => (
|
||||
<div key={idx} className="space-y-0.5">
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
type="text"
|
||||
value={pattern}
|
||||
onChange={(e) => handlePatternChange(e.target.value, idx)}
|
||||
className={`flex-1 h-8 rounded-md border bg-background px-3 text-xs font-mono focus-visible:outline-none focus-visible:ring-1 ${
|
||||
regexErrors[idx]
|
||||
? 'border-destructive focus-visible:ring-destructive'
|
||||
: 'border-input focus-visible:ring-ring'
|
||||
}`}
|
||||
placeholder={t('ai.safety.blocklist.placeholder')}
|
||||
/>
|
||||
<button
|
||||
onClick={() => {
|
||||
const next = commandBlocklist.filter((_, i) => i !== idx);
|
||||
setCommandBlocklist(next);
|
||||
setRegexErrors((prev) => {
|
||||
const updated: Record<number, string> = {};
|
||||
for (const [k, v] of Object.entries(prev)) {
|
||||
const ki = Number(k);
|
||||
if (ki < idx) updated[ki] = v as string;
|
||||
else if (ki > idx) updated[ki - 1] = v as string;
|
||||
}
|
||||
return updated;
|
||||
});
|
||||
}}
|
||||
className="p-1 rounded hover:bg-destructive/10 text-muted-foreground hover:text-destructive transition-colors"
|
||||
>
|
||||
<X size={14} />
|
||||
</button>
|
||||
</div>
|
||||
{regexErrors[idx] && (
|
||||
<p className="text-[11px] text-destructive pl-1">{regexErrors[idx]}</p>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="text-xs"
|
||||
onClick={() => setCommandBlocklist([...commandBlocklist, ''])}
|
||||
>
|
||||
<Plus size={14} className="mr-1" />
|
||||
{t('ai.safety.blocklist.add')}
|
||||
</Button>
|
||||
</SettingCard>
|
||||
</SettingsAnchor>
|
||||
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t('ai.safety.note')}
|
||||
</p>
|
||||
</div>
|
||||
</SettingsSection>
|
||||
);
|
||||
};
|
||||
24
components/settings/tabs/ai/ToolAccessGuidance.test.ts
Normal file
24
components/settings/tabs/ai/ToolAccessGuidance.test.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
|
||||
import { buildMcpOnboardingPrompt } from "./ToolAccessGuidance";
|
||||
|
||||
test("buildMcpOnboardingPrompt includes launcher and discovery env", () => {
|
||||
const prompt = buildMcpOnboardingPrompt("/opt/netcatty/launcher", "/tmp/discovery.json");
|
||||
assert.match(prompt, /netcatty-external/);
|
||||
assert.match(prompt, /\/opt\/netcatty\/launcher/);
|
||||
assert.match(prompt, /NETCATTY_EXTERNAL_MCP_DISCOVERY_FILE=\/tmp\/discovery\.json/);
|
||||
assert.match(prompt, /get_environment/);
|
||||
});
|
||||
|
||||
test("buildMcpOnboardingPrompt omits env line without discovery path", () => {
|
||||
const prompt = buildMcpOnboardingPrompt("/opt/netcatty/launcher", null);
|
||||
assert.match(prompt, /\/opt\/netcatty\/launcher/);
|
||||
assert.doesNotMatch(prompt, /NETCATTY_EXTERNAL_MCP_DISCOVERY_FILE=/);
|
||||
});
|
||||
|
||||
test("buildMcpOnboardingPrompt falls back to enable-External-MCP guidance", () => {
|
||||
const prompt = buildMcpOnboardingPrompt(null, null);
|
||||
assert.match(prompt, /External MCP/);
|
||||
assert.doesNotMatch(prompt, /Command: /);
|
||||
});
|
||||
134
components/settings/tabs/ai/ToolAccessGuidance.tsx
Normal file
134
components/settings/tabs/ai/ToolAccessGuidance.tsx
Normal file
@@ -0,0 +1,134 @@
|
||||
import React, { useState } from "react";
|
||||
import { Check, Copy } from "lucide-react";
|
||||
import { useI18n } from "../../../../application/i18n/I18nProvider";
|
||||
import type { AIToolIntegrationMode } from "../../../../infrastructure/ai/types";
|
||||
import { cn } from "../../../../lib/utils";
|
||||
import { useToolAccessGuidanceState } from "../../../../application/state/useToolAccessGuidanceState";
|
||||
import { EXTERNAL_MCP_DISCOVERY_ENV_VAR } from "./ExternalMcpCard";
|
||||
|
||||
/** Build a ready-to-paste prompt so an external AI client can register Netcatty MCP itself. */
|
||||
export function buildMcpOnboardingPrompt(
|
||||
launcherPath: string | null | undefined,
|
||||
discoveryPath: string | null | undefined,
|
||||
): string {
|
||||
if (!launcherPath) {
|
||||
return [
|
||||
"Please connect Netcatty to this session via MCP.",
|
||||
"In the Netcatty desktop app, open Settings → AI → Tool Access, turn on External MCP,",
|
||||
"then copy the generated prompt from the Tool Access section and run it here.",
|
||||
"After that, list the netcatty-external MCP tools and call get_environment to verify the connection.",
|
||||
].join(" ");
|
||||
}
|
||||
const lines = [
|
||||
"Please register Netcatty's MCP server in your MCP client configuration:",
|
||||
`- Server name: netcatty-external`,
|
||||
`- Transport: local stdio`,
|
||||
`- Command: ${launcherPath}`,
|
||||
];
|
||||
if (discoveryPath) {
|
||||
lines.push(`- Environment: ${EXTERNAL_MCP_DISCOVERY_ENV_VAR}=${discoveryPath}`);
|
||||
}
|
||||
lines.push(
|
||||
"After registering, list the server's tools and call get_environment to verify the connection.",
|
||||
"Keep the Netcatty desktop app running while you use these tools.",
|
||||
);
|
||||
return lines.join("\n");
|
||||
}
|
||||
|
||||
type CopyRowProps = {
|
||||
value: string;
|
||||
label: string;
|
||||
copyLabel: string;
|
||||
copiedLabel: string;
|
||||
testId?: string;
|
||||
};
|
||||
|
||||
const CopyRow: React.FC<CopyRowProps> = ({ value, label, copyLabel, copiedLabel, testId }) => {
|
||||
const [copied, setCopied] = useState(false);
|
||||
const canCopy = Boolean(value);
|
||||
|
||||
const handleCopy = async () => {
|
||||
if (!value) return;
|
||||
try {
|
||||
await navigator.clipboard.writeText(value);
|
||||
setCopied(true);
|
||||
window.setTimeout(() => setCopied(false), 1200);
|
||||
} catch {
|
||||
// Clipboard may be unavailable; the text stays selectable in the block.
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-1.5">
|
||||
<div className="text-xs font-medium text-muted-foreground">{label}</div>
|
||||
<div className="group relative rounded-md border border-border/60 bg-muted/20">
|
||||
<pre
|
||||
data-testid={testId}
|
||||
className={cn(
|
||||
"max-h-40 overflow-auto whitespace-pre-wrap break-all px-3 py-2.5 pr-11 font-mono text-xs leading-5",
|
||||
!value && "text-muted-foreground",
|
||||
)}
|
||||
>
|
||||
{value}
|
||||
</pre>
|
||||
<button
|
||||
type="button"
|
||||
disabled={!canCopy}
|
||||
className="absolute right-1.5 top-1.5 flex h-7 w-7 items-center justify-center rounded-md text-muted-foreground transition-colors hover:bg-secondary hover:text-foreground disabled:opacity-40"
|
||||
onClick={() => void handleCopy()}
|
||||
aria-label={copied ? copiedLabel : copyLabel}
|
||||
title={copied ? copiedLabel : copyLabel}
|
||||
>
|
||||
{copied ? <Check size={14} className="text-emerald-500" /> : <Copy size={14} />}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export const ToolAccessGuidance: React.FC<{ mode: AIToolIntegrationMode }> = ({ mode }) => {
|
||||
const { t } = useI18n();
|
||||
const { skillPath, commandPrefix, mcpLauncherPath, mcpDiscoveryPath } =
|
||||
useToolAccessGuidanceState(mode);
|
||||
|
||||
if (mode === "skills") {
|
||||
return (
|
||||
<div className="rounded-md border border-border/60 bg-background/50 p-3 space-y-2">
|
||||
<p className="text-xs text-muted-foreground leading-5">
|
||||
{t("ai.toolAccess.skills.description")}
|
||||
</p>
|
||||
<CopyRow
|
||||
value={skillPath || ""}
|
||||
label={t("ai.toolAccess.skills.file")}
|
||||
copyLabel={t("ai.externalMcp.copy")}
|
||||
copiedLabel={t("ai.externalMcp.copied")}
|
||||
testId="tool-access-skill-path"
|
||||
/>
|
||||
{!skillPath ? (
|
||||
<p className="text-xs text-amber-500">{t("ai.toolAccess.skills.unavailable")}</p>
|
||||
) : null}
|
||||
{commandPrefix ? (
|
||||
<p className="text-xs text-muted-foreground/80 font-mono break-all">{commandPrefix}</p>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="rounded-md border border-border/60 bg-background/50 p-3 space-y-2">
|
||||
<p className="text-xs text-muted-foreground leading-5">
|
||||
{t("ai.toolAccess.mcpPrompt.description")}
|
||||
</p>
|
||||
<CopyRow
|
||||
value={buildMcpOnboardingPrompt(mcpLauncherPath, mcpDiscoveryPath)}
|
||||
label={t("ai.toolAccess.mcpPrompt.title")}
|
||||
copyLabel={t("ai.externalMcp.copy")}
|
||||
copiedLabel={t("ai.externalMcp.copied")}
|
||||
testId="tool-access-mcp-prompt"
|
||||
/>
|
||||
{!mcpLauncherPath ? (
|
||||
<p className="text-xs text-amber-500">{t("ai.toolAccess.mcpPrompt.enableHint")}</p>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
211
components/settings/tabs/ai/WebSearchSettings.tsx
Normal file
211
components/settings/tabs/ai/WebSearchSettings.tsx
Normal file
@@ -0,0 +1,211 @@
|
||||
import React, { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { Eye, EyeOff } from "lucide-react";
|
||||
import type { WebSearchConfig, WebSearchProviderId } from "../../../../infrastructure/ai/types";
|
||||
import { WEB_SEARCH_PROVIDER_PRESETS } from "../../../../infrastructure/ai/types";
|
||||
import { encryptField, decryptField } from "../../../../infrastructure/persistence/secureFieldAdapter";
|
||||
import { useI18n } from "../../../../application/i18n/I18nProvider";
|
||||
import { Select, SettingCard, SettingRow, SettingsSection, Toggle } from "../../settings-ui";
|
||||
|
||||
const SEARCH_ICON_PATHS: Record<WebSearchProviderId, string> = {
|
||||
tavily: "/ai/search/tavily.svg",
|
||||
exa: "/ai/search/exa.png",
|
||||
bocha: "/ai/search/bocha.webp",
|
||||
zhipu: "/ai/search/zhipu.png",
|
||||
searxng: "/ai/search/searxng.svg",
|
||||
};
|
||||
|
||||
const SearchProviderIcon: React.FC<{ providerId: WebSearchProviderId }> = ({ providerId }) => (
|
||||
<img
|
||||
src={SEARCH_ICON_PATHS[providerId]}
|
||||
alt=""
|
||||
className="w-4 h-4 shrink-0"
|
||||
/>
|
||||
);
|
||||
|
||||
const PROVIDER_OPTIONS: Array<{ value: WebSearchProviderId; label: string; icon: React.ReactNode }> = Object.entries(
|
||||
WEB_SEARCH_PROVIDER_PRESETS,
|
||||
).map(([id, preset]) => ({
|
||||
value: id as WebSearchProviderId,
|
||||
label: preset.name,
|
||||
icon: <SearchProviderIcon providerId={id as WebSearchProviderId} />,
|
||||
}));
|
||||
|
||||
export const WebSearchSettings: React.FC<{
|
||||
webSearchConfig: WebSearchConfig | null;
|
||||
setWebSearchConfig: (config: WebSearchConfig | null) => void;
|
||||
}> = ({ webSearchConfig, setWebSearchConfig }) => {
|
||||
const { t } = useI18n();
|
||||
const [apiKeyInput, setApiKeyInput] = useState("");
|
||||
const [showApiKey, setShowApiKey] = useState(false);
|
||||
const [isDecrypting, setIsDecrypting] = useState(false);
|
||||
|
||||
const config = useMemo(() => webSearchConfig ?? {
|
||||
providerId: "tavily" as WebSearchProviderId,
|
||||
enabled: false,
|
||||
maxResults: 5,
|
||||
}, [webSearchConfig]);
|
||||
|
||||
// Ref to always read the latest config in async callbacks (avoids stale closure)
|
||||
const configRef = useRef(config);
|
||||
configRef.current = config;
|
||||
|
||||
const preset = WEB_SEARCH_PROVIDER_PRESETS[config.providerId];
|
||||
|
||||
// Decrypt API key on mount or when provider changes (with cancellation guard)
|
||||
const decryptSeqRef = useRef(0);
|
||||
useEffect(() => {
|
||||
if (config.apiKey) {
|
||||
const seq = ++decryptSeqRef.current;
|
||||
setIsDecrypting(true);
|
||||
decryptField(config.apiKey)
|
||||
.then((decrypted) => {
|
||||
if (decryptSeqRef.current === seq) setApiKeyInput(decrypted ?? "");
|
||||
})
|
||||
.catch(() => {
|
||||
if (decryptSeqRef.current === seq) setApiKeyInput(config.apiKey ?? "");
|
||||
})
|
||||
.finally(() => {
|
||||
if (decryptSeqRef.current === seq) setIsDecrypting(false);
|
||||
});
|
||||
} else {
|
||||
decryptSeqRef.current++;
|
||||
setApiKeyInput("");
|
||||
setIsDecrypting(false);
|
||||
}
|
||||
}, [config.apiKey, config.providerId]);
|
||||
|
||||
const updateConfig = useCallback(
|
||||
(updates: Partial<WebSearchConfig>) => {
|
||||
setWebSearchConfig({ ...configRef.current, ...updates });
|
||||
},
|
||||
[setWebSearchConfig],
|
||||
);
|
||||
|
||||
const handleProviderChange = useCallback(
|
||||
(val: string) => {
|
||||
const providerId = val as WebSearchProviderId;
|
||||
const newPreset = WEB_SEARCH_PROVIDER_PRESETS[providerId];
|
||||
setWebSearchConfig({
|
||||
...configRef.current,
|
||||
providerId,
|
||||
apiKey: undefined,
|
||||
apiHost: newPreset.defaultApiHost || undefined,
|
||||
});
|
||||
setApiKeyInput("");
|
||||
},
|
||||
[setWebSearchConfig],
|
||||
);
|
||||
|
||||
// Sequence counter for blur saves — prevents out-of-order encryption results
|
||||
const blurSeqRef = useRef(0);
|
||||
const handleApiKeyBlur = useCallback(async () => {
|
||||
if (!apiKeyInput.trim()) {
|
||||
blurSeqRef.current++;
|
||||
updateConfig({ apiKey: undefined });
|
||||
return;
|
||||
}
|
||||
const seq = ++blurSeqRef.current;
|
||||
const providerAtBlur = configRef.current.providerId;
|
||||
const encrypted = await encryptField(apiKeyInput.trim());
|
||||
// Only apply if this is still the latest blur and provider hasn't changed
|
||||
if (blurSeqRef.current === seq && configRef.current.providerId === providerAtBlur) {
|
||||
updateConfig({ apiKey: encrypted });
|
||||
}
|
||||
}, [apiKeyInput, updateConfig]);
|
||||
|
||||
return (
|
||||
<SettingsSection title={t("ai.webSearch.title")}>
|
||||
<SettingCard divided>
|
||||
<SettingRow
|
||||
anchorId="ai-web-search-enable"
|
||||
label={t("ai.webSearch.enable")}
|
||||
description={t("ai.webSearch.enable.description")}
|
||||
>
|
||||
<Toggle
|
||||
checked={config.enabled}
|
||||
onChange={(enabled) => updateConfig({ enabled })}
|
||||
/>
|
||||
</SettingRow>
|
||||
|
||||
{/* Provider */}
|
||||
<SettingRow
|
||||
anchorId="ai-web-search-provider"
|
||||
label={t("ai.webSearch.provider")}
|
||||
description={t("ai.webSearch.provider.description")}
|
||||
>
|
||||
<Select
|
||||
value={config.providerId}
|
||||
options={PROVIDER_OPTIONS}
|
||||
onChange={handleProviderChange}
|
||||
className="w-48"
|
||||
/>
|
||||
</SettingRow>
|
||||
|
||||
{/* API Key (hidden for SearXNG) */}
|
||||
{preset.requiresApiKey && (
|
||||
<SettingRow
|
||||
label={t("ai.webSearch.apiKey")}
|
||||
description={t("ai.webSearch.apiKey.description")}
|
||||
>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<input
|
||||
type={showApiKey ? "text" : "password"}
|
||||
value={isDecrypting ? "" : apiKeyInput}
|
||||
placeholder={isDecrypting ? t("ai.providers.apiKey.decrypting") : t("ai.webSearch.apiKey.placeholder")}
|
||||
onChange={(e) => setApiKeyInput(e.target.value)}
|
||||
onBlur={() => void handleApiKeyBlur()}
|
||||
className="w-64 h-9 rounded-md border border-input bg-background px-3 text-sm focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
|
||||
disabled={isDecrypting}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowApiKey(!showApiKey)}
|
||||
className="p-1.5 rounded hover:bg-muted text-muted-foreground"
|
||||
>
|
||||
{showApiKey ? <EyeOff size={14} /> : <Eye size={14} />}
|
||||
</button>
|
||||
</div>
|
||||
</SettingRow>
|
||||
)}
|
||||
|
||||
{/* API Host */}
|
||||
<SettingRow
|
||||
label={t("ai.webSearch.apiHost")}
|
||||
description={
|
||||
config.providerId === "searxng"
|
||||
? t("ai.webSearch.apiHost.searxngDescription")
|
||||
: t("ai.webSearch.apiHost.description")
|
||||
}
|
||||
>
|
||||
<input
|
||||
type="text"
|
||||
value={config.apiHost ?? preset.defaultApiHost}
|
||||
onChange={(e) => updateConfig({ apiHost: e.target.value || undefined })}
|
||||
placeholder={preset.defaultApiHost || "https://..."}
|
||||
className="w-64 h-9 rounded-md border border-input bg-background px-3 text-sm focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
|
||||
/>
|
||||
</SettingRow>
|
||||
|
||||
{/* Max Results */}
|
||||
<SettingRow
|
||||
label={t("ai.webSearch.maxResults")}
|
||||
description={t("ai.webSearch.maxResults.description")}
|
||||
>
|
||||
<input
|
||||
type="number"
|
||||
value={config.maxResults ?? 5}
|
||||
onChange={(e) => {
|
||||
const val = parseInt(e.target.value, 10);
|
||||
if (!isNaN(val) && val >= 1 && val <= 20) {
|
||||
updateConfig({ maxResults: val });
|
||||
}
|
||||
}}
|
||||
min={1}
|
||||
max={20}
|
||||
className="w-20 h-9 rounded-md border border-input bg-background px-3 text-sm text-right focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
|
||||
/>
|
||||
</SettingRow>
|
||||
</SettingCard>
|
||||
</SettingsSection>
|
||||
);
|
||||
};
|
||||
75
components/settings/tabs/ai/claudeConfigEnv.ts
Normal file
75
components/settings/tabs/ai/claudeConfigEnv.ts
Normal file
@@ -0,0 +1,75 @@
|
||||
/**
|
||||
* Pure helpers for the Claude Code card's "config directory + environment
|
||||
* variables" editor. The managed Claude agent stores everything in its
|
||||
* ExternalAgentConfig.env; this splits that into the editable pieces and
|
||||
* recombines them. CLAUDE_CODE_EXECUTABLE is owned by path discovery, so it
|
||||
* is preserved across edits but never shown in the env editor.
|
||||
*/
|
||||
|
||||
const CONFIG_DIR_KEY = "CLAUDE_CONFIG_DIR";
|
||||
// netcatty marker carrying the claude SDK `settings` option (a settings.json
|
||||
// path or inline JSON). Extracted in the main process and passed to the SDK as
|
||||
// `options.settings`; never sent to the agent as a real env var. Additive to —
|
||||
// and independent of — CLAUDE_CONFIG_DIR.
|
||||
const SETTINGS_KEY = "NETCATTY_CLAUDE_SETTINGS";
|
||||
const MANAGED_KEYS = new Set(["CLAUDE_CODE_EXECUTABLE", CONFIG_DIR_KEY, SETTINGS_KEY]);
|
||||
|
||||
export function parseEnvLines(text: string): Record<string, string> {
|
||||
const out: Record<string, string> = {};
|
||||
for (const rawLine of String(text || "").split("\n")) {
|
||||
const line = rawLine.trim();
|
||||
if (!line || line.startsWith("#")) continue;
|
||||
const eq = line.indexOf("=");
|
||||
if (eq <= 0) continue;
|
||||
const key = line.slice(0, eq).trim();
|
||||
const value = line.slice(eq + 1).trim();
|
||||
if (key) out[key] = value;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
export function serializeEnvLines(env: Record<string, string>): string {
|
||||
return Object.entries(env)
|
||||
.map(([k, v]) => `${k}=${v}`)
|
||||
.join("\n");
|
||||
}
|
||||
|
||||
export function splitClaudeEnv(
|
||||
env: Record<string, string> | undefined,
|
||||
): { configDir: string; settingsPath: string; envText: string } {
|
||||
if (!env) return { configDir: "", settingsPath: "", envText: "" };
|
||||
const configDir = env[CONFIG_DIR_KEY] ?? "";
|
||||
const settingsPath = env[SETTINGS_KEY] ?? "";
|
||||
const rest: Record<string, string> = {};
|
||||
for (const [k, v] of Object.entries(env)) {
|
||||
if (MANAGED_KEYS.has(k)) continue;
|
||||
rest[k] = v;
|
||||
}
|
||||
return { configDir, settingsPath, envText: serializeEnvLines(rest) };
|
||||
}
|
||||
|
||||
export function buildClaudeEnv(
|
||||
prevEnv: Record<string, string> | undefined,
|
||||
configDir: string,
|
||||
settingsPath: string,
|
||||
envText: string,
|
||||
): Record<string, string> | undefined {
|
||||
const next: Record<string, string> = {};
|
||||
// Preserve discovery-owned key if present.
|
||||
const exe = prevEnv?.CLAUDE_CODE_EXECUTABLE;
|
||||
if (exe) next.CLAUDE_CODE_EXECUTABLE = exe;
|
||||
|
||||
const trimmedDir = String(configDir || "").trim();
|
||||
if (trimmedDir) next[CONFIG_DIR_KEY] = trimmedDir;
|
||||
|
||||
const trimmedSettings = String(settingsPath || "").trim();
|
||||
if (trimmedSettings) next[SETTINGS_KEY] = trimmedSettings;
|
||||
|
||||
// Drop managed keys if a user typed them into the free-text editor — the
|
||||
// dedicated fields and path discovery own these keys.
|
||||
const parsed = parseEnvLines(envText);
|
||||
for (const key of MANAGED_KEYS) delete parsed[key];
|
||||
Object.assign(next, parsed);
|
||||
|
||||
return Object.keys(next).length > 0 ? next : undefined;
|
||||
}
|
||||
73
components/settings/tabs/ai/codebuddyConfigEnv.ts
Normal file
73
components/settings/tabs/ai/codebuddyConfigEnv.ts
Normal file
@@ -0,0 +1,73 @@
|
||||
/**
|
||||
* Pure helpers for the CodeBuddy card's environment variables editor.
|
||||
* The managed CodeBuddy agent stores everything in its
|
||||
* ExternalAgentConfig.env; this splits that into the editable pieces and
|
||||
* recombines them.
|
||||
*
|
||||
* CODEBUDDY_CODE_PATH is owned by path discovery, so it is preserved across
|
||||
* edits but never shown in the env editor.
|
||||
*
|
||||
* The SDK supports CODEBUDDY_API_KEY (via options.env), but the CLI itself
|
||||
* does not. CODEBUDDY_INTERNET_ENVIRONMENT is managed as a first-class field.
|
||||
* Users who need CODEBUDDY_API_KEY or CODEBUDDY_AUTH_TOKEN should set them
|
||||
* in the free-text environment editor or in their shell profile.
|
||||
*/
|
||||
|
||||
const INTERNET_ENV_VAR = "CODEBUDDY_INTERNET_ENVIRONMENT";
|
||||
const CODE_PATH_KEY = "CODEBUDDY_CODE_PATH";
|
||||
const MANAGED_KEYS = new Set([INTERNET_ENV_VAR, CODE_PATH_KEY]);
|
||||
|
||||
export function parseEnvLines(text: string): Record<string, string> {
|
||||
const out: Record<string, string> = {};
|
||||
for (const rawLine of String(text || "").split("\n")) {
|
||||
const line = rawLine.trim();
|
||||
if (!line || line.startsWith("#")) continue;
|
||||
const eq = line.indexOf("=");
|
||||
if (eq <= 0) continue;
|
||||
const key = line.slice(0, eq).trim();
|
||||
const value = line.slice(eq + 1).trim();
|
||||
if (key) out[key] = value;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
export function serializeEnvLines(env: Record<string, string>): string {
|
||||
return Object.entries(env)
|
||||
.map(([k, v]) => `${k}=${v}`)
|
||||
.join("\n");
|
||||
}
|
||||
|
||||
export function splitCodebuddyEnv(
|
||||
env: Record<string, string> | undefined,
|
||||
): { internetEnv: string; envText: string } {
|
||||
if (!env) return { internetEnv: "", envText: "" };
|
||||
const internetEnv = env[INTERNET_ENV_VAR] ?? "";
|
||||
const rest: Record<string, string> = {};
|
||||
for (const [k, v] of Object.entries(env)) {
|
||||
if (MANAGED_KEYS.has(k)) continue;
|
||||
rest[k] = v;
|
||||
}
|
||||
return { internetEnv, envText: serializeEnvLines(rest) };
|
||||
}
|
||||
|
||||
export function buildCodebuddyEnv(
|
||||
prevEnv: Record<string, string> | undefined,
|
||||
internetEnv: string,
|
||||
envText: string,
|
||||
): Record<string, string> | undefined {
|
||||
const next: Record<string, string> = {};
|
||||
|
||||
const trimmedInternetEnv = String(internetEnv || "").trim();
|
||||
if (trimmedInternetEnv) next[INTERNET_ENV_VAR] = trimmedInternetEnv;
|
||||
|
||||
// Preserve auto-injected CODEBUDDY_CODE_PATH across edits.
|
||||
const codePath = prevEnv?.[CODE_PATH_KEY];
|
||||
if (codePath) next[CODE_PATH_KEY] = codePath;
|
||||
|
||||
// Drop managed keys if a user typed them into the free-text editor
|
||||
const parsed = parseEnvLines(envText);
|
||||
for (const key of MANAGED_KEYS) delete parsed[key];
|
||||
Object.assign(next, parsed);
|
||||
|
||||
return Object.keys(next).length > 0 ? next : undefined;
|
||||
}
|
||||
47
components/settings/tabs/ai/dropdownLayout.test.ts
Normal file
47
components/settings/tabs/ai/dropdownLayout.test.ts
Normal file
@@ -0,0 +1,47 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { readFileSync } from "node:fs";
|
||||
import test from "node:test";
|
||||
|
||||
import { ADD_PROVIDER_MENU_CLASS } from "./AddProviderDropdown.tsx";
|
||||
import { getModelSuggestionClassName, getModelSuggestionsPresentation } from "./ModelSelector.tsx";
|
||||
|
||||
const modelSelectorSource = readFileSync(new URL("./ModelSelector.tsx", import.meta.url), "utf8");
|
||||
|
||||
test("add provider menu opens toward the left edge of the button and stays width-bounded", () => {
|
||||
assert.match(ADD_PROVIDER_MENU_CLASS, /right-0/);
|
||||
assert.doesNotMatch(ADD_PROVIDER_MENU_CLASS, /left-0/);
|
||||
assert.match(ADD_PROVIDER_MENU_CLASS, /max-w-\[calc\(100vw-2rem\)\]/);
|
||||
});
|
||||
|
||||
test("preset model suggestions stay visible while remote models are loading", () => {
|
||||
assert.deepEqual(
|
||||
getModelSuggestionsPresentation({
|
||||
suggestionsLength: 2,
|
||||
isLoading: true,
|
||||
error: null,
|
||||
hasFetched: false,
|
||||
hasPresetModels: true,
|
||||
}),
|
||||
{ showSuggestions: true, emptyState: null, footerState: "loading" },
|
||||
);
|
||||
});
|
||||
|
||||
test("preset model suggestions stay visible when remote model discovery fails", () => {
|
||||
assert.deepEqual(
|
||||
getModelSuggestionsPresentation({
|
||||
suggestionsLength: 2,
|
||||
isLoading: false,
|
||||
error: "Failed to fetch models",
|
||||
hasFetched: false,
|
||||
hasPresetModels: true,
|
||||
}),
|
||||
{ showSuggestions: true, emptyState: null, footerState: "error" },
|
||||
);
|
||||
});
|
||||
|
||||
test("selected model suggestions use the matching accent foreground", () => {
|
||||
assert.match(getModelSuggestionClassName(true), /bg-accent/);
|
||||
assert.match(getModelSuggestionClassName(true), /text-accent-foreground/);
|
||||
assert.match(getModelSuggestionClassName(false), /hover:text-accent-foreground/);
|
||||
assert.match(modelSelectorSource, /<Check size=\{12\} className="text-accent-foreground shrink-0" \/>/);
|
||||
});
|
||||
239
components/settings/tabs/ai/managedAgentState.ts
Normal file
239
components/settings/tabs/ai/managedAgentState.ts
Normal file
@@ -0,0 +1,239 @@
|
||||
import type { ExternalAgentConfig } from "../../../../infrastructure/ai/types";
|
||||
import {
|
||||
type ManagedAgentKey,
|
||||
isPathLikeCommand,
|
||||
} from "../../../../infrastructure/ai/managedAgents";
|
||||
import type { AgentPathInfo } from "./types";
|
||||
import { AGENT_DEFAULTS, isCursorAvailableForMode } from "./types";
|
||||
import { buildCodebuddyEnv } from "./codebuddyConfigEnv";
|
||||
|
||||
function getAutoManagedAgentStoredPath(
|
||||
agents: ExternalAgentConfig[],
|
||||
agentKey: ManagedAgentKey,
|
||||
): string | null {
|
||||
const managed = agents.find((agent) => agent.id === `discovered_${agentKey}`);
|
||||
if (managed?.commandSource === "auto") return null;
|
||||
return isPathLikeCommand(managed?.command) ? managed?.command ?? null : null;
|
||||
}
|
||||
|
||||
export function areExternalAgentListsEqual(
|
||||
left: ExternalAgentConfig[],
|
||||
right: ExternalAgentConfig[],
|
||||
): boolean {
|
||||
if (left.length !== right.length) return false;
|
||||
return left.every((agent, index) => JSON.stringify(agent) === JSON.stringify(right[index]));
|
||||
}
|
||||
|
||||
export function buildManagedAgentState(
|
||||
prevAgents: ExternalAgentConfig[],
|
||||
defaultAgentId: string,
|
||||
agentKey: ManagedAgentKey,
|
||||
pathInfo: AgentPathInfo | null,
|
||||
commandSource: "manual" | "auto" = "auto",
|
||||
): { agents: ExternalAgentConfig[]; defaultAgentId: string } {
|
||||
const managedId = `discovered_${agentKey}`;
|
||||
const managedAgents = prevAgents.filter((agent) => agent.id === managedId);
|
||||
const otherAgents = prevAgents.filter((agent) => agent.id !== managedId);
|
||||
|
||||
if (!pathInfo?.available || !pathInfo.path) {
|
||||
const existingManaged = managedAgents.find((agent) => agent.id === managedId);
|
||||
if (agentKey === "cursor" && (existingManaged?.apiKey || existingManaged?.cursorAuthMode === "cli-login")) {
|
||||
const defaults = AGENT_DEFAULTS[agentKey];
|
||||
const {
|
||||
acpCommand: _legacyCommand,
|
||||
acpArgs: _legacyArgs,
|
||||
...existingManagedWithoutLegacy
|
||||
} = existingManaged;
|
||||
return {
|
||||
agents: [
|
||||
...otherAgents,
|
||||
{
|
||||
...existingManagedWithoutLegacy,
|
||||
...defaults,
|
||||
id: managedId,
|
||||
command: pathInfo?.path || existingManaged.command || "cursor",
|
||||
// Preserve enable preference when probe is temporarily unavailable
|
||||
// (e.g. wrong apiKeyPresent gating). Send requires available too.
|
||||
enabled: existingManaged.enabled ?? true,
|
||||
available: false,
|
||||
// Preserve stored API key across mode / temporary unavailability.
|
||||
...(existingManaged.apiKey ? { apiKey: existingManaged.apiKey } : {}),
|
||||
cursorAuthMode: existingManaged.cursorAuthMode === "cli-login" ? "cli-login" : "api-key",
|
||||
},
|
||||
],
|
||||
defaultAgentId: existingManaged.id === defaultAgentId ? "catty" : defaultAgentId,
|
||||
};
|
||||
}
|
||||
if (agentKey === "codebuddy") {
|
||||
const hasSavedCodebuddyConfig = Boolean(
|
||||
(existingManaged?.env && Object.keys(existingManaged.env).length > 0) ||
|
||||
(
|
||||
existingManaged?.codebuddyOptions &&
|
||||
Object.keys(existingManaged.codebuddyOptions).length > 0
|
||||
),
|
||||
);
|
||||
if (hasSavedCodebuddyConfig) {
|
||||
return {
|
||||
agents: [
|
||||
...otherAgents,
|
||||
{
|
||||
...existingManaged,
|
||||
...AGENT_DEFAULTS.codebuddy,
|
||||
id: managedId,
|
||||
command: existingManaged.command || "codebuddy",
|
||||
enabled: false,
|
||||
available: false,
|
||||
},
|
||||
],
|
||||
defaultAgentId: existingManaged.id === defaultAgentId ? "catty" : defaultAgentId,
|
||||
};
|
||||
}
|
||||
}
|
||||
return {
|
||||
agents: otherAgents,
|
||||
defaultAgentId: managedAgents.some((agent) => agent.id === defaultAgentId)
|
||||
? "catty"
|
||||
: defaultAgentId,
|
||||
};
|
||||
}
|
||||
|
||||
const existingManaged = managedAgents.find((agent) => agent.id === managedId);
|
||||
const {
|
||||
acpCommand: _legacyCommand,
|
||||
acpArgs: _legacyArgs,
|
||||
...existingManagedWithoutLegacy
|
||||
} = existingManaged ?? {};
|
||||
const defaults = AGENT_DEFAULTS[agentKey];
|
||||
const managedEnv =
|
||||
agentKey === "claude"
|
||||
? { ...(existingManaged?.env ?? {}), CLAUDE_CODE_EXECUTABLE: pathInfo.path }
|
||||
: agentKey === "codebuddy"
|
||||
? { ...(existingManaged?.env ?? {}), CODEBUDDY_CODE_PATH: pathInfo.path }
|
||||
: agentKey === "opencode"
|
||||
? { ...(existingManaged?.env ?? {}), OPENCODE_BIN: pathInfo.path }
|
||||
: existingManaged?.env;
|
||||
const cursorAuthMode = agentKey === "cursor"
|
||||
? (existingManaged?.cursorAuthMode
|
||||
?? (pathInfo.authSource === "cli-login" || pathInfo.cliLoginOk ? "cli-login" : "api-key"))
|
||||
: undefined;
|
||||
const cursorModeAvailable = agentKey === "cursor"
|
||||
? isCursorAvailableForMode(pathInfo, cursorAuthMode === "cli-login" ? "cli-login" : "api-key", {
|
||||
hasStoredApiKey: Boolean(existingManaged?.apiKey),
|
||||
})
|
||||
: true;
|
||||
|
||||
const nextManagedAgent: ExternalAgentConfig = {
|
||||
...existingManagedWithoutLegacy,
|
||||
...defaults,
|
||||
id: managedId,
|
||||
command: agentKey === "cursor" && cursorAuthMode === "cli-login"
|
||||
? (pathInfo.cliBinPath || pathInfo.path)
|
||||
: pathInfo.path,
|
||||
commandSource,
|
||||
// Persist probed --version so the chat model picker can gate GPT-5.6+
|
||||
// even when this custom path is not the PATH discovery binary.
|
||||
...(pathInfo.version ? { cliVersion: pathInfo.version } : {}),
|
||||
...(managedEnv ? { env: managedEnv } : {}),
|
||||
available: cursorModeAvailable,
|
||||
// Do not force-disable when only the current auth mode is temporarily
|
||||
// unavailable (user may switch modes). Send paths already require available.
|
||||
enabled: managedAgents.length === 0
|
||||
|| (agentKey === "codebuddy" && existingManaged && !isPathLikeCommand(existingManaged.command))
|
||||
? true
|
||||
: managedAgents.some((agent) => agent.enabled) || managedAgents.every((agent) => agent.available === false),
|
||||
...(agentKey === "cursor" ? {
|
||||
cursorAuthMode,
|
||||
// Keep stored API key in both modes; CLI turns omit it via env wiring.
|
||||
...(existingManaged?.apiKey ? { apiKey: existingManaged.apiKey } : {}),
|
||||
} : {}),
|
||||
};
|
||||
|
||||
return {
|
||||
agents: [...otherAgents, nextManagedAgent],
|
||||
defaultAgentId: managedAgents.some((agent) => agent.id === defaultAgentId)
|
||||
? managedId
|
||||
: defaultAgentId,
|
||||
};
|
||||
}
|
||||
|
||||
export function updateCodebuddyManagedEnv(
|
||||
prevAgents: ExternalAgentConfig[],
|
||||
internetEnv: string,
|
||||
envText: string,
|
||||
): ExternalAgentConfig[] {
|
||||
const managedId = "discovered_codebuddy";
|
||||
const existingManaged = prevAgents.find((agent) => agent.id === managedId);
|
||||
const nextEnv = buildCodebuddyEnv(existingManaged?.env, internetEnv, envText);
|
||||
|
||||
if (existingManaged) {
|
||||
if (!nextEnv && !isPathLikeCommand(existingManaged.command)) {
|
||||
return prevAgents.filter((agent) => agent.id !== managedId);
|
||||
}
|
||||
return prevAgents.map((agent) =>
|
||||
agent.id === managedId
|
||||
? { ...agent, ...(nextEnv ? { env: nextEnv } : { env: undefined }) }
|
||||
: agent,
|
||||
);
|
||||
}
|
||||
|
||||
if (!nextEnv) return prevAgents;
|
||||
|
||||
return [
|
||||
...prevAgents,
|
||||
{
|
||||
...AGENT_DEFAULTS.codebuddy,
|
||||
id: managedId,
|
||||
command: "codebuddy",
|
||||
enabled: false,
|
||||
env: nextEnv,
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
export function updateCodebuddyManagedOptions(
|
||||
prevAgents: ExternalAgentConfig[],
|
||||
options: ExternalAgentConfig['codebuddyOptions'],
|
||||
): ExternalAgentConfig[] {
|
||||
const managedId = "discovered_codebuddy";
|
||||
const existingManaged = prevAgents.find((agent) => agent.id === managedId);
|
||||
|
||||
if (existingManaged) {
|
||||
if (
|
||||
!options &&
|
||||
(!existingManaged.env || Object.keys(existingManaged.env).length === 0) &&
|
||||
!isPathLikeCommand(existingManaged.command)
|
||||
) {
|
||||
return prevAgents.filter((agent) => agent.id !== managedId);
|
||||
}
|
||||
return prevAgents.map((agent) =>
|
||||
agent.id === managedId
|
||||
? { ...agent, codebuddyOptions: options }
|
||||
: agent,
|
||||
);
|
||||
}
|
||||
|
||||
if (!options) return prevAgents;
|
||||
|
||||
return [
|
||||
...prevAgents,
|
||||
{
|
||||
...AGENT_DEFAULTS.codebuddy,
|
||||
id: managedId,
|
||||
command: "codebuddy",
|
||||
enabled: false,
|
||||
codebuddyOptions: options,
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
export function getInitialManagedAgentPaths(agents: ExternalAgentConfig[]) {
|
||||
return {
|
||||
codex: getAutoManagedAgentStoredPath(agents, "codex") ?? "",
|
||||
claude: getAutoManagedAgentStoredPath(agents, "claude") ?? "",
|
||||
copilot: getAutoManagedAgentStoredPath(agents, "copilot") ?? "",
|
||||
cursor: getAutoManagedAgentStoredPath(agents, "cursor") ?? "",
|
||||
codebuddy: getAutoManagedAgentStoredPath(agents, "codebuddy") ?? "",
|
||||
opencode: getAutoManagedAgentStoredPath(agents, "opencode") ?? "",
|
||||
grok: getAutoManagedAgentStoredPath(agents, "grok") ?? "",
|
||||
};
|
||||
}
|
||||
63
components/settings/tabs/ai/modelMetadata.test.ts
Normal file
63
components/settings/tabs/ai/modelMetadata.test.ts
Normal file
@@ -0,0 +1,63 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
import {
|
||||
mergeModelContextWindow,
|
||||
parseFetchedModels,
|
||||
} from "./modelMetadata.ts";
|
||||
import { buildModelSuggestions } from "./ModelSelector.tsx";
|
||||
|
||||
test("parseFetchedModels reads common context window fields from model list responses", () => {
|
||||
assert.deepEqual(
|
||||
parseFetchedModels({
|
||||
data: [
|
||||
{ id: "openrouter/model", name: "OpenRouter Model", context_length: 131072 },
|
||||
{ id: "vercel/model", context_window: 262144 },
|
||||
{ id: "custom/model", contextWindow: 65536 },
|
||||
],
|
||||
}),
|
||||
[
|
||||
{ id: "openrouter/model", name: "OpenRouter Model", contextWindow: 131072 },
|
||||
{ id: "vercel/model", contextWindow: 262144 },
|
||||
{ id: "custom/model", contextWindow: 65536 },
|
||||
],
|
||||
);
|
||||
});
|
||||
|
||||
test("mergeModelContextWindow stores valid discovered model windows only", () => {
|
||||
assert.deepEqual(
|
||||
mergeModelContextWindow(undefined, "qwen", 262144),
|
||||
{ qwen: 262144 },
|
||||
);
|
||||
assert.deepEqual(
|
||||
mergeModelContextWindow({ old: 8192 }, "qwen", undefined),
|
||||
{ old: 8192 },
|
||||
);
|
||||
});
|
||||
|
||||
test("buildModelSuggestions uses provider presets before fetched model discovery", () => {
|
||||
assert.deepEqual(
|
||||
buildModelSuggestions({
|
||||
presetModels: ["qwen3.6-plus", "qwen3.6-flash"],
|
||||
fetchedModels: [],
|
||||
hasFetched: false,
|
||||
value: "plus",
|
||||
}),
|
||||
[{ id: "qwen3.6-plus" }],
|
||||
);
|
||||
});
|
||||
|
||||
test("buildModelSuggestions merges fetched and preset models without duplicates", () => {
|
||||
assert.deepEqual(
|
||||
buildModelSuggestions({
|
||||
presetModels: ["kimi-k2.6", "moonshot-v1-128k"],
|
||||
fetchedModels: [
|
||||
{ id: "kimi-k2.6", name: "Kimi K2.6" },
|
||||
{ id: "moonshot-v1-8k", name: "Moonshot 8K" },
|
||||
],
|
||||
hasFetched: true,
|
||||
value: "",
|
||||
}).map((model) => model.id),
|
||||
["kimi-k2.6", "moonshot-v1-128k", "moonshot-v1-8k"],
|
||||
);
|
||||
});
|
||||
44
components/settings/tabs/ai/modelMetadata.ts
Normal file
44
components/settings/tabs/ai/modelMetadata.ts
Normal file
@@ -0,0 +1,44 @@
|
||||
import { sanitizeContextWindow } from "../../../../infrastructure/ai/contextCompaction";
|
||||
import type { FetchedModel } from "./types";
|
||||
|
||||
export function parseFetchedModels(parsed: unknown): FetchedModel[] {
|
||||
const record = parsed && typeof parsed === "object" ? parsed as Record<string, unknown> : {};
|
||||
const rawModels = Array.isArray(record.data)
|
||||
? record.data
|
||||
: Array.isArray(record.models)
|
||||
? record.models
|
||||
: [];
|
||||
|
||||
return rawModels
|
||||
.map((raw): FetchedModel | null => {
|
||||
if (!raw || typeof raw !== "object") return null;
|
||||
const model = raw as Record<string, unknown>;
|
||||
if (typeof model.id !== "string" || !model.id) return null;
|
||||
return {
|
||||
id: model.id,
|
||||
...(typeof model.name === "string" ? { name: model.name } : {}),
|
||||
...(resolveModelContextWindow(model) != null ? { contextWindow: resolveModelContextWindow(model) } : {}),
|
||||
};
|
||||
})
|
||||
.filter((model): model is FetchedModel => model != null);
|
||||
}
|
||||
|
||||
export function mergeModelContextWindow(
|
||||
current: Record<string, number> | undefined,
|
||||
modelId: string,
|
||||
contextWindow: number | undefined,
|
||||
): Record<string, number> | undefined {
|
||||
const sanitized = sanitizeContextWindow(contextWindow);
|
||||
if (!modelId || sanitized == null) return current;
|
||||
return { ...(current ?? {}), [modelId]: sanitized };
|
||||
}
|
||||
|
||||
function resolveModelContextWindow(model: Record<string, unknown>): number | undefined {
|
||||
return sanitizeContextWindow(
|
||||
model.context_length
|
||||
?? model.context_window
|
||||
?? model.contextWindow
|
||||
?? model.context
|
||||
?? model.max_context_tokens,
|
||||
);
|
||||
}
|
||||
67
components/settings/tabs/ai/types.test.ts
Normal file
67
components/settings/tabs/ai/types.test.ts
Normal file
@@ -0,0 +1,67 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
|
||||
import { isCursorAvailableForMode, isCursorRuntimeInstalled } from "./types";
|
||||
|
||||
test("isCursorRuntimeInstalled ignores bundled SDK flags", () => {
|
||||
assert.equal(isCursorRuntimeInstalled({
|
||||
path: "cursor",
|
||||
version: "Cursor SDK",
|
||||
available: true,
|
||||
installed: true,
|
||||
sdkInstalled: true,
|
||||
}), false);
|
||||
assert.equal(isCursorRuntimeInstalled({
|
||||
path: "cursor",
|
||||
version: "Cursor SDK",
|
||||
available: false,
|
||||
installed: false,
|
||||
sdkInstalled: true,
|
||||
cliBinPath: null,
|
||||
cliLoginOk: false,
|
||||
}), false);
|
||||
});
|
||||
|
||||
test("isCursorRuntimeInstalled is true for Agent CLI path or CLI login", () => {
|
||||
assert.equal(isCursorRuntimeInstalled({
|
||||
path: "/usr/local/bin/cursor-agent",
|
||||
version: "Cursor Agent CLI",
|
||||
available: false,
|
||||
sdkInstalled: true,
|
||||
cliBinPath: "/usr/local/bin/cursor-agent",
|
||||
cliLoginOk: false,
|
||||
}), true);
|
||||
assert.equal(isCursorRuntimeInstalled({
|
||||
path: "cursor",
|
||||
version: "Cursor Agent CLI",
|
||||
available: true,
|
||||
sdkInstalled: true,
|
||||
cliLoginOk: true,
|
||||
}), true);
|
||||
});
|
||||
|
||||
test("isCursorAvailableForMode still allows API-key mode from bundled SDK", () => {
|
||||
assert.equal(isCursorAvailableForMode({
|
||||
path: "cursor",
|
||||
version: "Cursor SDK",
|
||||
available: true,
|
||||
installed: false,
|
||||
sdkInstalled: true,
|
||||
apiKeyOk: true,
|
||||
}, "api-key"), true);
|
||||
assert.equal(isCursorAvailableForMode({
|
||||
path: "cursor",
|
||||
version: "Cursor SDK",
|
||||
available: true,
|
||||
installed: false,
|
||||
apiKeyOk: true,
|
||||
}, "api-key"), true);
|
||||
assert.equal(isCursorAvailableForMode({
|
||||
path: "cursor",
|
||||
version: "Cursor SDK",
|
||||
available: false,
|
||||
installed: false,
|
||||
sdkInstalled: true,
|
||||
cliLoginOk: false,
|
||||
}, "cli-login"), false);
|
||||
});
|
||||
339
components/settings/tabs/ai/types.ts
Normal file
339
components/settings/tabs/ai/types.ts
Normal file
@@ -0,0 +1,339 @@
|
||||
/**
|
||||
* Shared types for AI settings sub-components
|
||||
*/
|
||||
import type {
|
||||
AIProviderId,
|
||||
ExternalAgentConfig,
|
||||
ProviderAdvancedParams,
|
||||
OpenAIApiFormat,
|
||||
ProviderStyle,
|
||||
} from "../../../../infrastructure/ai/types";
|
||||
|
||||
export type CodexIntegrationState =
|
||||
| "connected_chatgpt"
|
||||
| "connected_api_key"
|
||||
| "connected_custom_config"
|
||||
| "not_logged_in"
|
||||
| "unknown";
|
||||
|
||||
export interface CodexCustomProviderConfig {
|
||||
providerName: string;
|
||||
displayName: string;
|
||||
baseUrl: string | null;
|
||||
envKey: string | null;
|
||||
envKeyPresent: boolean;
|
||||
hasHardcodedApiKey: boolean;
|
||||
model: string | null;
|
||||
authHash: string | null;
|
||||
}
|
||||
|
||||
export interface CodexIntegrationStatus {
|
||||
state: CodexIntegrationState;
|
||||
isConnected: boolean;
|
||||
rawOutput: string;
|
||||
exitCode: number | null;
|
||||
customConfig?: CodexCustomProviderConfig | null;
|
||||
}
|
||||
|
||||
export interface CodexAppServerStatus {
|
||||
available: boolean;
|
||||
checking?: boolean;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export type CodexLoginState = "running" | "success" | "error" | "cancelled";
|
||||
|
||||
export interface CodexLoginSession {
|
||||
sessionId: string;
|
||||
state: CodexLoginState;
|
||||
url: string | null;
|
||||
output: string;
|
||||
error: string | null;
|
||||
exitCode: number | null;
|
||||
codexPath?: string | null;
|
||||
}
|
||||
|
||||
export interface AgentPathInfo {
|
||||
path: string | null;
|
||||
binPath?: string | null;
|
||||
version: string | null;
|
||||
available: boolean;
|
||||
/** True when the user's Cursor Agent CLI is on PATH or logged in. */
|
||||
installed?: boolean;
|
||||
authenticated?: boolean;
|
||||
authSource?: string | null;
|
||||
cliEmail?: string | null;
|
||||
cliBinPath?: string | null;
|
||||
/** True when local Cursor Agent CLI is logged in (subscription session). */
|
||||
cliLoginOk?: boolean;
|
||||
/** True when settings or env API key is present. */
|
||||
apiKeyOk?: boolean;
|
||||
/** True when @cursor/sdk platform package is importable. */
|
||||
sdkInstalled?: boolean;
|
||||
}
|
||||
|
||||
/** User-environment Cursor Agent CLI, not Netcatty's bundled @cursor/sdk. */
|
||||
export function isCursorRuntimeInstalled(pathInfo: AgentPathInfo | null | undefined): boolean {
|
||||
return Boolean(pathInfo?.cliBinPath || pathInfo?.cliLoginOk);
|
||||
}
|
||||
|
||||
/** Mode-aware Cursor availability for Settings enablement. */
|
||||
export function isCursorAvailableForMode(
|
||||
pathInfo: AgentPathInfo | null | undefined,
|
||||
mode: "api-key" | "cli-login",
|
||||
options?: { hasStoredApiKey?: boolean },
|
||||
): boolean {
|
||||
if (!pathInfo) return false;
|
||||
if (mode === "cli-login") {
|
||||
return Boolean(pathInfo.cliLoginOk || pathInfo.authSource === "cli-login");
|
||||
}
|
||||
const hasKey = Boolean(
|
||||
options?.hasStoredApiKey
|
||||
|| pathInfo.apiKeyOk
|
||||
|| pathInfo.authSource === "settings"
|
||||
|| pathInfo.authSource === "CURSOR_API_KEY",
|
||||
);
|
||||
// Missing sdkInstalled means the probe has not filled it yet. API-key mode
|
||||
// uses Netcatty's bundled SDK and must not wait for Cursor.app.
|
||||
const sdkOk = pathInfo.sdkInstalled !== undefined
|
||||
? Boolean(pathInfo.sdkInstalled)
|
||||
: true;
|
||||
return hasKey && sdkOk;
|
||||
}
|
||||
|
||||
export interface UserSkillStatusItem {
|
||||
id: string;
|
||||
slug: string;
|
||||
directoryName: string;
|
||||
directoryPath: string;
|
||||
skillPath: string;
|
||||
name: string;
|
||||
description: string;
|
||||
status: "ready" | "warning";
|
||||
warnings: string[];
|
||||
}
|
||||
|
||||
export interface UserSkillsStatusResult {
|
||||
ok: boolean;
|
||||
directoryPath?: string;
|
||||
readyCount?: number;
|
||||
warningCount?: number;
|
||||
skills?: UserSkillStatusItem[];
|
||||
warnings?: string[];
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export interface ProviderFormState {
|
||||
name: string;
|
||||
apiKey: string;
|
||||
baseURL: string;
|
||||
defaultModel: string;
|
||||
contextWindow: string;
|
||||
modelContextWindows: Record<string, number>;
|
||||
skipTLSVerify: boolean;
|
||||
advancedParams: ProviderAdvancedParams;
|
||||
style: ProviderStyle | ""; // "" means inherit-from-providerId
|
||||
openaiApi: OpenAIApiFormat;
|
||||
iconId: string; // "" means no built-in pick (fall back to providerId)
|
||||
iconDataUrl: string; // "" means no upload override
|
||||
}
|
||||
|
||||
export interface FetchedModel {
|
||||
id: string;
|
||||
name?: string;
|
||||
contextWindow?: number;
|
||||
}
|
||||
|
||||
export interface FetchBridge {
|
||||
aiFetch?: (url: string, method?: string, headers?: Record<string, string>, body?: string, providerId?: string, skipHostCheck?: boolean, followRedirects?: boolean, skipTLSVerify?: boolean) => Promise<{ ok: boolean; status?: number; data: string; error?: string }>;
|
||||
aiAllowlistAddHost?: (baseURL: string) => Promise<{ ok: boolean }>;
|
||||
}
|
||||
|
||||
export interface NetcattyAiBridge {
|
||||
aiDiscoverAgents?: (options?: { refreshShellEnv?: boolean; apiKeyPresent?: boolean }) => Promise<Array<AgentPathInfo & { command: string }>>;
|
||||
aiPrewarmShellEnv?: () => Promise<{ ok: boolean; error?: string }>;
|
||||
aiCodexGetIntegration?: (options?: { refreshShellEnv?: boolean; validateChatGptAuth?: boolean; codexPath?: string }) => Promise<CodexIntegrationStatus>;
|
||||
aiCodexStartLogin?: (options?: { codexPath?: string }) => Promise<{ ok: boolean; session?: CodexLoginSession; error?: string }>;
|
||||
aiCodexGetLoginSession?: (sessionId: string) => Promise<{ ok: boolean; session?: CodexLoginSession; error?: string }>;
|
||||
aiCodexCancelLogin?: (sessionId: string) => Promise<{ ok: boolean; found?: boolean; session?: CodexLoginSession; error?: string }>;
|
||||
aiCodexLogout?: (options?: { codexPath?: string }) => Promise<{ ok: boolean; state?: CodexIntegrationState; isConnected?: boolean; rawOutput?: string; logoutOutput?: string; error?: string }>;
|
||||
aiResolveCli?: (params: { command: string; customPath?: string; refreshShellEnv?: boolean; apiKeyPresent?: boolean }) => Promise<AgentPathInfo>;
|
||||
aiSdkAgentListModels?: (sdkBackend: string, cwd?: string, providerId?: string, chatSessionId?: string, agentEnv?: Record<string, string>, agentCommand?: string, codexRuntime?: 'sdk' | 'app-server') => Promise<{ ok: boolean; models?: Array<{ id: string; name: string; description?: string; thinkingLevels?: string[]; defaultThinkingLevel?: string }>; currentModelId?: string | null; error?: string }>;
|
||||
codexAppServerGetStatus?: (agentCommand?: string, agentEnv?: Record<string, string>) => Promise<{ ok: boolean; available: boolean; error?: string }>;
|
||||
aiUserSkillsGetStatus?: () => Promise<UserSkillsStatusResult>;
|
||||
aiUserSkillsOpenFolder?: () => Promise<UserSkillsStatusResult>;
|
||||
aiSkillsCliGetInvocation?: () => Promise<{
|
||||
ok: boolean;
|
||||
skillPath?: string | null;
|
||||
commandPrefix?: string;
|
||||
launcherPath?: string | null;
|
||||
usesLauncher?: boolean;
|
||||
error?: string;
|
||||
}>;
|
||||
openExternal?: (url: string) => Promise<void>;
|
||||
externalMcpGetStatus?: () => Promise<Record<string, unknown>>;
|
||||
externalMcpSetEnabled?: (enabled: boolean) => Promise<Record<string, unknown>>;
|
||||
externalMcpSetConfig?: (config: {
|
||||
mode?: 'temporary' | 'persistent';
|
||||
idleTimeoutMinutes?: number;
|
||||
sessionIdleTimeoutMinutes?: number;
|
||||
}) => Promise<Record<string, unknown>>;
|
||||
externalMcpCodexGetStatus?: () => Promise<Record<string, unknown>>;
|
||||
externalMcpCodexAdd?: () => Promise<Record<string, unknown>>;
|
||||
externalMcpClaudeGetStatus?: () => Promise<Record<string, unknown>>;
|
||||
externalMcpClaudeAdd?: () => Promise<Record<string, unknown>>;
|
||||
externalMcpGrokGetStatus?: () => Promise<Record<string, unknown>>;
|
||||
externalMcpGrokAdd?: () => Promise<Record<string, unknown>>;
|
||||
}
|
||||
|
||||
// Agent default configs for registration in externalAgents
|
||||
export const AGENT_DEFAULTS: Record<string, Omit<ExternalAgentConfig, "id" | "command" | "enabled">> = {
|
||||
codex: {
|
||||
name: "Codex CLI",
|
||||
args: ["exec", "--full-auto", "--json", "{prompt}"],
|
||||
icon: "openai",
|
||||
sdkBackend: "codex",
|
||||
},
|
||||
claude: {
|
||||
name: "Claude Code",
|
||||
args: ["-p", "--output-format", "text", "{prompt}"],
|
||||
icon: "claude",
|
||||
sdkBackend: "claude",
|
||||
},
|
||||
copilot: {
|
||||
name: "GitHub Copilot CLI",
|
||||
args: ["-p", "{prompt}"],
|
||||
icon: "copilot",
|
||||
sdkBackend: "copilot",
|
||||
},
|
||||
cursor: {
|
||||
name: "Cursor",
|
||||
args: ["{prompt}"],
|
||||
icon: "cursor",
|
||||
sdkBackend: "cursor",
|
||||
},
|
||||
codebuddy: {
|
||||
name: "CodeBuddy Code",
|
||||
args: [],
|
||||
icon: "codebuddy",
|
||||
sdkBackend: "codebuddy",
|
||||
},
|
||||
opencode: {
|
||||
name: "OpenCode",
|
||||
args: [],
|
||||
icon: "opencode",
|
||||
sdkBackend: "opencode",
|
||||
},
|
||||
grok: {
|
||||
name: "Grok Build",
|
||||
args: [],
|
||||
icon: "grok",
|
||||
sdkBackend: "grok",
|
||||
},
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Bridge helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export function getBridge(): NetcattyAiBridge | undefined {
|
||||
return (window as unknown as { netcatty?: NetcattyAiBridge }).netcatty;
|
||||
}
|
||||
|
||||
export function getFetchBridge(): FetchBridge | undefined {
|
||||
return (window as unknown as { netcatty?: FetchBridge }).netcatty;
|
||||
}
|
||||
|
||||
export function normalizeCodexBridgeError(error: unknown): string {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
if (message.includes("No handler registered for 'netcatty:ai:codex:")) {
|
||||
return "Codex main-process handlers are not loaded yet. Fully restart Netcatty, or restart the Electron dev process, then try again.";
|
||||
}
|
||||
return message;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Provider icon helper
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export type SettingsIconId = AIProviderId | "claude" | "copilot" | "codebuddy" | "opencode";
|
||||
|
||||
export const SETTINGS_ICON_PATHS: Record<SettingsIconId, string> = {
|
||||
openai: "/ai/providers/openai.svg",
|
||||
anthropic: "/ai/providers/anthropic.svg",
|
||||
claude: "/ai/agents/claude.svg",
|
||||
copilot: "/ai/agents/copilot.svg",
|
||||
codebuddy: "/ai/agents/codebuddy.svg",
|
||||
opencode: "/ai/agents/opencode.svg",
|
||||
google: "/ai/providers/google.svg",
|
||||
ollama: "/ai/providers/ollama.svg",
|
||||
openrouter: "/ai/providers/openrouter.svg",
|
||||
qwen: "/ai/providers/qwen.svg",
|
||||
deepseek: "/ai/providers/deepseek.svg",
|
||||
kimi: "/ai/providers/kimi.svg",
|
||||
zhipu: "/ai/providers/zhipu.svg",
|
||||
doubao: "/ai/providers/doubao.svg",
|
||||
mimo: "/ai/providers/xiaomi.svg",
|
||||
custom: "/ai/providers/custom.svg",
|
||||
};
|
||||
|
||||
export const SETTINGS_ICON_COLORS: Record<SettingsIconId, string> = {
|
||||
openai: "bg-emerald-600",
|
||||
anthropic: "bg-orange-600",
|
||||
claude: "bg-orange-600",
|
||||
copilot: "border border-zinc-300 bg-white",
|
||||
codebuddy: "bg-indigo-600",
|
||||
opencode: "bg-teal-600",
|
||||
google: "bg-blue-600",
|
||||
ollama: "bg-purple-600",
|
||||
openrouter: "bg-pink-600",
|
||||
qwen: "bg-[#615CED]",
|
||||
deepseek: "bg-[#4D6BFE]",
|
||||
kimi: "bg-zinc-800",
|
||||
zhipu: "bg-[#3859FF]",
|
||||
doubao: "bg-[#0066FF]",
|
||||
mimo: "bg-[#FF6900]",
|
||||
custom: "bg-zinc-600",
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Extra brand icons (lobe-icons subset, MIT) for ProviderConfig.iconId
|
||||
// See public/ai/providers/NOTICE.md for attribution.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface BuiltinProviderIcon {
|
||||
/** Identifier stored as ProviderConfig.iconId. */
|
||||
id: string;
|
||||
/** Display label shown in the icon picker. */
|
||||
label: string;
|
||||
/** Suggested display name when picking this preset (auto-fills ProviderConfig.name). */
|
||||
name: string;
|
||||
/** Absolute URL of the SVG asset. */
|
||||
path: string;
|
||||
/** Background tint applied behind the monochrome glyph. */
|
||||
bgColor: string;
|
||||
}
|
||||
|
||||
export const BUILTIN_PROVIDER_ICONS: BuiltinProviderIcon[] = [
|
||||
{ id: "anthropic", label: "Anthropic", name: "Anthropic", path: "/ai/providers/anthropic.svg", bgColor: "bg-orange-600" },
|
||||
{ id: "openai", label: "OpenAI", name: "OpenAI", path: "/ai/providers/openai.svg", bgColor: "bg-emerald-600" },
|
||||
{ id: "google", label: "Google", name: "Google", path: "/ai/providers/google.svg", bgColor: "bg-blue-600" },
|
||||
{ id: "ollama", label: "Ollama", name: "Ollama", path: "/ai/providers/ollama.svg", bgColor: "bg-purple-600" },
|
||||
{ id: "openrouter", label: "OpenRouter", name: "OpenRouter", path: "/ai/providers/openrouter.svg", bgColor: "bg-pink-600" },
|
||||
{ id: "deepseek", label: "DeepSeek", name: "DeepSeek", path: "/ai/providers/deepseek.svg", bgColor: "bg-[#4D6BFE]" },
|
||||
{ id: "moonshot", label: "Moonshot", name: "Moonshot", path: "/ai/providers/moonshot.svg", bgColor: "bg-zinc-800" },
|
||||
{ id: "kimi", label: "Kimi", name: "Kimi", path: "/ai/providers/kimi.svg", bgColor: "bg-zinc-800" },
|
||||
{ id: "qwen", label: "Qwen / 通义", name: "Qwen", path: "/ai/providers/qwen.svg", bgColor: "bg-[#615CED]" },
|
||||
{ id: "zhipu", label: "Zhipu / 智谱", name: "Zhipu", path: "/ai/providers/zhipu.svg", bgColor: "bg-[#3859FF]" },
|
||||
{ id: "doubao", label: "Doubao / 豆包", name: "Doubao", path: "/ai/providers/doubao.svg", bgColor: "bg-[#0066FF]" },
|
||||
{ id: "xiaomi", label: "Xiaomi / 小米", name: "Xiaomi MiMo", path: "/ai/providers/xiaomi.svg", bgColor: "bg-[#FF6900]" },
|
||||
{ id: "mistral", label: "Mistral", name: "Mistral", path: "/ai/providers/mistral.svg", bgColor: "bg-[#FA520F]" },
|
||||
{ id: "cohere", label: "Cohere", name: "Cohere", path: "/ai/providers/cohere.svg", bgColor: "bg-[#39594D]" },
|
||||
{ id: "grok", label: "Grok / xAI", name: "Grok", path: "/ai/providers/grok.svg", bgColor: "bg-zinc-900" },
|
||||
{ id: "perplexity", label: "Perplexity", name: "Perplexity", path: "/ai/providers/perplexity.svg", bgColor: "bg-[#1F8A8C]" },
|
||||
{ id: "groq", label: "Groq", name: "Groq", path: "/ai/providers/groq.svg", bgColor: "bg-[#F55036]" },
|
||||
{ id: "huggingface", label: "Hugging Face", name: "Hugging Face", path: "/ai/providers/huggingface.svg", bgColor: "bg-[#FF9D00]" },
|
||||
];
|
||||
|
||||
export const BUILTIN_PROVIDER_ICON_BY_ID: Record<string, BuiltinProviderIcon> =
|
||||
Object.fromEntries(BUILTIN_PROVIDER_ICONS.map((icon) => [icon.id, icon]));
|
||||
Reference in New Issue
Block a user