| import { Button } from "@/components/ui/button"; |
| import { |
| DropdownMenu, |
| DropdownMenuContent, |
| DropdownMenuItem, |
| DropdownMenuTrigger, |
| } from "@/components/ui/dropdown-menu"; |
| import { Input } from "@/components/ui/input"; |
| import { Switch } from "@/components/ui/switch"; |
| import { cn } from "@/lib/utils"; |
| import { |
| MODELS, |
| PROVIDERS, |
| getAutocompleteEligibleModels, |
| getModel, |
| getProvider, |
| providerNeedsKey, |
| providerSupportsKey, |
| type ModelId, |
| type ProviderId, |
| } from "@/modules/ai/config"; |
| import { clearKey, getAllKeys, setKey } from "@/modules/ai/lib/keyring"; |
| import { usePreferencesStore } from "@/modules/settings/preferences"; |
| import { |
| emitKeysChanged, |
| setAutocompleteEnabled, |
| setAutocompleteModelId, |
| setAutocompleteProvider, |
| setDefaultModel, |
| setLmstudioBaseURL, |
| setLmstudioModelId, |
| setOpenaiCompatibleBaseURL, |
| setOpenaiCompatibleModelId, |
| } from "@/modules/settings/store"; |
| import { invoke } from "@tauri-apps/api/core"; |
| import { |
| ArrowDown01Icon, |
| CheckmarkCircle02Icon, |
| Cancel01Icon, |
| } from "@hugeicons/core-free-icons"; |
| import { HugeiconsIcon } from "@hugeicons/react"; |
| import { useEffect, useMemo, useState } from "react"; |
| import { ProviderIcon } from "../components/ProviderIcon"; |
| import { ProviderKeyCard } from "../components/ProviderKeyCard"; |
| import { SectionHeader } from "../components/SectionHeader"; |
|
|
| type KeysMap = Record<ProviderId, string | null>; |
|
|
| export function ModelsSection() { |
| const [keys, setKeys] = useState<KeysMap | null>(null); |
| const defaultModel = usePreferencesStore((s) => s.defaultModelId); |
| const lmstudioModelId = usePreferencesStore((s) => s.lmstudioModelId); |
| const openaiCompatModelId = usePreferencesStore( |
| (s) => s.openaiCompatibleModelId, |
| ); |
|
|
| useEffect(() => { |
| void getAllKeys().then(setKeys); |
| }, []); |
|
|
| const onSave = async (provider: ProviderId, value: string) => { |
| await setKey(provider, value); |
| setKeys((prev) => (prev ? { ...prev, [provider]: value } : prev)); |
| await emitKeysChanged(); |
| }; |
|
|
| const onClear = async (provider: ProviderId) => { |
| await clearKey(provider); |
| setKeys((prev) => (prev ? { ...prev, [provider]: null } : prev)); |
| await emitKeysChanged(); |
| }; |
|
|
| if (!keys) { |
| return <div className="text-[12px] text-muted-foreground">Loading…</div>; |
| } |
|
|
| const cloudProviders = PROVIDERS.filter( |
| (p) => |
| providerNeedsKey(p.id) && p.id !== "lmstudio" && p.id !== "openai-compatible", |
| ); |
| const configuredCount = cloudProviders.filter((p) => !!keys[p.id]).length; |
|
|
| return ( |
| <div className="flex flex-col gap-7"> |
| <SectionHeader |
| title="Models" |
| description="Bring your own keys. They live in your OS keychain and are used only by Terax." |
| /> |
| |
| <DefaultModelBlock |
| defaultModel={defaultModel} |
| keys={keys} |
| lmstudioModelId={lmstudioModelId} |
| openaiCompatModelId={openaiCompatModelId} |
| /> |
| |
| <div className="flex flex-col gap-2"> |
| <div className="flex items-baseline justify-between"> |
| <Label>Cloud providers</Label> |
| <span className="text-[10.5px] text-muted-foreground"> |
| {configuredCount} of {cloudProviders.length} configured |
| </span> |
| </div> |
| <div className="grid grid-cols-1 gap-2 sm:grid-cols-2"> |
| {cloudProviders.map((p) => ( |
| <ProviderKeyCard |
| key={p.id} |
| provider={p} |
| currentKey={keys[p.id]} |
| onSave={(v) => onSave(p.id, v)} |
| onClear={() => onClear(p.id)} |
| /> |
| ))} |
| </div> |
| </div> |
| |
| <LocalModelsBlock /> |
| |
| <OpenAICompatibleBlock |
| compatKey={keys["openai-compatible"]} |
| onSaveKey={(v) => onSave("openai-compatible", v)} |
| onClearKey={() => onClear("openai-compatible")} |
| /> |
| |
| <AutocompleteBlock keys={keys} /> |
| </div> |
| ); |
| } |
|
|
| function DefaultModelBlock({ |
| defaultModel, |
| keys, |
| lmstudioModelId, |
| openaiCompatModelId, |
| }: { |
| defaultModel: ModelId; |
| keys: KeysMap; |
| lmstudioModelId: string; |
| openaiCompatModelId: string; |
| }) { |
| const m = getModel(defaultModel); |
|
|
| const isAvailable = (modelId: string, providerId: ProviderId): boolean => { |
| if (modelId === "lmstudio-local") return !!lmstudioModelId.trim(); |
| if (modelId === "openai-compatible-custom") |
| return !!openaiCompatModelId.trim(); |
| return providerNeedsKey(providerId) ? !!keys[providerId] : true; |
| }; |
|
|
| return ( |
| <div className="flex flex-col gap-2"> |
| <Label>Default model</Label> |
| <DropdownMenu> |
| <DropdownMenuTrigger asChild> |
| <Button |
| variant="outline" |
| className="h-9 justify-between gap-2 px-2.5 text-[12px]" |
| > |
| <span className="flex items-center gap-2"> |
| <ProviderIcon provider={m.provider} size={14} /> |
| <span>{m.label}</span> |
| <span className="text-muted-foreground">· {m.hint}</span> |
| </span> |
| <HugeiconsIcon |
| icon={ArrowDown01Icon} |
| size={12} |
| strokeWidth={2} |
| className="opacity-70" |
| /> |
| </Button> |
| </DropdownMenuTrigger> |
| <DropdownMenuContent |
| align="start" |
| className="max-h-[28rem] min-w-[280px] overflow-y-auto" |
| > |
| {PROVIDERS.map((p) => { |
| const models = MODELS.filter((x) => x.provider === p.id); |
| if (models.length === 0) return null; |
| const hasKey = providerNeedsKey(p.id) ? !!keys[p.id] : true; |
| return ( |
| <div key={p.id} className="px-1 pt-1.5 first:pt-1"> |
| <div className="mb-0.5 flex items-center gap-1.5 px-2 text-[10px] font-medium tracking-wide text-muted-foreground uppercase"> |
| <ProviderIcon provider={p.id} size={11} /> |
| <span>{p.label}</span> |
| {!hasKey ? ( |
| <span className="ml-auto text-[9.5px] normal-case tracking-normal text-muted-foreground/70"> |
| no key |
| </span> |
| ) : null} |
| </div> |
| {models.map((mod) => { |
| const available = isAvailable(mod.id, p.id); |
| return ( |
| <DropdownMenuItem |
| key={mod.id} |
| disabled={!available} |
| onSelect={() => |
| available && void setDefaultModel(mod.id as ModelId) |
| } |
| className={cn( |
| "flex items-start gap-2 text-[12px]", |
| mod.id === defaultModel && "bg-accent/50", |
| )} |
| > |
| <span className="flex flex-1 flex-col"> |
| <span>{mod.label}</span> |
| <span className="text-[10px] text-muted-foreground"> |
| {mod.description} |
| </span> |
| </span> |
| </DropdownMenuItem> |
| ); |
| })} |
| </div> |
| ); |
| })} |
| </DropdownMenuContent> |
| </DropdownMenu> |
| </div> |
| ); |
| } |
|
|
| function LocalModelsBlock() { |
| const baseURL = usePreferencesStore((s) => s.lmstudioBaseURL); |
| const modelId = usePreferencesStore((s) => s.lmstudioModelId); |
| const [urlDraft, setUrlDraft] = useState(baseURL); |
| const [modelDraft, setModelDraft] = useState(modelId); |
| const [testStatus, setTestStatus] = useState< |
| "idle" | "testing" | "ok" | "fail" |
| >("idle"); |
|
|
| useEffect(() => setUrlDraft(baseURL), [baseURL]); |
| useEffect(() => setModelDraft(modelId), [modelId]); |
|
|
| const test = async () => { |
| setTestStatus("testing"); |
| try { |
| const status = await invoke<number>("lm_ping", { |
| baseUrl: urlDraft, |
| }); |
| setTestStatus(status > 0 ? "ok" : "fail"); |
| } catch { |
| setTestStatus("fail"); |
| } |
| }; |
|
|
| return ( |
| <div className="flex flex-col gap-3"> |
| <div className="flex flex-col gap-0.5"> |
| <Label>Local — LM Studio</Label> |
| <span className="text-[10.5px] leading-relaxed text-muted-foreground"> |
| Run any GGUF model on your machine via LM Studio's HTTP server. Enable |
| the server in LM Studio → Developer tab. |
| </span> |
| </div> |
| |
| <div className="flex flex-col gap-2.5 rounded-lg border border-border/60 bg-card/60 px-3 py-2.5"> |
| <FieldRow label="Base URL"> |
| <div className="flex flex-1 gap-1.5"> |
| <Input |
| value={urlDraft} |
| onChange={(e) => setUrlDraft(e.target.value)} |
| onBlur={() => { |
| const v = urlDraft.trim(); |
| if (v && v !== baseURL) void setLmstudioBaseURL(v); |
| }} |
| placeholder="http://localhost:1234/v1" |
| spellCheck={false} |
| className="h-8 flex-1 font-mono text-[11.5px]" |
| /> |
| <Button |
| size="sm" |
| variant="outline" |
| onClick={() => void test()} |
| disabled={!urlDraft.trim()} |
| className="h-8 px-3 text-[11px]" |
| > |
| Test |
| </Button> |
| </div> |
| </FieldRow> |
| |
| <FieldRow label="Model ID"> |
| <Input |
| value={modelDraft} |
| onChange={(e) => setModelDraft(e.target.value)} |
| onBlur={() => { |
| const v = modelDraft.trim(); |
| if (v !== modelId) void setLmstudioModelId(v); |
| }} |
| placeholder="qwen2.5-coder-7b-instruct" |
| spellCheck={false} |
| className="h-8 font-mono text-[11.5px]" |
| /> |
| </FieldRow> |
| |
| <StatusLine status={testStatus} /> |
| |
| {!modelId.trim() ? ( |
| <p className="text-[10.5px] leading-relaxed text-amber-600 dark:text-amber-400"> |
| Enter the model id that's loaded in LM Studio — e.g. the one shown |
| on the server's <span className="font-mono">/v1/models</span> page. |
| </p> |
| ) : null} |
| </div> |
| </div> |
| ); |
| } |
|
|
| function OpenAICompatibleBlock({ |
| compatKey, |
| onSaveKey, |
| onClearKey, |
| }: { |
| compatKey: string | null; |
| onSaveKey: (v: string) => Promise<void>; |
| onClearKey: () => Promise<void>; |
| }) { |
| const baseURL = usePreferencesStore((s) => s.openaiCompatibleBaseURL); |
| const modelId = usePreferencesStore((s) => s.openaiCompatibleModelId); |
| const [urlDraft, setUrlDraft] = useState(baseURL); |
| const [modelDraft, setModelDraft] = useState(modelId); |
| const [keyDraft, setKeyDraft] = useState(""); |
| const [testStatus, setTestStatus] = useState< |
| "idle" | "testing" | "ok" | "fail" |
| >("idle"); |
|
|
| useEffect(() => setUrlDraft(baseURL), [baseURL]); |
| useEffect(() => setModelDraft(modelId), [modelId]); |
|
|
| const test = async () => { |
| setTestStatus("testing"); |
| try { |
| const status = await invoke<number>("lm_ping", { |
| baseUrl: urlDraft, |
| }); |
| setTestStatus(status > 0 ? "ok" : "fail"); |
| } catch { |
| setTestStatus("fail"); |
| } |
| }; |
|
|
| return ( |
| <div className="flex flex-col gap-3"> |
| <div className="flex flex-col gap-0.5"> |
| <Label>OpenAI-compatible endpoint</Label> |
| <span className="text-[10.5px] leading-relaxed text-muted-foreground"> |
| Any OpenAI-compatible HTTPS endpoint — vLLM, Z.AI, Fireworks, hosted |
| Ollama, etc. |
| </span> |
| </div> |
| |
| <div className="flex flex-col gap-2.5 rounded-lg border border-border/60 bg-card/60 px-3 py-2.5"> |
| <FieldRow label="Base URL"> |
| <div className="flex flex-1 gap-1.5"> |
| <Input |
| value={urlDraft} |
| onChange={(e) => setUrlDraft(e.target.value)} |
| onBlur={() => { |
| const v = urlDraft.trim(); |
| if (v !== baseURL) void setOpenaiCompatibleBaseURL(v); |
| }} |
| placeholder="https://api.example.com/v1" |
| spellCheck={false} |
| className="h-8 flex-1 font-mono text-[11.5px]" |
| /> |
| <Button |
| size="sm" |
| variant="outline" |
| onClick={() => void test()} |
| disabled={!urlDraft.trim()} |
| className="h-8 px-3 text-[11px]" |
| > |
| Test |
| </Button> |
| </div> |
| </FieldRow> |
| |
| <FieldRow label="Model ID"> |
| <Input |
| value={modelDraft} |
| onChange={(e) => setModelDraft(e.target.value)} |
| onBlur={() => { |
| const v = modelDraft.trim(); |
| if (v !== modelId) void setOpenaiCompatibleModelId(v); |
| }} |
| placeholder="gpt-4o, qwen3-max, glm-4.6, …" |
| spellCheck={false} |
| className="h-8 font-mono text-[11.5px]" |
| /> |
| </FieldRow> |
| |
| <FieldRow label="API key"> |
| {compatKey ? ( |
| <div className="flex flex-1 items-center gap-1.5"> |
| <code className="flex-1 truncate rounded bg-muted/40 px-2 py-1 font-mono text-[11px] text-muted-foreground"> |
| {`${compatKey.slice(0, 4)}${"•".repeat(8)}${compatKey.slice(-4)}`} |
| </code> |
| <Button |
| size="icon" |
| variant="ghost" |
| onClick={() => void onClearKey()} |
| title="Remove" |
| className="size-7 text-muted-foreground hover:text-destructive" |
| > |
| <HugeiconsIcon |
| icon={Cancel01Icon} |
| size={12} |
| strokeWidth={1.75} |
| /> |
| </Button> |
| </div> |
| ) : ( |
| <div className="flex flex-1 gap-1.5"> |
| <Input |
| type="password" |
| value={keyDraft} |
| onChange={(e) => setKeyDraft(e.target.value)} |
| placeholder="Optional — leave empty for unauthenticated endpoints" |
| spellCheck={false} |
| className="h-8 flex-1 font-mono text-[11.5px]" |
| /> |
| <Button |
| size="sm" |
| onClick={async () => { |
| const v = keyDraft.trim(); |
| if (!v) return; |
| await onSaveKey(v); |
| setKeyDraft(""); |
| }} |
| disabled={!keyDraft.trim()} |
| className="h-8 px-3 text-[11px]" |
| > |
| Save |
| </Button> |
| </div> |
| )} |
| </FieldRow> |
| |
| <StatusLine status={testStatus} /> |
| </div> |
| </div> |
| ); |
| } |
|
|
| function AutocompleteBlock({ keys }: { keys: KeysMap }) { |
| const enabled = usePreferencesStore((s) => s.autocompleteEnabled); |
| const provider = usePreferencesStore((s) => s.autocompleteProvider); |
| const modelId = usePreferencesStore((s) => s.autocompleteModelId); |
| const eligible = useMemo(() => getAutocompleteEligibleModels(), []); |
|
|
| const currentModel = useMemo( |
| () => |
| MODELS.find((m) => m.provider === provider && m.id === modelId) ?? |
| MODELS.find((m) => m.id === modelId) ?? |
| eligible[0], |
| [eligible, provider, modelId], |
| ); |
|
|
| const setModel = (id: string, providerId: ProviderId) => { |
| void setAutocompleteProvider(providerId); |
| void setAutocompleteModelId(id); |
| }; |
|
|
| const hasKey = providerSupportsKey(provider) |
| ? providerNeedsKey(provider) |
| ? !!keys[provider] |
| : true |
| : true; |
|
|
| |
| const grouped = useMemo(() => { |
| const map = new Map<ProviderId, (typeof eligible)[number][]>(); |
| for (const m of eligible) { |
| const arr = map.get(m.provider) ?? []; |
| arr.push(m); |
| map.set(m.provider, arr); |
| } |
| return map; |
| }, [eligible]); |
|
|
| return ( |
| <div className="flex flex-col gap-3"> |
| <div className="flex items-start justify-between gap-3"> |
| <div className="flex flex-col gap-0.5"> |
| <Label>Editor autocomplete</Label> |
| <span className="text-[10.5px] leading-relaxed text-muted-foreground"> |
| Inline ghost-text suggestions in the code editor. Pick a fast model |
| (LPU/wafer-scale, local, or a small cloud tier). |
| </span> |
| </div> |
| <Switch |
| checked={enabled} |
| onCheckedChange={(v) => void setAutocompleteEnabled(v)} |
| /> |
| </div> |
| |
| <div className="flex flex-col gap-2 rounded-lg border border-border/60 bg-card/60 px-3 py-2.5"> |
| <FieldRow label="Model"> |
| <DropdownMenu> |
| <DropdownMenuTrigger asChild> |
| <Button |
| variant="outline" |
| className="h-8 flex-1 justify-between gap-2 px-2.5 text-[11.5px]" |
| > |
| <span className="flex items-center gap-2 truncate"> |
| <ProviderIcon provider={currentModel.provider} size={12} /> |
| <span className="truncate">{currentModel.label}</span> |
| <span className="text-muted-foreground"> |
| · {currentModel.hint} |
| </span> |
| </span> |
| <HugeiconsIcon |
| icon={ArrowDown01Icon} |
| size={11} |
| strokeWidth={2} |
| className="opacity-70" |
| /> |
| </Button> |
| </DropdownMenuTrigger> |
| <DropdownMenuContent |
| align="start" |
| className="max-h-[24rem] min-w-[280px] overflow-y-auto" |
| > |
| {PROVIDERS.map((p) => { |
| const list = grouped.get(p.id); |
| if (!list || list.length === 0) return null; |
| const pHasKey = providerNeedsKey(p.id) ? !!keys[p.id] : true; |
| return ( |
| <div key={p.id} className="px-1 pt-1.5 first:pt-1"> |
| <div className="mb-0.5 flex items-center gap-1.5 px-2 text-[10px] font-medium tracking-wide text-muted-foreground uppercase"> |
| <ProviderIcon provider={p.id} size={11} /> |
| <span>{p.label}</span> |
| {!pHasKey ? ( |
| <span className="ml-auto text-[9.5px] normal-case tracking-normal text-muted-foreground/70"> |
| no key |
| </span> |
| ) : null} |
| </div> |
| {list.map((m) => ( |
| <DropdownMenuItem |
| key={m.id} |
| disabled={!pHasKey} |
| onSelect={() => pHasKey && setModel(m.id, p.id)} |
| className={cn( |
| "text-[11.5px]", |
| m.id === modelId && "bg-accent/50", |
| )} |
| > |
| <span className="flex flex-col"> |
| <span>{m.label}</span> |
| <span className="text-[10px] text-muted-foreground"> |
| {m.description} |
| </span> |
| </span> |
| </DropdownMenuItem> |
| ))} |
| </div> |
| ); |
| })} |
| </DropdownMenuContent> |
| </DropdownMenu> |
| </FieldRow> |
| |
| {!hasKey ? ( |
| <span className="text-[10.5px] text-amber-500"> |
| No API key configured for {getProvider(provider).label}. Add one |
| above. |
| </span> |
| ) : null} |
| </div> |
| </div> |
| ); |
| } |
|
|
| function FieldRow({ |
| label, |
| children, |
| }: { |
| label: string; |
| children: React.ReactNode; |
| }) { |
| return ( |
| <div className="flex items-center gap-3"> |
| <span className="w-16 shrink-0 text-[11px] tracking-tight text-muted-foreground"> |
| {label} |
| </span> |
| <div className="flex flex-1 items-center">{children}</div> |
| </div> |
| ); |
| } |
|
|
| function StatusLine({ |
| status, |
| }: { |
| status: "idle" | "testing" | "ok" | "fail"; |
| }) { |
| if (status === "idle") return null; |
| if (status === "testing") { |
| return ( |
| <span className="text-[10.5px] text-muted-foreground">Testing…</span> |
| ); |
| } |
| if (status === "ok") { |
| return ( |
| <span className="flex items-center gap-1 text-[10.5px] text-emerald-600 dark:text-emerald-400"> |
| <HugeiconsIcon icon={CheckmarkCircle02Icon} size={11} strokeWidth={2} /> |
| Reachable — server responded. |
| </span> |
| ); |
| } |
| return ( |
| <span className="text-[10.5px] text-destructive"> |
| Could not reach the server. |
| </span> |
| ); |
| } |
|
|
| function Label({ children }: { children: React.ReactNode }) { |
| return ( |
| <span className="text-[11px] font-medium tracking-tight text-muted-foreground"> |
| {children} |
| </span> |
| ); |
| } |
|
|