Advertisement

← vibesdk

src/components/config-modal.tsx

19.6 KB Download file
/**
 * Redesigned Model Configuration Modal
 * Three-mode interface: Platform Models, BYOK (Bring Your Own Key), Custom Providers
 */

import { useState, useEffect, useMemo, useCallback } from 'react';
import { Settings, Play, RotateCcw, Info, Key } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Badge } from '@/components/ui/badge';
import { Separator } from '@/components/ui/separator';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
import { ModelSelector } from '@/components/ui/model-selector';
import { Check } from 'lucide-react';
import {
  Dialog,
  DialogContent,
  DialogDescription,
  DialogFooter,
  DialogHeader,
  DialogTitle,
} from '@/components/ui/dialog';
import { Alert, AlertDescription } from '@/components/ui/alert';
import { apiClient } from '@/lib/api-client';
import type {
  ModelConfig,
  UserModelConfigWithMetadata,
  ModelConfigUpdate,
  ByokProvidersData,
  AgentDisplayConfig
} from '@/api-types';

interface ConfigModalProps {
  isOpen: boolean;
  onClose: () => void;
  agentConfig: AgentDisplayConfig;
  userConfig?: UserModelConfigWithMetadata;
  defaultConfig?: ModelConfig;
  onSave: (config: ModelConfigUpdate) => Promise<void>;
  onTest: (tempConfig?: ModelConfigUpdate) => Promise<void>;
  onReset: () => Promise<void>;
  isTesting: boolean;
}


// Helper to extract provider from model name (e.g., "openai/gpt-4" -> "openai")
const getProviderFromModel = (modelName: string): string => {
  if (!modelName || modelName === 'default') return '';
  return modelName.split('/')[0] || '';
};

// Helper to check if user has BYOK key for a model's provider
const hasUserKeyForModel = (modelName: string, byokProviders: Array<{ provider: string; hasValidKey: boolean }>): boolean => {
  const provider = getProviderFromModel(modelName);
  if (!provider) return false;

  return byokProviders.some(p => p.provider === provider && p.hasValidKey);
};


// Model recommendations by agent
const getModelRecommendation = (agentAction: string) => {
  const recommendations: Record<string, string> = {
    templateSelection: 'Recommended: Fast models for quick template selection',
    blueprint: 'Recommended: Creative models for architecture design',
    projectSetup: 'Recommended: Reliable models for precise setup',
    phaseGeneration: 'Recommended: Large context models for comprehensive planning',
    firstPhaseImplementation: 'Recommended: High-capability models for foundation development',
    phaseImplementation: 'Recommended: Strong coding models for implementation',
    realtimeCodeFixer: 'Recommended: Fast debugging models',
    fastCodeFixer: 'Recommended: Ultra-fast models for quick fixes',
    conversationalResponse: 'Recommended: Balanced models for natural conversation',
    codeReview: 'Recommended: Analytical models with large context',
    fileRegeneration: 'Recommended: Pure coding models',
    screenshotAnalysis: 'Recommended: Vision-capable models for image analysis'
  };
  return recommendations[agentAction] || '';
};

