CoolFace
Apppublic

Leon4gr45/builder

sourceHugging Facemitupdated 2d agoView on Hugging Face
0likes
model-settings.tsx527 linesDownload Raw Back to settings
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&apos;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&apos;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