| 'use client'; |
|
|
| import React, { useState, useEffect } from 'react'; |
| import { configManager } from '@/lib/config/storage'; |
| import { validateApiKey as checkApiKey } from '@/lib/llm/llm-client'; |
| import { Button } from '@/components/ui/button'; |
| import { Input } from '@/components/ui/input'; |
| import { Label } from '@/components/ui/label'; |
| import { Switch } from '@/components/ui/switch'; |
| import { Eye, EyeOff, Check, X, ExternalLink, Loader2 } from 'lucide-react'; |
| import { toast } from 'sonner'; |
| import { ModelSelector } from '@/components/model-selector'; |
| import { |
| Select, |
| SelectContent, |
| SelectItem, |
| SelectTrigger, |
| SelectValue, |
| } from '@/components/ui/select'; |
| import { ProviderId } from '@/lib/llm/providers/types'; |
| import { getOfferableProviders, getProvider } from '@/lib/llm/providers/registry'; |
| import { CodexAuthPanel } from '@/components/settings/codex-auth-panel'; |
| import { HFAuthPanel } from '@/components/settings/hf-auth-panel'; |
| import { ConnectionBadge } from '@/components/settings/connection-badge'; |
| import { checkHFCapabilities } from '@/lib/auth/hf-auth'; |
| import { track } from '@/lib/telemetry'; |
|
|
| interface ModelSettingsPanelProps { |
| onClose?: () => void; |
| onModelChange?: (modelId: string) => void; |
| showJudgeModel?: boolean; |
| onJudgeModelChange?: (modelId: string) => void; |
| } |
|
|
| export function ModelSettingsPanel({ onClose, onModelChange, showJudgeModel, onJudgeModelChange }: ModelSettingsPanelProps) { |
| const [selectedProvider, setSelectedProvider] = useState<ProviderId>(() => |
| configManager.getSelectedProvider() |
| ); |
| const [showApiKey, setShowApiKey] = useState(false); |
| const [validatingKey, setValidatingKey] = useState(false); |
| const [keyValid, setKeyValid] = useState<boolean | null>(null); |
| const [currentApiKey, setCurrentApiKey] = useState(''); |
| const [apiKeyStored, setApiKeyStored] = useState(() => { |
| const p = configManager.getSelectedProvider(); |
| return getProvider(p).apiKeyRequired ? !!configManager.getProviderApiKey(p) : false; |
| }); |
| const [codexAvailable, setCodexAvailable] = useState(true); |
| const [useSeparateChatModel, setUseSeparateChatModel] = useState<boolean>(() => { |
| if (typeof window !== 'undefined') { |
| const stored = localStorage.getItem(`osw-studio-use-separate-chat-model-${configManager.getSelectedProvider()}`); |
| return stored === 'true'; |
| } |
| return false; |
| }); |
| const [compactionEnabled, setCompactionEnabled] = useState<boolean>(() => { |
| return configManager.isCompactionEnabled(configManager.getSelectedProvider()); |
| }); |
| const [compactionLimit, setCompactionLimit] = useState<string>(() => { |
| const savedLimit = configManager.getCompactionLimit(configManager.getSelectedProvider()); |
| return savedLimit ? String(savedLimit) : ''; |
| }); |
|
|
| |
| useEffect(() => { |
| checkHFCapabilities().then(caps => { |
| setCodexAvailable(caps.codexAvailable); |
| }).catch(() => {}); |
| }, []); |
|
|
| useEffect(() => { |
| |
| const key = configManager.getProviderApiKey(selectedProvider) || ''; |
| setCurrentApiKey(key); |
| setKeyValid(null); |
| const providerCfg = getProvider(selectedProvider); |
| setApiKeyStored(providerCfg.apiKeyRequired ? !!key : false); |
|
|
| |
| if (typeof window !== 'undefined') { |
| const stored = localStorage.getItem(`osw-studio-use-separate-chat-model-${selectedProvider}`); |
| setUseSeparateChatModel(stored === 'true'); |
| } |
|
|
| |
| setCompactionEnabled(configManager.isCompactionEnabled(selectedProvider)); |
| const savedLimit = configManager.getCompactionLimit(selectedProvider); |
| setCompactionLimit(savedLimit ? String(savedLimit) : ''); |
| }, [selectedProvider]); |
|
|
| |
| useEffect(() => { |
| if (typeof window !== 'undefined') { |
| localStorage.setItem(`osw-studio-use-separate-chat-model-${selectedProvider}`, String(useSeparateChatModel)); |
| } |
| }, [useSeparateChatModel, selectedProvider]); |
|
|
| const handleProviderChange = (provider: ProviderId) => { |
| setSelectedProvider(provider); |
| configManager.setSelectedProvider(provider); |
| track('provider_selected', { provider, has_api_key: !!configManager.getProviderApiKey(provider) }); |
| }; |
|
|
| const handleApiKeyChange = (key: string) => { |
| setCurrentApiKey(key); |
| configManager.setProviderApiKey(selectedProvider, key); |
| setKeyValid(null); |
| configManager.clearModelCache(selectedProvider); |
| window.dispatchEvent(new CustomEvent('apiKeyUpdated', { |
| detail: { provider: selectedProvider, hasKey: !!key } |
| })); |
| }; |
|
|
| const validateApiKey = async () => { |
| if (!currentApiKey) { |
| toast.error('Please enter an API key'); |
| return; |
| } |
|
|
| setValidatingKey(true); |
| try { |
| const isValid = await checkApiKey(currentApiKey, selectedProvider); |
| setKeyValid(isValid); |
| |
| if (isValid) { |
| toast.success('API key is valid!'); |
| } else { |
| toast.error('Invalid API key'); |
| } |
| } catch { |
| setKeyValid(false); |
| toast.error('Failed to validate API key'); |
| } finally { |
| setValidatingKey(false); |
| } |
| }; |
|
|
| const handleConnect = async () => { |
| const key = currentApiKey.trim(); |
| if (!key) { |
| toast.error('Please enter an API key'); |
| return; |
| } |
| setValidatingKey(true); |
| try { |
| const isValid = await checkApiKey(key, selectedProvider); |
| if (isValid) { |
| configManager.setProviderApiKey(selectedProvider, key); |
| configManager.clearModelCache(selectedProvider); |
| setApiKeyStored(true); |
| setCurrentApiKey(''); |
| setKeyValid(null); |
| toast.success('API key connected!'); |
| window.dispatchEvent(new CustomEvent('apiKeyUpdated', { |
| detail: { provider: selectedProvider, hasKey: true } |
| })); |
| } else { |
| toast.error('Invalid API key. Please check and try again.'); |
| } |
| } catch { |
| toast.error('Failed to validate API key'); |
| } finally { |
| setValidatingKey(false); |
| } |
| }; |
|
|
| const handleApiKeyDisconnect = () => { |
| configManager.setProviderApiKey(selectedProvider, ''); |
| configManager.clearModelCache(selectedProvider); |
| setApiKeyStored(false); |
| setCurrentApiKey(''); |
| setKeyValid(null); |
| toast.success(`Disconnected from ${getProvider(selectedProvider).name}`); |
| window.dispatchEvent(new CustomEvent('apiKeyUpdated', { |
| detail: { provider: selectedProvider, hasKey: false } |
| })); |
| }; |
|
|
| const providerConfig = getProvider(selectedProvider); |
|
|
| return ( |
| <div className="flex-1 min-h-0 flex flex-col overflow-hidden"> |
| {/* Header */} |
| <div className="shrink-0"> |
| <h3 className="font-semibold text-base tracking-tight">Model Settings</h3> |
| <p className="text-muted-foreground text-xs mt-1"> |
| Configure your AI model and API connection |
| </p> |
| </div> |
| |
| {/* Scrollable content */} |
| <div className="flex-1 min-h-0 overflow-y-auto mt-5 space-y-5"> |
| |
| {/* Provider Selection */} |
| <div> |
| <Label htmlFor="provider" className="text-xs font-semibold uppercase tracking-wider text-muted-foreground">Provider</Label> |
| <Select value={selectedProvider} onValueChange={handleProviderChange}> |
| <SelectTrigger id="provider" className="mt-2 !h-fit w-full"> |
| <SelectValue placeholder="Select a provider"> |
| {selectedProvider && ( |
| <div className="flex flex-col text-left"> |
| <span className="font-medium">{providerConfig.name}</span> |
| <span className="text-xs text-muted-foreground"> |
| {providerConfig.description} |
| </span> |
| </div> |
| )} |
| </SelectValue> |
| </SelectTrigger> |
| <SelectContent className="max-h-[400px]"> |
| {getOfferableProviders() |
| .filter(p => codexAvailable || p.id !== 'openai-codex') |
| .map(provider => ( |
| <SelectItem key={provider.id} value={provider.id} className="py-2.5"> |
| <div className="flex flex-col"> |
| <span className="font-medium">{provider.name}</span> |
| <span className="text-xs text-muted-foreground"> |
| {provider.description} |
| </span> |
| </div> |
| </SelectItem> |
| ))} |
| </SelectContent> |
| </Select> |
| </div> |
| |
| {/* Auth: OAuth panel for Codex/HF, API key for others */} |
| {providerConfig.usesOAuth ? ( |
| selectedProvider === 'huggingface' ? ( |
| <HFAuthPanel onAuthChange={() => { |
| window.dispatchEvent(new CustomEvent('apiKeyUpdated', { |
| detail: { provider: selectedProvider, hasKey: !!configManager.getProviderApiKey(selectedProvider) } |
| })); |
| }} /> |
| ) : ( |
| <CodexAuthPanel onAuthChange={() => { |
| window.dispatchEvent(new CustomEvent('apiKeyUpdated', { |
| detail: { provider: selectedProvider, hasKey: !!configManager.getProviderApiKey(selectedProvider) } |
| })); |
| }} /> |
| ) |
| ) : providerConfig.apiKeyRequired ? ( |
| apiKeyStored ? ( |
| <ConnectionBadge |
| method="API Key" |
| extra={(() => { const k = configManager.getProviderApiKey(selectedProvider); return k ? `···${k.slice(-4)}` : undefined; })()} |
| info={providerConfig.apiKeyHelpUrl && ( |
| <a |
| href={providerConfig.apiKeyHelpUrl} |
| target="_blank" |
| rel="noopener noreferrer" |
| className="text-primary hover:underline inline-flex items-center gap-0.5" |
| > |
| Manage on {providerConfig.name} <ExternalLink className="h-2.5 w-2.5" /> |
| </a> |
| )} |
| onDisconnect={handleApiKeyDisconnect} |
| /> |
| ) : ( |
| <div> |
| <Label htmlFor="api-key">{providerConfig.name} API Key</Label> |
| <div className="flex gap-2 mt-2"> |
| <div className="relative flex-1"> |
| <Input |
| id="api-key" |
| type={showApiKey ? "text" : "password"} |
| value={currentApiKey} |
| onChange={(e) => { setCurrentApiKey(e.target.value); setKeyValid(null); }} |
| onKeyDown={(e) => { if (e.key === 'Enter' && currentApiKey.trim()) handleConnect(); }} |
| placeholder={providerConfig.apiKeyPlaceholder || 'API Key'} |
| className="pr-10" |
| data-tour-id="provider-key-input" |
| disabled={validatingKey} |
| /> |
| <Button |
| size="icon" |
| variant="ghost" |
| className="absolute right-1 top-1 h-7 w-7" |
| onClick={() => setShowApiKey(!showApiKey)} |
| > |
| {showApiKey ? ( |
| <EyeOff className="h-4 w-4" /> |
| ) : ( |
| <Eye className="h-4 w-4" /> |
| )} |
| </Button> |
| </div> |
| <Button |
| onClick={handleConnect} |
| disabled={validatingKey || !currentApiKey.trim()} |
| size="sm" |
| > |
| {validatingKey ? ( |
| <> |
| <Loader2 className="h-3 w-3 animate-spin mr-1" /> |
| Connecting... |
| </> |
| ) : ( |
| 'Connect' |
| )} |
| </Button> |
| </div> |
| {providerConfig.apiKeyHelpUrl && ( |
| <p className="text-sm text-muted-foreground mt-2"> |
| Get your API key from{' '} |
| <a |
| href={providerConfig.apiKeyHelpUrl} |
| target="_blank" |
| rel="noopener noreferrer" |
| className="text-primary hover:underline inline-flex items-center gap-1" |
| > |
| {providerConfig.name} <ExternalLink className="h-3 w-3" /> |
| </a> |
| </p> |
| )} |
| </div> |
| ) |
| ) : providerConfig.isLocal ? ( |
| <div> |
| <Label htmlFor="api-key"> |
| {providerConfig.name} API Key |
| <span className="text-muted-foreground text-xs ml-1">(optional)</span> |
| </Label> |
| <div className="flex gap-2 mt-2"> |
| <div className="relative flex-1"> |
| <Input |
| id="api-key" |
| type={showApiKey ? "text" : "password"} |
| value={currentApiKey} |
| onChange={(e) => handleApiKeyChange(e.target.value)} |
| placeholder={providerConfig.apiKeyPlaceholder || 'API Key'} |
| className="pr-10" |
| data-tour-id="provider-key-input" |
| /> |
| <Button |
| size="icon" |
| variant="ghost" |
| className="absolute right-1 top-1 h-7 w-7" |
| onClick={() => setShowApiKey(!showApiKey)} |
| > |
| {showApiKey ? ( |
| <EyeOff className="h-4 w-4" /> |
| ) : ( |
| <Eye className="h-4 w-4" /> |
| )} |
| </Button> |
| </div> |
| <Button |
| onClick={validateApiKey} |
| disabled={validatingKey || !currentApiKey} |
| size="sm" |
| > |
| {validatingKey ? 'Validating...' : 'Validate'} |
| </Button> |
| {keyValid !== null && ( |
| <div className="flex items-center"> |
| {keyValid ? ( |
| <Check className="h-5 w-5 text-green-500" /> |
| ) : ( |
| <X className="h-5 w-5 text-red-500" /> |
| )} |
| </div> |
| )} |
| </div> |
| <p className="text-sm text-muted-foreground mt-2"> |
| API key is optional for {providerConfig.name}. Only needed if you've configured authentication on your local server. |
| </p> |
| </div> |
| ) : null} |
| |
| {!providerConfig.apiKeyRequired && providerConfig.isLocal && ( |
| <div className="text-sm text-muted-foreground p-3 border rounded-md bg-muted/50"> |
| <p className="font-medium mb-1">Local Provider</p> |
| <p>Make sure {providerConfig.name} is running on your machine.</p> |
| <p>Default endpoint: <code className="text-xs">{providerConfig.baseUrl}</code></p> |
| {selectedProvider === 'lmstudio' && ( |
| <div className="mt-2 text-xs"> |
| <p className="font-medium">For tool use support:</p> |
| <p>• Load a model like qwen/qwen3-4b-thinking-2507</p> |
| <p>• Start the local server in LM Studio</p> |
| <p>• Models will be automatically discovered</p> |
| </div> |
| )} |
| </div> |
| )} |
| |
| {/* Divider */} |
| <hr className="border-border" /> |
| |
| {/* Code Model */} |
| <div> |
| <Label className="text-xs font-semibold uppercase tracking-wider text-muted-foreground">Code Model</Label> |
| <div className="mt-2"> |
| <ModelSelector |
| provider={selectedProvider} |
| mode="inline" |
| autoFocus={apiKeyStored || (!providerConfig.apiKeyRequired && !providerConfig.usesOAuth)} |
| onChange={(modelId) => { |
| if (typeof window !== 'undefined') { |
| localStorage.setItem(`osw-studio-code-model-${selectedProvider}`, modelId); |
| } |
| if (!useSeparateChatModel) { |
| onModelChange?.(modelId); |
| } |
| }} |
| className="space-y-2" |
| /> |
| </div> |
| </div> |
| |
| {/* Separate Chat Model Toggle — hidden in judge mode */} |
| {!showJudgeModel && ( |
| <div className="flex items-center justify-between gap-3"> |
| <div> |
| <div className="text-sm font-medium">Use different model for chat</div> |
| <p className="text-xs text-muted-foreground mt-0.5"> |
| Select a separate (usually cheaper) model for chat/planning |
| </p> |
| </div> |
| <Switch |
| id="separate-chat-model" |
| checked={useSeparateChatModel} |
| onCheckedChange={(checked) => setUseSeparateChatModel(checked)} |
| /> |
| </div> |
| )} |
| |
| {/* Chat Model (conditional) — hidden in judge mode */} |
| {!showJudgeModel && useSeparateChatModel && ( |
| <div> |
| <Label className="text-xs font-semibold uppercase tracking-wider text-muted-foreground">Chat Model</Label> |
| <div className="mt-2"> |
| <ModelSelector |
| provider={selectedProvider} |
| mode="inline" |
| onChange={(modelId) => { |
| if (typeof window !== 'undefined') { |
| localStorage.setItem(`osw-studio-chat-model-${selectedProvider}`, modelId); |
| } |
| onModelChange?.(modelId); |
| }} |
| className="space-y-2" |
| /> |
| </div> |
| </div> |
| )} |
| |
| {/* Judge Model — shown only in benchmark mode */} |
| {showJudgeModel && ( |
| <> |
| <hr className="border-border" /> |
| <div> |
| <Label className="text-xs font-semibold uppercase tracking-wider text-muted-foreground"> |
| Judge Model <span className="normal-case font-normal">(optional)</span> |
| </Label> |
| <p className="text-xs text-muted-foreground mt-1 mb-2"> |
| Separate model for evaluating subjective test criteria |
| </p> |
| <ModelSelector |
| provider={selectedProvider} |
| mode="inline" |
| skipGlobalSync |
| onChange={(modelId) => onJudgeModelChange?.(modelId)} |
| className="space-y-2" |
| /> |
| </div> |
| </> |
| )} |
|
|
| {} |
| <div className="flex items-center justify-between gap-3"> |
| <div> |
| <div className="text-sm font-medium">Auto-compact</div> |
| <p className="text-xs text-muted-foreground mt-0.5"> |
| Summarize conversation when reaching 60% of the model's context limit |
| </p> |
| </div> |
| <Switch |
| id="compaction-enabled" |
| checked={compactionEnabled} |
| onCheckedChange={(checked) => { |
| setCompactionEnabled(checked); |
| configManager.setCompactionEnabled(selectedProvider, checked); |
| }} |
| /> |
| </div> |
|
|
| {compactionEnabled && ( |
| <div className="space-y-2"> |
| <label className="text-sm font-medium text-foreground"> |
| Compaction limit (tokens) |
| </label> |
| <p className="text-xs text-muted-foreground"> |
| Leave empty to auto-detect (60% of model context). Manual values are used as-is. |
| </p> |
| <input |
| type="number" |
| value={compactionLimit} |
| onChange={(e) => { |
| const val = e.target.value; |
| setCompactionLimit(val); |
| const num = parseInt(val, 10); |
| configManager.setCompactionLimit( |
| selectedProvider, |
| isNaN(num) || num <= 0 ? undefined : num |
| ); |
| }} |
| placeholder="e.g. 128000" |
| className="w-full rounded-md border border-input bg-background px-3 py-2 text-sm" |
| min={1} |
| /> |
| </div> |
| )} |
|
|
| </div>{/* end scrollable content */} |
|
|
| {} |
| {onClose && ( |
| <div className="shrink-0 flex justify-end pt-4 border-t mt-4"> |
| <Button onClick={onClose} size="sm"> |
| Done |
| </Button> |
| </div> |
| )} |
| </div> |
| ); |
| } |
|
|