export function ConfigModal({
  isOpen,
  onClose,
  agentConfig,
  userConfig,
  defaultConfig,
  onSave,
  onTest,
  onReset,
  isTesting
}: ConfigModalProps) {
  // Form state
  const [formData, setFormData] = useState({
    modelName: userConfig?.name || 'default',
    temperature: userConfig?.temperature?.toString() || '',
    reasoningEffort: userConfig?.reasoning_effort || 'default',
    fallbackModel: userConfig?.fallbackModel || 'default'
  });

  // UI state
  const [hasChanges, setHasChanges] = useState(false);

  // Modal lifecycle tracking
  const [isInitialOpen, setIsInitialOpen] = useState(false);

  // BYOK data state
  const [byokData, setByokData] = useState<ByokProvidersData | null>(null);
  const [loadingByok, setLoadingByok] = useState(false);

  // Load BYOK data (filtered by agent constraints)
  const loadByokData = useCallback(async () => {
    try {
      setLoadingByok(true);
      // Pass agent key to get constraint-filtered models
      const response = await apiClient.getByokProviders(agentConfig.key);
      if (response.success && response.data) {
        setByokData(response.data);
      }
    } catch (error) {
      console.error('Failed to load BYOK data:', error);
    } finally {
      setLoadingByok(false);
    }
  }, [agentConfig.key]);

  // Handle modal open/close lifecycle
  useEffect(() => {
    if (isOpen && !isInitialOpen) {
      // First time opening - reset everything and load data
      setFormData({
        modelName: userConfig?.name || 'default',
        temperature: userConfig?.temperature?.toString() || '',
        reasoningEffort: userConfig?.reasoning_effort || 'default',
        fallbackModel: userConfig?.fallbackModel || 'default'
      });
      setHasChanges(false);
      setIsInitialOpen(true);
      loadByokData();
    } else if (!isOpen && isInitialOpen) {
      // Modal closed - reset for next time
      setIsInitialOpen(false);
    }
  }, [isOpen, isInitialOpen, userConfig, loadByokData]);

  // Load BYOK data when modal opens
  useEffect(() => {
    if (byokData && isInitialOpen) {
      // BYOK data is loaded, ready for model selection
    }
  }, [byokData, isInitialOpen, userConfig?.name]);

  // Check for changes
  useEffect(() => {
    const originalFormData = {
      modelName: userConfig?.name || 'default',
      temperature: userConfig?.temperature?.toString() || '',
      reasoningEffort: userConfig?.reasoning_effort || 'default',
      fallbackModel: userConfig?.fallbackModel || 'default'
    };

    setHasChanges(JSON.stringify(formData) !== JSON.stringify(originalFormData));
  }, [formData, userConfig]);

  // Get unified model list with BYOK status info
  const availableModels = useMemo(() => {
    if (!byokData) return [];

    const models: { value: string; label: string; provider: string; hasUserKey: boolean; byokAvailable: boolean }[] = [];
    const processedModels = new Set<string>();

    // First, add all BYOK models (they have BYOK capability)
    Object.values(byokData.modelsByProvider).forEach(providerModels => {
      providerModels.forEach(model => {
        const modelStr = model as string;
        if (!processedModels.has(modelStr)) {
          const provider = getProviderFromModel(modelStr);
          const hasUserKey = hasUserKeyForModel(modelStr, byokData.providers);

          models.push({
            value: modelStr,
            label: modelStr,
            provider,
            hasUserKey,
            byokAvailable: true
          });
          processedModels.add(modelStr);
        }
      });
    });

    // Then, add platform-only models (no BYOK capability)
    byokData.platformModels.forEach(model => {
      const modelStr = model as string;
      if (!processedModels.has(modelStr)) {
        models.push({
          value: modelStr,
          label: modelStr,
          provider: '',
          hasUserKey: false,
          byokAvailable: false
        });
        processedModels.add(modelStr);
      }
    });

    return models.sort((a, b) => a.label.localeCompare(b.label));
  }, [byokData]);

  // Get current model's BYOK status
  const selectedModelInfo = useMemo(() => {
    const currentModel = formData.modelName && formData.modelName !== 'default'
      ? formData.modelName
      : '';

    if (!currentModel || !byokData) {
      return { hasUserKey: false, provider: '', requiresBYOK: false, isPlatformModel: true };
    }

    // Check if this is a BYOK-capable model
    const isByokModel = Object.values(byokData.modelsByProvider).some(providerModels =>
      providerModels.some(model => model === currentModel)
    );

    const provider = getProviderFromModel(currentModel);
    const hasUserKey = hasUserKeyForModel(currentModel, byokData.providers);

    return {
      hasUserKey,
      provider,
      requiresBYOK: isByokModel && !hasUserKey, // Only BYOK-capable models can require keys
      isPlatformModel: !isByokModel
    };
  }, [formData.modelName, byokData]);

  // Create config object from current form state
  const buildCurrentConfig = (): ModelConfigUpdate => {
    return {
      ...(formData.modelName !== 'default' && { modelName: formData.modelName }),
      ...(formData.temperature && { temperature: parseFloat(formData.temperature) }),
      ...(formData.reasoningEffort !== 'default' && { reasoningEffort: formData.reasoningEffort }),
      ...(formData.fallbackModel !== 'default' && { fallbackModel: formData.fallbackModel }),
      isUserOverride: true
    };
  };

  const handleSave = async () => {
    const config = buildCurrentConfig();
    await onSave(config);
  };

  const handleTestWithCurrentConfig = async () => {
    const currentConfig = buildCurrentConfig();
    // We'll need to update the parent component to handle testing with temporary config
    await onTest(currentConfig);
  };

  const handleReset = async () => {
    await onReset();
    onClose();
  };

  const isUserOverride = userConfig?.isUserOverride || false;

  return (
    <Dialog open={isOpen} onOpenChange={onClose}>
      <DialogContent className="overflow-y-auto max-w-3xl w-[90vw] max-h-[90vh] sm:max-w-4xl">
        <DialogHeader>
          <DialogTitle className="flex items-center gap-2">
            <Settings className="h-5 w-5" />
            Configure {agentConfig.name}
          </DialogTitle>
          <DialogDescription>
            {agentConfig.description}
          </DialogDescription>
        </DialogHeader>

        {/* Alerts outside DialogDescription to avoid nested p/div issues */}
        <div className="space-y-2 -mt-2">
          {agentConfig.constraint?.enabled && (
            <Alert>
              <Info className="h-4 w-4" />
              <AlertDescription className="text-sm">
                Model selection limited to {agentConfig.constraint.allowedModels.length} allowed model{agentConfig.constraint.allowedModels.length !== 1 ? 's' : ''} for this operation.
              </AlertDescription>
            </Alert>
          )}
          {getModelRecommendation(agentConfig.key) && (
            <Alert>
              <Info className="h-4 w-4" />
              <AlertDescription className="text-sm">
                {getModelRecommendation(agentConfig.key)}
              </AlertDescription>
            </Alert>
          )}
        </div>

        <div className="space-y-6">
          {/* Current Status */}
          <div className="flex items-center justify-between p-3 bg-kumo-base/50 rounded-lg">
            <div>
              <p className="font-medium text-sm">Configuration Status</p>
              <p className="text-xs text-kumo-subtle">
                {isUserOverride ? 'Using custom configuration' : 'Using system defaults'}
              </p>
            </div>
            <Badge variant={isUserOverride ? "default" : "outline"}>
              {isUserOverride ? "Custom" : "Default"}
            </Badge>
          </div>


          {/* Model Selection Section */}
          <div className="space-y-4">
            <div className="flex items-center justify-between">
              <div>
                <Label className="text-sm font-medium">Model Configuration</Label>
                <p className="text-xs text-kumo-subtle mt-1">
                  Select primary and fallback models - we'll use your API keys if available
                </p>
              </div>
              <Button variant="outline" size="sm"
              disabled // DISABLED: BYOK Disabled for security reasons
              className="gap-2">
                <Key className="h-4 w-4" />
                {/* Manage Keys */}
                Coming Soon
              </Button>
            </div>

            {/* Two-Column Model Layout */}
            <div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
              {/* Primary AI Model */}
              <div className="space-y-2">
                <ModelSelector
                  value={formData.modelName}
                  onValueChange={(value) => setFormData({...formData, modelName: value})}
                  availableModels={availableModels}
                  placeholder="Select model..."
                  label="AI Model"
                  systemDefault={defaultConfig?.name}
                  disabled={loadingByok}
                />

                {/* Model Status Messages */}
                {selectedModelInfo.requiresBYOK && (
                  <div className="flex items-center gap-2 text-sm text-amber-600 bg-amber-50 dark:bg-amber-950/20 px-3 py-2 rounded-md border border-amber-200 dark:border-amber-800">
                    <Key className="h-4 w-4" />
                    <span>API key needed for {selectedModelInfo.provider}</span>
                  </div>
                )}

                {selectedModelInfo.isPlatformModel && formData.modelName && formData.modelName !== 'default' && (
                  <div className="flex items-center gap-2 text-sm text-blue-600 bg-blue-50 dark:bg-blue-950/20 px-3 py-2 rounded-md border border-blue-200 dark:border-blue-800">
                    <svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
                      <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
                    </svg>
                    <span>Platform model with usage limits. Consider BYOK for higher usage.</span>
                  </div>
                )}

                {formData.modelName && formData.modelName !== 'default' && selectedModelInfo.hasUserKey && (
                  <div className="flex items-center gap-2 text-sm text-green-600 bg-green-50 dark:bg-green-950/20 px-3 py-2 rounded-md border border-green-200 dark:border-green-800">
                    <Check className="h-4 w-4" />
                    <span>Using your {selectedModelInfo.provider} API key</span>
                  </div>
                )}
              </div>

              {/* Fallback Model */}
              <div className="space-y-2">
                <ModelSelector
                  value={formData.fallbackModel}
                  onValueChange={(value) => setFormData({...formData, fallbackModel: value})}
                  availableModels={availableModels}
                  placeholder="Select fallback model..."
                  label="Fallback Model"
                  systemDefault={defaultConfig?.fallbackModel}
                  includeDefaultOption={true}
                  disabled={loadingByok}
                />
              </div>
            </div>
          </div>


          <Separator />

          {/* BYOK Information */}
          <div className="space-y-3">
            <div className="grid grid-cols-1 md:grid-cols-2 gap-4">
              <div className="p-4 rounded-lg border bg-blue-50/50 border-blue-200">
                <div className="flex items-center gap-2 mb-2">
                  <div className="h-2 w-2 rounded-full bg-blue-500"></div>
                  <h4 className="font-medium text-sm text-blue-900">Platform Models</h4>
                </div>
                <p className="text-xs text-blue-700">
                  Models served through our platform with limited quota. No API keys required.
                </p>
              </div>

              <div className="p-4 rounded-lg border bg-green-50/50 border-green-200">
                <div className="flex items-center gap-2 mb-2">
                  <div className="h-2 w-2 rounded-full bg-green-500"></div>
                  <h4 className="font-medium text-sm text-green-900">BYOK (Your Keys)</h4>
                </div>
                <p className="text-xs text-green-700">
                  Your API keys are used for direct billing with providers. Unlimited usage based on your provider account.
                </p>
              </div>
            </div>
          </div>

          <Separator />

          {/* Parameters */}
          <div className="space-y-4">
            <h4 className="font-medium text-sm">Parameters</h4>

            <div className="grid grid-cols-1 md:grid-cols-2 gap-4">
              {/* Temperature */}
              <div className="space-y-2">
                <Label className="text-sm font-medium">Temperature</Label>
                <Input
                  type="number"
                  min="0"
                  max="2"
                  step="0.1"
                  value={formData.temperature}
                  placeholder={defaultConfig?.temperature ? `${defaultConfig.temperature}` : '0.7'}
                  onChange={(e) => setFormData({...formData, temperature: e.target.value})}
                  className="h-10"
                />
                {defaultConfig?.temperature && (
                  <p className="text-xs text-kumo-subtle">
                    🔧 Default: {defaultConfig.temperature}
                  </p>
                )}
              </div>

              {/* Reasoning Effort */}
              <div className="space-y-2">
                <Label className="text-sm font-medium">Reasoning Effort</Label>
                <Select value={formData.reasoningEffort} onValueChange={(value) => setFormData({...formData, reasoningEffort: value})}>
                  <SelectTrigger className="h-10">
                    <SelectValue placeholder="Select effort..." />
                  </SelectTrigger>
                  <SelectContent>
                    <SelectItem value="default">Use default</SelectItem>
                    <SelectItem value="low">Low (Fast)</SelectItem>
                    <SelectItem value="medium">Medium (Balanced)</SelectItem>
                    <SelectItem value="high">High (Deep)</SelectItem>
                  </SelectContent>
                </Select>
                {defaultConfig?.reasoning_effort && (
                  <p className="text-xs text-kumo-subtle">
                    🔧 Default: {defaultConfig.reasoning_effort}
                  </p>
                )}
              </div>
            </div>
          </div>
        </div>

        <DialogFooter className="gap-3 flex-col sm:flex-row sm:justify-between">
          <div className="flex gap-2 flex-wrap">
            <Button
              variant="outline"
              size="sm"
              onClick={handleTestWithCurrentConfig}
              disabled={isTesting}
              className="gap-2"
            >
              {isTesting ? (
                <>
                  <Settings className="h-4 w-4 animate-spin" />
                  Testing...
                </>
              ) : (
                <>
                  <Play className="h-4 w-4" />
                  Test Config
                </>
              )}
            </Button>

            {isUserOverride && (
              <Button
                variant="ghost"
                size="sm"
                onClick={handleReset}
                className="gap-2 text-kumo-subtle"
              >
                <RotateCcw className="h-4 w-4" />
                Reset to Default
              </Button>
            )}
          </div>

          <div className="flex gap-2 flex-wrap sm:justify-end">
            <Button variant="outline" onClick={onClose}>
              Cancel
            </Button>
            <Button
              onClick={handleSave}
              disabled={!hasChanges}
            >
              Save Changes
            </Button>
          </div>
        </DialogFooter>
      </DialogContent>
    </Dialog>
  );
}