Leon4gr45/builder
0
1'use client';2 3import React, { useState, useEffect } from 'react';4import { configManager } from '@/lib/config/storage';5import { validateApiKey as checkApiKey } from '@/lib/llm/llm-client';6import { Button } from '@/components/ui/button';7import { Input } from '@/components/ui/input';8import { Label } from '@/components/ui/label';9import { Switch } from '@/components/ui/switch';10import { Eye, EyeOff, Check, X, ExternalLink, Loader2 } from 'lucide-react';11import { toast } from 'sonner';12import { ModelSelector } from '@/components/model-selector';13import {14 Select,15 SelectContent,16 SelectItem,17 SelectTrigger,18 SelectValue,19} from '@/components/ui/select';20import { ProviderId } from '@/lib/llm/providers/types';21import { getOfferableProviders, getProvider } from '@/lib/llm/providers/registry';22import { CodexAuthPanel } from '@/components/settings/codex-auth-panel';23import { HFAuthPanel } from '@/components/settings/hf-auth-panel';24import { ConnectionBadge } from '@/components/settings/connection-badge';25import { checkHFCapabilities } from '@/lib/auth/hf-auth';26import { track } from '@/lib/telemetry';27 28interface ModelSettingsPanelProps {29 onClose?: () => void;30 onModelChange?: (modelId: string) => void;31 showJudgeModel?: boolean;32 onJudgeModelChange?: (modelId: string) => void;33}34 35export function ModelSettingsPanel({ onClose, onModelChange, showJudgeModel, onJudgeModelChange }: ModelSettingsPanelProps) {36 const [selectedProvider, setSelectedProvider] = useState<ProviderId>(() =>37 configManager.getSelectedProvider()38 );39 const [showApiKey, setShowApiKey] = useState(false);40 const [validatingKey, setValidatingKey] = useState(false);41 const [keyValid, setKeyValid] = useState<boolean | null>(null);42 const [currentApiKey, setCurrentApiKey] = useState('');43 const [apiKeyStored, setApiKeyStored] = useState(() => {44 const p = configManager.getSelectedProvider();45 return getProvider(p).apiKeyRequired ? !!configManager.getProviderApiKey(p) : false;46 });47 const [codexAvailable, setCodexAvailable] = useState(true);48 const [useSeparateChatModel, setUseSeparateChatModel] = useState<boolean>(() => {49 if (typeof window !== 'undefined') {50 const stored = localStorage.getItem(`osw-studio-use-separate-chat-model-${configManager.getSelectedProvider()}`);51 return stored === 'true';52 }53 return false;54 });55 const [compactionEnabled, setCompactionEnabled] = useState<boolean>(() => {56 return configManager.isCompactionEnabled(configManager.getSelectedProvider());57 });58 const [compactionLimit, setCompactionLimit] = useState<string>(() => {59 const savedLimit = configManager.getCompactionLimit(configManager.getSelectedProvider());60 return savedLimit ? String(savedLimit) : '';61 });62 63 // Check if Codex is available (blocked on HF Spaces — HttpOnly cookies don't work)64 useEffect(() => {65 checkHFCapabilities().then(caps => {66 setCodexAvailable(caps.codexAvailable);67 }).catch(() => {});68 }, []);69 70 useEffect(() => {71 // Update API key when provider changes72 const key = configManager.getProviderApiKey(selectedProvider) || '';73 setCurrentApiKey(key);74 setKeyValid(null); // Reset validation75 const providerCfg = getProvider(selectedProvider);76 setApiKeyStored(providerCfg.apiKeyRequired ? !!key : false);77 78 // Load separate chat model setting for this provider79 if (typeof window !== 'undefined') {80 const stored = localStorage.getItem(`osw-studio-use-separate-chat-model-${selectedProvider}`);81 setUseSeparateChatModel(stored === 'true');82 }83 84 // Load compaction settings for this provider85 setCompactionEnabled(configManager.isCompactionEnabled(selectedProvider));86 const savedLimit = configManager.getCompactionLimit(selectedProvider);87 setCompactionLimit(savedLimit ? String(savedLimit) : '');88 }, [selectedProvider]);89 90 // Persist separate chat model setting91 useEffect(() => {92 if (typeof window !== 'undefined') {93 localStorage.setItem(`osw-studio-use-separate-chat-model-${selectedProvider}`, String(useSeparateChatModel));94 }95 }, [useSeparateChatModel, selectedProvider]);96 97 const handleProviderChange = (provider: ProviderId) => {98 setSelectedProvider(provider);99 configManager.setSelectedProvider(provider);100 track('provider_selected', { provider, has_api_key: !!configManager.getProviderApiKey(provider) });101 };102 103 const handleApiKeyChange = (key: string) => {104 setCurrentApiKey(key);105 configManager.setProviderApiKey(selectedProvider, key);106 setKeyValid(null);107 configManager.clearModelCache(selectedProvider);108 window.dispatchEvent(new CustomEvent('apiKeyUpdated', {109 detail: { provider: selectedProvider, hasKey: !!key }110 }));111 };112 113 const validateApiKey = async () => {114 if (!currentApiKey) {115 toast.error('Please enter an API key');116 return;117 }118 119 setValidatingKey(true);120 try {121 const isValid = await checkApiKey(currentApiKey, selectedProvider);122 setKeyValid(isValid);123 124 if (isValid) {125 toast.success('API key is valid!');126 } else {127 toast.error('Invalid API key');128 }129 } catch {130 setKeyValid(false);131 toast.error('Failed to validate API key');132 } finally {133 setValidatingKey(false);134 }135 };136 137 const handleConnect = async () => {138 const key = currentApiKey.trim();139 if (!key) {140 toast.error('Please enter an API key');141 return;142 }143 setValidatingKey(true);144 try {145 const isValid = await checkApiKey(key, selectedProvider);146 if (isValid) {147 configManager.setProviderApiKey(selectedProvider, key);148 configManager.clearModelCache(selectedProvider);149 setApiKeyStored(true);150 setCurrentApiKey('');151 setKeyValid(null);152 toast.success('API key connected!');153 window.dispatchEvent(new CustomEvent('apiKeyUpdated', {154 detail: { provider: selectedProvider, hasKey: true }155 }));156 } else {157 toast.error('Invalid API key. Please check and try again.');158 }159 } catch {160 toast.error('Failed to validate API key');161 } finally {162 setValidatingKey(false);163 }164 };165 166 const handleApiKeyDisconnect = () => {167 configManager.setProviderApiKey(selectedProvider, '');168 configManager.clearModelCache(selectedProvider);169 setApiKeyStored(false);170 setCurrentApiKey('');171 setKeyValid(null);172 toast.success(`Disconnected from ${getProvider(selectedProvider).name}`);173 window.dispatchEvent(new CustomEvent('apiKeyUpdated', {174 detail: { provider: selectedProvider, hasKey: false }175 }));176 };177 178 const providerConfig = getProvider(selectedProvider);179 180 return (181 <div className="flex-1 min-h-0 flex flex-col overflow-hidden">182 {/* Header */}183 <div className="shrink-0">184 <h3 className="font-semibold text-base tracking-tight">Model Settings</h3>185 <p className="text-muted-foreground text-xs mt-1">186 Configure your AI model and API connection187 </p>188 </div>189 190 {/* Scrollable content */}191 <div className="flex-1 min-h-0 overflow-y-auto mt-5 space-y-5">192 193 {/* Provider Selection */}194 <div>195 <Label htmlFor="provider" className="text-xs font-semibold uppercase tracking-wider text-muted-foreground">Provider</Label>196 <Select value={selectedProvider} onValueChange={handleProviderChange}>197 <SelectTrigger id="provider" className="mt-2 !h-fit w-full">198 <SelectValue placeholder="Select a provider">199 {selectedProvider && (200 <div className="flex flex-col text-left">201 <span className="font-medium">{providerConfig.name}</span>202 <span className="text-xs text-muted-foreground">203 {providerConfig.description}204 </span>205 </div>206 )}207 </SelectValue>208 </SelectTrigger>209 <SelectContent className="max-h-[400px]">210 {getOfferableProviders()211 .filter(p => codexAvailable || p.id !== 'openai-codex')212 .map(provider => (213 <SelectItem key={provider.id} value={provider.id} className="py-2.5">214 <div className="flex flex-col">215 <span className="font-medium">{provider.name}</span>216 <span className="text-xs text-muted-foreground">217 {provider.description}218 </span>219 </div>220 </SelectItem>221 ))}222 </SelectContent>223 </Select>224 </div>225 226 {/* Auth: OAuth panel for Codex/HF, API key for others */}227 {providerConfig.usesOAuth ? (228 selectedProvider === 'huggingface' ? (229 <HFAuthPanel onAuthChange={() => {230 window.dispatchEvent(new CustomEvent('apiKeyUpdated', {231 detail: { provider: selectedProvider, hasKey: !!configManager.getProviderApiKey(selectedProvider) }232 }));233 }} />234 ) : (235 <CodexAuthPanel onAuthChange={() => {236 window.dispatchEvent(new CustomEvent('apiKeyUpdated', {237 detail: { provider: selectedProvider, hasKey: !!configManager.getProviderApiKey(selectedProvider) }238 }));239 }} />240 )241 ) : providerConfig.apiKeyRequired ? (242 apiKeyStored ? (243 <ConnectionBadge244 method="API Key"245 extra={(() => { const k = configManager.getProviderApiKey(selectedProvider); return k ? `···${k.slice(-4)}` : undefined; })()}246 info={providerConfig.apiKeyHelpUrl && (247 <a248 href={providerConfig.apiKeyHelpUrl}249 target="_blank"250 rel="noopener noreferrer"251 className="text-primary hover:underline inline-flex items-center gap-0.5"252 >253 Manage on {providerConfig.name} <ExternalLink className="h-2.5 w-2.5" />254 </a>255 )}256 onDisconnect={handleApiKeyDisconnect}257 />258 ) : (259 <div>260 <Label htmlFor="api-key">{providerConfig.name} API Key</Label>261 <div className="flex gap-2 mt-2">262 <div className="relative flex-1">263 <Input264 id="api-key"265 type={showApiKey ? "text" : "password"}266 value={currentApiKey}267 onChange={(e) => { setCurrentApiKey(e.target.value); setKeyValid(null); }}268 onKeyDown={(e) => { if (e.key === 'Enter' && currentApiKey.trim()) handleConnect(); }}269 placeholder={providerConfig.apiKeyPlaceholder || 'API Key'}270 className="pr-10"271 data-tour-id="provider-key-input"272 disabled={validatingKey}273 />274 <Button275 size="icon"276 variant="ghost"277 className="absolute right-1 top-1 h-7 w-7"278 onClick={() => setShowApiKey(!showApiKey)}279 >280 {showApiKey ? (281 <EyeOff className="h-4 w-4" />282 ) : (283 <Eye className="h-4 w-4" />284 )}285 </Button>286 </div>287 <Button288 onClick={handleConnect}289 disabled={validatingKey || !currentApiKey.trim()}290 size="sm"291 >292 {validatingKey ? (293 <>294 <Loader2 className="h-3 w-3 animate-spin mr-1" />295 Connecting...296 </>297 ) : (298 'Connect'299 )}300 </Button>301 </div>302 {providerConfig.apiKeyHelpUrl && (303 <p className="text-sm text-muted-foreground mt-2">304 Get your API key from{' '}305 <a306 href={providerConfig.apiKeyHelpUrl}307 target="_blank"308 rel="noopener noreferrer"309 className="text-primary hover:underline inline-flex items-center gap-1"310 >311 {providerConfig.name} <ExternalLink className="h-3 w-3" />312 </a>313 </p>314 )}315 </div>316 )317 ) : providerConfig.isLocal ? (318 <div>319 <Label htmlFor="api-key">320 {providerConfig.name} API Key321 <span className="text-muted-foreground text-xs ml-1">(optional)</span>322 </Label>323 <div className="flex gap-2 mt-2">324 <div className="relative flex-1">325 <Input326 id="api-key"327 type={showApiKey ? "text" : "password"}328 value={currentApiKey}329 onChange={(e) => handleApiKeyChange(e.target.value)}330 placeholder={providerConfig.apiKeyPlaceholder || 'API Key'}331 className="pr-10"332 data-tour-id="provider-key-input"333 />334 <Button335 size="icon"336 variant="ghost"337 className="absolute right-1 top-1 h-7 w-7"338 onClick={() => setShowApiKey(!showApiKey)}339 >340 {showApiKey ? (341 <EyeOff className="h-4 w-4" />342 ) : (343 <Eye className="h-4 w-4" />344 )}345 </Button>346 </div>347 <Button348 onClick={validateApiKey}349 disabled={validatingKey || !currentApiKey}350 size="sm"351 >352 {validatingKey ? 'Validating...' : 'Validate'}353 </Button>354 {keyValid !== null && (355 <div className="flex items-center">356 {keyValid ? (357 <Check className="h-5 w-5 text-green-500" />358 ) : (359 <X className="h-5 w-5 text-red-500" />360 )}361 </div>362 )}363 </div>364 <p className="text-sm text-muted-foreground mt-2">365 API key is optional for {providerConfig.name}. Only needed if you've configured authentication on your local server.366 </p>367 </div>368 ) : null}369 370 {!providerConfig.apiKeyRequired && providerConfig.isLocal && (371 <div className="text-sm text-muted-foreground p-3 border rounded-md bg-muted/50">372 <p className="font-medium mb-1">Local Provider</p>373 <p>Make sure {providerConfig.name} is running on your machine.</p>374 <p>Default endpoint: <code className="text-xs">{providerConfig.baseUrl}</code></p>375 {selectedProvider === 'lmstudio' && (376 <div className="mt-2 text-xs">377 <p className="font-medium">For tool use support:</p>378 <p>• Load a model like qwen/qwen3-4b-thinking-2507</p>379 <p>• Start the local server in LM Studio</p>380 <p>• Models will be automatically discovered</p>381 </div>382 )}383 </div>384 )}385 386 {/* Divider */}387 <hr className="border-border" />388 389 {/* Code Model */}390 <div>391 <Label className="text-xs font-semibold uppercase tracking-wider text-muted-foreground">Code Model</Label>392 <div className="mt-2">393 <ModelSelector394 provider={selectedProvider}395 mode="inline"396 autoFocus={apiKeyStored || (!providerConfig.apiKeyRequired && !providerConfig.usesOAuth)}397 onChange={(modelId) => {398 if (typeof window !== 'undefined') {399 localStorage.setItem(`osw-studio-code-model-${selectedProvider}`, modelId);400 }401 if (!useSeparateChatModel) {402 onModelChange?.(modelId);403 }404 }}405 className="space-y-2"406 />407 </div>408 </div>409 410 {/* Separate Chat Model Toggle — hidden in judge mode */}411 {!showJudgeModel && (412 <div className="flex items-center justify-between gap-3">413 <div>414 <div className="text-sm font-medium">Use different model for chat</div>415 <p className="text-xs text-muted-foreground mt-0.5">416 Select a separate (usually cheaper) model for chat/planning417 </p>418 </div>419 <Switch420 id="separate-chat-model"421 checked={useSeparateChatModel}422 onCheckedChange={(checked) => setUseSeparateChatModel(checked)}423 />424 </div>425 )}426 427 {/* Chat Model (conditional) — hidden in judge mode */}428 {!showJudgeModel && useSeparateChatModel && (429 <div>430 <Label className="text-xs font-semibold uppercase tracking-wider text-muted-foreground">Chat Model</Label>431 <div className="mt-2">432 <ModelSelector433 provider={selectedProvider}434 mode="inline"435 onChange={(modelId) => {436 if (typeof window !== 'undefined') {437 localStorage.setItem(`osw-studio-chat-model-${selectedProvider}`, modelId);438 }439 onModelChange?.(modelId);440 }}441 className="space-y-2"442 />443 </div>444 </div>445 )}446 447 {/* Judge Model — shown only in benchmark mode */}448 {showJudgeModel && (449 <>450 <hr className="border-border" />451 <div>452 <Label className="text-xs font-semibold uppercase tracking-wider text-muted-foreground">453 Judge Model <span className="normal-case font-normal">(optional)</span>454 </Label>455 <p className="text-xs text-muted-foreground mt-1 mb-2">456 Separate model for evaluating subjective test criteria457 </p>458 <ModelSelector459 provider={selectedProvider}460 mode="inline"461 skipGlobalSync462 onChange={(modelId) => onJudgeModelChange?.(modelId)}463 className="space-y-2"464 />465 </div>466 </>467 )}468 469 {/* Auto-Compaction */}470 <div className="flex items-center justify-between gap-3">471 <div>472 <div className="text-sm font-medium">Auto-compact</div>473 <p className="text-xs text-muted-foreground mt-0.5">474 Summarize conversation when reaching 60% of the model's context limit475 </p>476 </div>477 <Switch478 id="compaction-enabled"479 checked={compactionEnabled}480 onCheckedChange={(checked) => {481 setCompactionEnabled(checked);482 configManager.setCompactionEnabled(selectedProvider, checked);483 }}484 />485 </div>486 487 {compactionEnabled && (488 <div className="space-y-2">489 <label className="text-sm font-medium text-foreground">490 Compaction limit (tokens)491 </label>492 <p className="text-xs text-muted-foreground">493 Leave empty to auto-detect (60% of model context). Manual values are used as-is.494 </p>495 <input496 type="number"497 value={compactionLimit}498 onChange={(e) => {499 const val = e.target.value;500 setCompactionLimit(val);501 const num = parseInt(val, 10);502 configManager.setCompactionLimit(503 selectedProvider,504 isNaN(num) || num <= 0 ? undefined : num505 );506 }}507 placeholder="e.g. 128000"508 className="w-full rounded-md border border-input bg-background px-3 py-2 text-sm"509 min={1}510 />511 </div>512 )}513 514 </div>{/* end scrollable content */}515 516 {/* Footer */}517 {onClose && (518 <div className="shrink-0 flex justify-end pt-4 border-t mt-4">519 <Button onClick={onClose} size="sm">520 Done521 </Button>522 </div>523 )}524 </div>525 );526}527 