From 1e916aa86e5d44f56d2a93684de4561601e663ed Mon Sep 17 00:00:00 2001 From: "dayuan.jiang" Date: Mon, 22 Dec 2025 17:58:05 +0900 Subject: [PATCH] feat: add multi-provider model configuration - Add model config dialog for managing multiple AI providers - Support for OpenAI, Anthropic, Google, Azure, Bedrock, OpenRouter, DeepSeek, SiliconFlow, Ollama, and AI Gateway - Add model selector dropdown in chat panel header - Add API key validation endpoint - Add custom model ID input with keyboard navigation - Fix hover highlight in Command component - Add suggested models for each provider including latest Claude 4.5 series - Store configuration locally in browser --- app/api/validate-model/route.ts | 180 ++++++ components/ai-elements/model-selector.tsx | 148 +++++ components/chat-panel.tsx | 27 +- components/model-config-dialog.tsx | 607 ++++++++++++++++++ components/model-selector.tsx | 193 ++++++ components/settings-dialog.tsx | 223 +------ components/ui/command.tsx | 191 ++++++ components/ui/popover.tsx | 48 ++ hooks/use-model-config.ts | 354 +++++++++++ lib/i18n/dictionaries/en.json | 19 + lib/i18n/dictionaries/ja.json | 19 + lib/i18n/dictionaries/zh.json | 19 + lib/storage.ts | 4 + lib/types/model-config.ts | 290 +++++++++ package-lock.json | 741 ++++++++++++++++++++-- package.json | 4 +- 16 files changed, 2791 insertions(+), 276 deletions(-) create mode 100644 app/api/validate-model/route.ts create mode 100644 components/ai-elements/model-selector.tsx create mode 100644 components/model-config-dialog.tsx create mode 100644 components/model-selector.tsx create mode 100644 components/ui/command.tsx create mode 100644 components/ui/popover.tsx create mode 100644 hooks/use-model-config.ts create mode 100644 lib/types/model-config.ts diff --git a/app/api/validate-model/route.ts b/app/api/validate-model/route.ts new file mode 100644 index 0000000..20633a0 --- /dev/null +++ b/app/api/validate-model/route.ts @@ -0,0 +1,180 @@ +import { createAnthropic } from "@ai-sdk/anthropic" +import { createDeepSeek, deepseek } from "@ai-sdk/deepseek" +import { createGateway } from "@ai-sdk/gateway" +import { createGoogleGenerativeAI } from "@ai-sdk/google" +import { createOpenAI } from "@ai-sdk/openai" +import { createOpenRouter } from "@openrouter/ai-sdk-provider" +import { generateText } from "ai" +import { NextResponse } from "next/server" +import { createOllama } from "ollama-ai-provider-v2" + +export const runtime = "nodejs" + +interface ValidateRequest { + provider: string + apiKey: string + baseUrl?: string + modelId: string +} + +export async function POST(req: Request) { + try { + const body: ValidateRequest = await req.json() + const { provider, apiKey, baseUrl, modelId } = body + + if (!provider || !modelId) { + return NextResponse.json( + { valid: false, error: "Provider and model ID are required" }, + { status: 400 }, + ) + } + + // Ollama doesn't require API key + if (provider !== "ollama" && !apiKey) { + return NextResponse.json( + { valid: false, error: "API key is required" }, + { status: 400 }, + ) + } + + let model: any + + switch (provider) { + case "openai": { + const openai = createOpenAI({ + apiKey, + ...(baseUrl && { baseURL: baseUrl }), + }) + model = openai.chat(modelId) + break + } + + case "anthropic": { + const anthropic = createAnthropic({ + apiKey, + baseURL: baseUrl || "https://api.anthropic.com/v1", + }) + model = anthropic(modelId) + break + } + + case "google": { + const google = createGoogleGenerativeAI({ + apiKey, + ...(baseUrl && { baseURL: baseUrl }), + }) + model = google(modelId) + break + } + + case "azure": { + const azure = createOpenAI({ + apiKey, + baseURL: baseUrl, + }) + model = azure.chat(modelId) + break + } + + case "openrouter": { + const openrouter = createOpenRouter({ + apiKey, + ...(baseUrl && { baseURL: baseUrl }), + }) + model = openrouter(modelId) + break + } + + case "deepseek": { + if (baseUrl || apiKey) { + const ds = createDeepSeek({ + apiKey, + ...(baseUrl && { baseURL: baseUrl }), + }) + model = ds(modelId) + } else { + model = deepseek(modelId) + } + break + } + + case "siliconflow": { + const sf = createOpenAI({ + apiKey, + baseURL: baseUrl || "https://api.siliconflow.com/v1", + }) + model = sf.chat(modelId) + break + } + + case "ollama": { + const ollama = createOllama({ + baseURL: baseUrl || "http://localhost:11434", + }) + model = ollama(modelId) + break + } + + case "gateway": { + const gw = createGateway({ + apiKey, + ...(baseUrl && { baseURL: baseUrl }), + }) + model = gw(modelId) + break + } + + default: + return NextResponse.json( + { valid: false, error: `Unknown provider: ${provider}` }, + { status: 400 }, + ) + } + + // Make a minimal test request + const startTime = Date.now() + await generateText({ + model, + prompt: "Say 'OK'", + maxOutputTokens: 20, + }) + const responseTime = Date.now() - startTime + + return NextResponse.json({ + valid: true, + responseTime, + }) + } catch (error) { + console.error("[validate-model] Error:", error) + + let errorMessage = "Validation failed" + if (error instanceof Error) { + // Extract meaningful error message + if ( + error.message.includes("401") || + error.message.includes("Unauthorized") + ) { + errorMessage = "Invalid API key" + } else if ( + error.message.includes("404") || + error.message.includes("not found") + ) { + errorMessage = "Model not found" + } else if ( + error.message.includes("429") || + error.message.includes("rate limit") + ) { + errorMessage = "Rate limited - try again later" + } else if (error.message.includes("ECONNREFUSED")) { + errorMessage = "Cannot connect to server" + } else { + errorMessage = error.message.slice(0, 100) + } + } + + return NextResponse.json( + { valid: false, error: errorMessage }, + { status: 200 }, // Return 200 so client can read error message + ) + } +} diff --git a/components/ai-elements/model-selector.tsx b/components/ai-elements/model-selector.tsx new file mode 100644 index 0000000..9b5aff8 --- /dev/null +++ b/components/ai-elements/model-selector.tsx @@ -0,0 +1,148 @@ +import type { ComponentProps, ReactNode } from "react" +import { + Command, + CommandDialog, + CommandEmpty, + CommandGroup, + CommandInput, + CommandItem, + CommandList, + CommandSeparator, + CommandShortcut, +} from "@/components/ui/command" +import { + Dialog, + DialogContent, + DialogTitle, + DialogTrigger, +} from "@/components/ui/dialog" +import { cn } from "@/lib/utils" + +export type ModelSelectorProps = ComponentProps + +export const ModelSelector = (props: ModelSelectorProps) => ( + +) + +export type ModelSelectorTriggerProps = ComponentProps + +export const ModelSelectorTrigger = (props: ModelSelectorTriggerProps) => ( + +) + +export type ModelSelectorContentProps = ComponentProps & { + title?: ReactNode +} + +export const ModelSelectorContent = ({ + className, + children, + title = "Model Selector", + ...props +}: ModelSelectorContentProps) => ( + + {title} + + {children} + + +) + +export type ModelSelectorDialogProps = ComponentProps + +export const ModelSelectorDialog = (props: ModelSelectorDialogProps) => ( + +) + +export type ModelSelectorInputProps = ComponentProps + +export const ModelSelectorInput = ({ + className, + ...props +}: ModelSelectorInputProps) => ( + +) + +export type ModelSelectorListProps = ComponentProps + +export const ModelSelectorList = (props: ModelSelectorListProps) => ( + +) + +export type ModelSelectorEmptyProps = ComponentProps + +export const ModelSelectorEmpty = (props: ModelSelectorEmptyProps) => ( + +) + +export type ModelSelectorGroupProps = ComponentProps + +export const ModelSelectorGroup = (props: ModelSelectorGroupProps) => ( + +) + +export type ModelSelectorItemProps = ComponentProps + +export const ModelSelectorItem = (props: ModelSelectorItemProps) => ( + +) + +export type ModelSelectorShortcutProps = ComponentProps + +export const ModelSelectorShortcut = (props: ModelSelectorShortcutProps) => ( + +) + +export type ModelSelectorSeparatorProps = ComponentProps< + typeof CommandSeparator +> + +export const ModelSelectorSeparator = (props: ModelSelectorSeparatorProps) => ( + +) + +export type ModelSelectorLogoProps = Omit< + ComponentProps<"img">, + "src" | "alt" +> & { + provider: string +} + +export const ModelSelectorLogo = ({ + provider, + className, + ...props +}: ModelSelectorLogoProps) => ( + {`${provider} +) + +export type ModelSelectorLogoGroupProps = ComponentProps<"div"> + +export const ModelSelectorLogoGroup = ({ + className, + ...props +}: ModelSelectorLogoGroupProps) => ( +
img]:rounded-full [&>img]:bg-background [&>img]:p-px [&>img]:ring-1 dark:[&>img]:bg-foreground", + className, + )} + {...props} + /> +) + +export type ModelSelectorNameProps = ComponentProps<"span"> + +export const ModelSelectorName = ({ + className, + ...props +}: ModelSelectorNameProps) => ( + +) diff --git a/components/chat-panel.tsx b/components/chat-panel.tsx index 70b3030..416393f 100644 --- a/components/chat-panel.tsx +++ b/components/chat-panel.tsx @@ -18,11 +18,13 @@ import { FaGithub } from "react-icons/fa" import { Toaster, toast } from "sonner" import { ButtonWithTooltip } from "@/components/button-with-tooltip" import { ChatInput } from "@/components/chat-input" +import { ModelConfigDialog } from "@/components/model-config-dialog" +import { ModelSelector } from "@/components/model-selector" import { ResetWarningModal } from "@/components/reset-warning-modal" import { SettingsDialog } from "@/components/settings-dialog" import { useDiagram } from "@/contexts/diagram-context" import { useDictionary } from "@/hooks/use-dictionary" -import { getAIConfig } from "@/lib/ai-config" +import { getSelectedAIConfig, useModelConfig } from "@/hooks/use-model-config" import { findCachedResponse } from "@/lib/cached-responses" import { isPdfFile, isTextFile } from "@/lib/pdf-utils" import { type FileData, useFileProcessor } from "@/lib/use-file-processor" @@ -146,7 +148,11 @@ export default function ChatPanel({ const [showHistory, setShowHistory] = useState(false) const [showSettingsDialog, setShowSettingsDialog] = useState(false) + const [showModelConfigDialog, setShowModelConfigDialog] = useState(false) const [, setAccessCodeRequired] = useState(false) + + // Model configuration hook + const modelConfig = useModelConfig() const [input, setInput] = useState("") const [dailyRequestLimit, setDailyRequestLimit] = useState(0) const [dailyTokenLimit, setDailyTokenLimit] = useState(0) @@ -1019,7 +1025,7 @@ Continue from EXACTLY where you stopped.`, autoRetryCountRef.current = 0 partialXmlRef.current = "" - const config = getAIConfig() + const config = getSelectedAIConfig() sendMessage( { parts }, @@ -1298,6 +1304,16 @@ Continue from EXACTLY where you stopped.`, className={`${isMobile ? "w-4 h-4" : "w-5 h-5"}`} /> + {!isMobile && modelConfig.isLoaded && ( + + setShowModelConfigDialog(true) + } + /> + )} setShowModelConfigDialog(true)} drawioUi={drawioUi} onToggleDrawioUi={onToggleDrawioUi} darkMode={darkMode} onToggleDarkMode={onToggleDarkMode} /> + + void + modelConfig: UseModelConfigReturn +} + +type ValidationStatus = "idle" | "validating" | "success" | "error" + +export function ModelConfigDialog({ + open, + onOpenChange, + modelConfig, +}: ModelConfigDialogProps) { + const dict = useDictionary() + const [selectedProviderId, setSelectedProviderId] = useState( + null, + ) + const [showApiKey, setShowApiKey] = useState(false) + const [validationStatus, setValidationStatus] = + useState("idle") + const [validationError, setValidationError] = useState("") + const [modelPopoverOpen, setModelPopoverOpen] = useState(false) + const [modelSearchValue, setModelSearchValue] = useState("") + + const { + config, + addProvider, + updateProvider, + deleteProvider, + addModel, + updateModel, + deleteModel, + } = modelConfig + + // Get selected provider + const selectedProvider = config.providers.find( + (p) => p.id === selectedProviderId, + ) + + // Get suggested models for current provider + const suggestedModels = selectedProvider + ? SUGGESTED_MODELS[selectedProvider.provider] || [] + : [] + + // Handle adding a new provider + const handleAddProvider = (providerType: ProviderName) => { + const newProvider = addProvider(providerType) + setSelectedProviderId(newProvider.id) + setValidationStatus("idle") + } + + // Handle provider field updates + const handleProviderUpdate = ( + field: keyof ProviderConfig, + value: string | boolean, + ) => { + if (!selectedProviderId) return + updateProvider(selectedProviderId, { [field]: value }) + // Reset validation when API key or base URL changes + if (field === "apiKey" || field === "baseUrl") { + setValidationStatus("idle") + updateProvider(selectedProviderId, { validated: false }) + } + } + + // Handle adding a model to current provider + const handleAddModel = (modelId: string) => { + if (!selectedProviderId) return + addModel(selectedProviderId, modelId) + } + + // Handle model field updates + const handleModelUpdate = ( + modelConfigId: string, + field: keyof ModelConfig, + value: string | boolean, + ) => { + if (!selectedProviderId) return + updateModel(selectedProviderId, modelConfigId, { [field]: value }) + } + + // Handle deleting a model + const handleDeleteModel = (modelConfigId: string) => { + if (!selectedProviderId) return + deleteModel(selectedProviderId, modelConfigId) + } + + // Handle deleting the provider + const handleDeleteProvider = () => { + if (!selectedProviderId) return + deleteProvider(selectedProviderId) + setSelectedProviderId(null) + setValidationStatus("idle") + } + + // Validate API key + const handleValidate = useCallback(async () => { + if (!selectedProvider || !selectedProvider.apiKey) return + + // Need at least one model to validate + if (selectedProvider.models.length === 0) { + setValidationError("Add at least one model to validate") + setValidationStatus("error") + return + } + + setValidationStatus("validating") + setValidationError("") + + try { + const response = await fetch("/api/validate-model", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + provider: selectedProvider.provider, + apiKey: selectedProvider.apiKey, + baseUrl: selectedProvider.baseUrl, + modelId: selectedProvider.models[0].modelId, + }), + }) + + const data = await response.json() + + if (data.valid) { + setValidationStatus("success") + updateProvider(selectedProviderId!, { validated: true }) + } else { + setValidationStatus("error") + setValidationError(data.error || "Validation failed") + } + } catch { + setValidationStatus("error") + setValidationError("Network error") + } + }, [selectedProvider, selectedProviderId, updateProvider]) + + // Get all available provider types (allow duplicates for different base URLs) + const availableProviders = Object.keys(PROVIDER_INFO) as ProviderName[] + + // Get display name for provider (use custom name if set) + const getProviderDisplayName = (provider: ProviderConfig) => { + return provider.name || PROVIDER_INFO[provider.provider].label + } + + return ( + + + + + {dict.modelConfig?.title || "AI Model Configuration"} + + + {dict.modelConfig?.description || + "Configure multiple AI providers and models"} + + + +
+ {/* Provider List (Left Sidebar) */} +
+ + + +
+ {config.providers.map((provider) => ( + + ))} +
+
+ + {/* Add Provider */} + {availableProviders.length > 0 && ( + + )} +
+ + {/* Provider Details (Right Panel) */} + + {selectedProvider ? ( +
+ {/* Provider Header */} +
+

+ { + PROVIDER_INFO[ + selectedProvider.provider + ].label + } +

+
+ + {/* Provider Name */} +
+ + + handleProviderUpdate( + "name", + e.target.value, + ) + } + placeholder={ + PROVIDER_INFO[ + selectedProvider.provider + ].label + } + /> +

+ Custom name to identify this provider + (e.g., "OpenAI Production") +

+
+ + {/* API Key */} +
+ +
+
+ + handleProviderUpdate( + "apiKey", + e.target.value, + ) + } + placeholder="Enter API key" + className="pr-10" + /> + +
+ +
+ {validationStatus === "error" && + validationError && ( +

+ {validationError} +

+ )} +
+ + {/* Base URL */} +
+ + + handleProviderUpdate( + "baseUrl", + e.target.value, + ) + } + placeholder={ + PROVIDER_INFO[ + selectedProvider.provider + ].defaultBaseUrl || + "Custom endpoint URL" + } + /> +
+ + {/* Models Section */} +
+
+ + { + setModelPopoverOpen(open) + if (!open) + setModelSearchValue("") + }} + > + + + + + + + + + + {modelSearchValue.trim() + ? "Press Enter to add custom model" + : "Type a model ID..."} + + + {/* Custom model option - appears when search doesn't match suggestions */} + {modelSearchValue.trim() && + !suggestedModels.some( + (m) => + m + .toLowerCase() + .includes( + modelSearchValue.toLowerCase(), + ), + ) && ( + + { + handleAddModel( + modelSearchValue.trim(), + ) + setModelSearchValue( + "", + ) + setModelPopoverOpen( + false, + ) + }} + className="text-xs cursor-pointer" + > + Add + " + {modelSearchValue.trim()} + " + + + )} + + {suggestedModels.map( + (modelId) => ( + { + handleAddModel( + modelId, + ) + setModelSearchValue( + "", + ) + setModelPopoverOpen( + false, + ) + }} + className="text-xs cursor-pointer" + > + { + modelId + } + + ), + )} + + + + + +
+ + {/* Model List */} +
+ {selectedProvider.models.length === + 0 ? ( +

+ No models configured. Add a + model to get started. +

+ ) : ( + selectedProvider.models.map( + (model) => ( +
+ + handleModelUpdate( + model.id, + "modelId", + e.target + .value, + ) + } + placeholder="Model ID (e.g., gpt-4o)" + className="h-8 text-xs flex-1" + /> +
+ + handleModelUpdate( + model.id, + "streaming", + checked, + ) + } + /> + + Stream + + +
+
+ ), + ) + )} +
+
+ + {/* Delete Provider */} +
+ +
+
+ ) : ( +
+

+ Select a provider or add a new one +

+

+ Configure multiple AI providers and switch + between them easily +

+
+ )} +
+
+ + {/* Footer */} +
+ API keys are stored locally in your browser +
+
+
+ ) +} diff --git a/components/model-selector.tsx b/components/model-selector.tsx new file mode 100644 index 0000000..f6d46d2 --- /dev/null +++ b/components/model-selector.tsx @@ -0,0 +1,193 @@ +"use client" + +import { Bot, Check, Settings2 } from "lucide-react" +import { useMemo, useState } from "react" +import { + ModelSelectorContent, + ModelSelectorEmpty, + ModelSelectorGroup, + ModelSelectorInput, + ModelSelectorItem, + ModelSelectorList, + ModelSelectorLogo, + ModelSelectorName, + ModelSelector as ModelSelectorRoot, + ModelSelectorSeparator, + ModelSelectorTrigger, +} from "@/components/ai-elements/model-selector" +import { ButtonWithTooltip } from "@/components/button-with-tooltip" +import type { FlattenedModel } from "@/lib/types/model-config" +import { cn } from "@/lib/utils" + +interface ModelSelectorProps { + models: FlattenedModel[] + selectedModelId: string | undefined + onSelect: (modelId: string | undefined) => void + onConfigure: () => void + disabled?: boolean +} + +// Map our provider names to models.dev logo names +const PROVIDER_LOGO_MAP: Record = { + openai: "openai", + anthropic: "anthropic", + google: "google", + azure: "azure", + bedrock: "amazon-bedrock", + openrouter: "openrouter", + deepseek: "deepseek", + siliconflow: "siliconflow", + ollama: "ollama", + gateway: "openai", // fallback +} + +// Group models by providerLabel (handles duplicate providers) +function groupModelsByProvider( + models: FlattenedModel[], +): Map { + const groups = new Map< + string, + { provider: string; models: FlattenedModel[] } + >() + for (const model of models) { + const key = model.providerLabel + const existing = groups.get(key) + if (existing) { + existing.models.push(model) + } else { + groups.set(key, { provider: model.provider, models: [model] }) + } + } + return groups +} + +export function ModelSelector({ + models, + selectedModelId, + onSelect, + onConfigure, + disabled = false, +}: ModelSelectorProps) { + const [open, setOpen] = useState(false) + const groupedModels = useMemo(() => groupModelsByProvider(models), [models]) + + // Find selected model for display + const selectedModel = useMemo( + () => models.find((m) => m.id === selectedModelId), + [models, selectedModelId], + ) + + const handleSelect = (value: string) => { + if (value === "__configure__") { + onConfigure() + } else if (value === "__server_default__") { + onSelect(undefined) + } else { + onSelect(value) + } + setOpen(false) + } + + const tooltipContent = selectedModel + ? `Model: ${selectedModel.modelId}` + : "Model: Server Default" + + return ( + + + + + + + + + + No models found. + + {/* Server Default Option */} + + + + + Server Default + + + + + {/* Configured Models by Provider */} + {Array.from(groupedModels.entries()).map( + ([ + providerLabel, + { provider, models: providerModels }, + ]) => ( + + {providerModels.map((model) => ( + handleSelect(model.id)} + className="cursor-pointer" + > + + + + {model.modelId} + + + ))} + + ), + )} + + {/* Configure Option */} + + + + + + Configure Models... + + + + + + + ) +} diff --git a/components/settings-dialog.tsx b/components/settings-dialog.tsx index b2ba8b7..9f7738e 100644 --- a/components/settings-dialog.tsx +++ b/components/settings-dialog.tsx @@ -1,6 +1,6 @@ "use client" -import { Moon, Sun } from "lucide-react" +import { Moon, Settings2, Sun } from "lucide-react" import { useEffect, useState } from "react" import { Button } from "@/components/ui/button" import { @@ -12,13 +12,6 @@ import { } from "@/components/ui/dialog" import { Input } from "@/components/ui/input" import { Label } from "@/components/ui/label" -import { - Select, - SelectContent, - SelectItem, - SelectTrigger, - SelectValue, -} from "@/components/ui/select" import { Switch } from "@/components/ui/switch" import { useDictionary } from "@/hooks/use-dictionary" @@ -26,6 +19,7 @@ interface SettingsDialogProps { open: boolean onOpenChange: (open: boolean) => void onCloseProtectionChange?: (enabled: boolean) => void + onOpenModelConfig: () => void drawioUi: "min" | "sketch" onToggleDrawioUi: () => void darkMode: boolean @@ -35,10 +29,6 @@ interface SettingsDialogProps { export const STORAGE_ACCESS_CODE_KEY = "next-ai-draw-io-access-code" export const STORAGE_CLOSE_PROTECTION_KEY = "next-ai-draw-io-close-protection" const STORAGE_ACCESS_CODE_REQUIRED_KEY = "next-ai-draw-io-access-code-required" -export const STORAGE_AI_PROVIDER_KEY = "next-ai-draw-io-ai-provider" -export const STORAGE_AI_BASE_URL_KEY = "next-ai-draw-io-ai-base-url" -export const STORAGE_AI_API_KEY_KEY = "next-ai-draw-io-ai-api-key" -export const STORAGE_AI_MODEL_KEY = "next-ai-draw-io-ai-model" function getStoredAccessCodeRequired(): boolean | null { if (typeof window === "undefined") return null @@ -51,6 +41,7 @@ export function SettingsDialog({ open, onOpenChange, onCloseProtectionChange, + onOpenModelConfig, drawioUi, onToggleDrawioUi, darkMode, @@ -64,10 +55,6 @@ export function SettingsDialog({ const [accessCodeRequired, setAccessCodeRequired] = useState( () => getStoredAccessCodeRequired() ?? false, ) - const [provider, setProvider] = useState("") - const [baseUrl, setBaseUrl] = useState("") - const [apiKey, setApiKey] = useState("") - const [modelId, setModelId] = useState("") useEffect(() => { // Only fetch if not cached in localStorage @@ -104,12 +91,6 @@ export function SettingsDialog({ // Default to true if not set setCloseProtection(storedCloseProtection !== "false") - // Load AI provider settings - setProvider(localStorage.getItem(STORAGE_AI_PROVIDER_KEY) || "") - setBaseUrl(localStorage.getItem(STORAGE_AI_BASE_URL_KEY) || "") - setApiKey(localStorage.getItem(STORAGE_AI_API_KEY_KEY) || "") - setModelId(localStorage.getItem(STORAGE_AI_MODEL_KEY) || "") - setError("") } }, [open]) @@ -197,188 +178,24 @@ export function SettingsDialog({ )}
)} -
- -

- {dict.settings.aiProviderDescription} -

-
-
- - -
- {provider && provider !== "default" && ( - <> -
- - { - setModelId(e.target.value) - localStorage.setItem( - STORAGE_AI_MODEL_KEY, - e.target.value, - ) - }} - placeholder={ - provider === "openai" - ? "e.g., gpt-4o" - : provider === "anthropic" - ? "e.g., claude-sonnet-4-5" - : provider === "google" - ? "e.g., gemini-2.0-flash-exp" - : provider === - "deepseek" - ? "e.g., deepseek-chat" - : dict.settings - .modelId - } - /> -
-
- - { - setApiKey(e.target.value) - localStorage.setItem( - STORAGE_AI_API_KEY_KEY, - e.target.value, - ) - }} - placeholder={ - dict.settings.apiKeyPlaceholder - } - autoComplete="off" - /> -

- {dict.settings.overrides}{" "} - {provider === "openai" - ? "OPENAI_API_KEY" - : provider === "anthropic" - ? "ANTHROPIC_API_KEY" - : provider === "google" - ? "GOOGLE_GENERATIVE_AI_API_KEY" - : provider === "azure" - ? "AZURE_API_KEY" - : provider === - "openrouter" - ? "OPENROUTER_API_KEY" - : provider === - "deepseek" - ? "DEEPSEEK_API_KEY" - : provider === - "siliconflow" - ? "SILICONFLOW_API_KEY" - : "server API key"} -

-
-
- - { - setBaseUrl(e.target.value) - localStorage.setItem( - STORAGE_AI_BASE_URL_KEY, - e.target.value, - ) - }} - placeholder={ - provider === "anthropic" - ? "https://api.anthropic.com/v1" - : provider === "siliconflow" - ? "https://api.siliconflow.com/v1" - : dict.settings - .customEndpoint - } - /> -
- - - )} +
+
+ +

+ {dict.settings.aiProviderDescription} +

+
diff --git a/components/ui/command.tsx b/components/ui/command.tsx new file mode 100644 index 0000000..82e269a --- /dev/null +++ b/components/ui/command.tsx @@ -0,0 +1,191 @@ +"use client" + +import * as React from "react" +import { Command as CommandPrimitive } from "cmdk" +import { SearchIcon } from "lucide-react" + +import { cn } from "@/lib/utils" +import { + Dialog, + DialogContent, + DialogDescription, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog" + +function Command({ + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function CommandDialog({ + title = "Command Palette", + description = "Search for a command to run...", + children, + className, + ...props +}: React.ComponentProps & { + title?: string + description?: string + className?: string +}) { + return ( + + + {title} + {description} + + + + {children} + + + + ) +} + +function CommandInput({ + className, + ...props +}: React.ComponentProps) { + return ( +
+ + +
+ ) +} + +function CommandList({ + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function CommandEmpty({ + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function CommandGroup({ + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function CommandSeparator({ + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function CommandItem({ + className, + ...props +}: React.ComponentProps) { + return ( + { + // Ensure hover updates selection for visual feedback + const item = e.currentTarget + item.setAttribute("data-selected", "true") + // Deselect siblings + const siblings = item.parentElement?.querySelectorAll("[cmdk-item]") + siblings?.forEach((sibling) => { + if (sibling !== item) { + sibling.setAttribute("data-selected", "false") + } + }) + }} + {...props} + /> + ) +} + +function CommandShortcut({ + className, + ...props +}: React.ComponentProps<"span">) { + return ( + + ) +} + +export { + Command, + CommandDialog, + CommandInput, + CommandList, + CommandEmpty, + CommandGroup, + CommandItem, + CommandShortcut, + CommandSeparator, +} diff --git a/components/ui/popover.tsx b/components/ui/popover.tsx new file mode 100644 index 0000000..01e468b --- /dev/null +++ b/components/ui/popover.tsx @@ -0,0 +1,48 @@ +"use client" + +import * as React from "react" +import * as PopoverPrimitive from "@radix-ui/react-popover" + +import { cn } from "@/lib/utils" + +function Popover({ + ...props +}: React.ComponentProps) { + return +} + +function PopoverTrigger({ + ...props +}: React.ComponentProps) { + return +} + +function PopoverContent({ + className, + align = "center", + sideOffset = 4, + ...props +}: React.ComponentProps) { + return ( + + + + ) +} + +function PopoverAnchor({ + ...props +}: React.ComponentProps) { + return +} + +export { Popover, PopoverTrigger, PopoverContent, PopoverAnchor } diff --git a/hooks/use-model-config.ts b/hooks/use-model-config.ts new file mode 100644 index 0000000..a0a07c2 --- /dev/null +++ b/hooks/use-model-config.ts @@ -0,0 +1,354 @@ +"use client" + +import { useCallback, useEffect, useState } from "react" +import { STORAGE_KEYS } from "@/lib/storage" +import { + createEmptyConfig, + createModelConfig, + createProviderConfig, + type FlattenedModel, + findModelById, + flattenModels, + type ModelConfig, + type MultiModelConfig, + PROVIDER_INFO, + type ProviderConfig, + type ProviderName, +} from "@/lib/types/model-config" + +// Old storage keys for migration +const OLD_KEYS = { + aiProvider: "next-ai-draw-io-ai-provider", + aiBaseUrl: "next-ai-draw-io-ai-base-url", + aiApiKey: "next-ai-draw-io-ai-api-key", + aiModel: "next-ai-draw-io-ai-model", +} + +/** + * Migrate from old single-provider format to new multi-model format + */ +function migrateOldConfig(): MultiModelConfig | null { + if (typeof window === "undefined") return null + + const oldProvider = localStorage.getItem(OLD_KEYS.aiProvider) + const oldApiKey = localStorage.getItem(OLD_KEYS.aiApiKey) + const oldModel = localStorage.getItem(OLD_KEYS.aiModel) + + // No old config to migrate + if (!oldProvider || !oldApiKey || !oldModel) return null + + const oldBaseUrl = localStorage.getItem(OLD_KEYS.aiBaseUrl) + + // Create new config from old format + const provider = createProviderConfig(oldProvider as ProviderName) + provider.apiKey = oldApiKey + if (oldBaseUrl) provider.baseUrl = oldBaseUrl + + const model = createModelConfig(oldModel) + provider.models.push(model) + + const config: MultiModelConfig = { + version: 1, + providers: [provider], + selectedModelId: model.id, + } + + // Clear old keys after migration + localStorage.removeItem(OLD_KEYS.aiProvider) + localStorage.removeItem(OLD_KEYS.aiBaseUrl) + localStorage.removeItem(OLD_KEYS.aiApiKey) + localStorage.removeItem(OLD_KEYS.aiModel) + + return config +} + +/** + * Load config from localStorage + */ +function loadConfig(): MultiModelConfig { + if (typeof window === "undefined") return createEmptyConfig() + + // First, check if new format exists + const stored = localStorage.getItem(STORAGE_KEYS.modelConfigs) + if (stored) { + try { + return JSON.parse(stored) as MultiModelConfig + } catch { + console.error("Failed to parse model config") + } + } + + // Try migration from old format + const migrated = migrateOldConfig() + if (migrated) { + // Save migrated config + localStorage.setItem( + STORAGE_KEYS.modelConfigs, + JSON.stringify(migrated), + ) + return migrated + } + + return createEmptyConfig() +} + +/** + * Save config to localStorage + */ +function saveConfig(config: MultiModelConfig): void { + if (typeof window === "undefined") return + localStorage.setItem(STORAGE_KEYS.modelConfigs, JSON.stringify(config)) +} + +export interface UseModelConfigReturn { + // State + config: MultiModelConfig + isLoaded: boolean + + // Getters + models: FlattenedModel[] + selectedModel: FlattenedModel | undefined + selectedModelId: string | undefined + + // Actions + setSelectedModelId: (modelId: string | undefined) => void + addProvider: (provider: ProviderName) => ProviderConfig + updateProvider: ( + providerId: string, + updates: Partial, + ) => void + deleteProvider: (providerId: string) => void + addModel: (providerId: string, modelId: string) => ModelConfig + updateModel: ( + providerId: string, + modelConfigId: string, + updates: Partial, + ) => void + deleteModel: (providerId: string, modelConfigId: string) => void + resetConfig: () => void +} + +export function useModelConfig(): UseModelConfigReturn { + const [config, setConfig] = useState(createEmptyConfig) + const [isLoaded, setIsLoaded] = useState(false) + + // Load config on mount + useEffect(() => { + const loaded = loadConfig() + setConfig(loaded) + setIsLoaded(true) + }, []) + + // Save config whenever it changes (after initial load) + useEffect(() => { + if (isLoaded) { + saveConfig(config) + } + }, [config, isLoaded]) + + // Derived state + const models = flattenModels(config) + const selectedModel = config.selectedModelId + ? findModelById(config, config.selectedModelId) + : undefined + + // Actions + const setSelectedModelId = useCallback((modelId: string | undefined) => { + setConfig((prev) => ({ + ...prev, + selectedModelId: modelId, + })) + }, []) + + const addProvider = useCallback( + (provider: ProviderName): ProviderConfig => { + const newProvider = createProviderConfig(provider) + setConfig((prev) => ({ + ...prev, + providers: [...prev.providers, newProvider], + })) + return newProvider + }, + [], + ) + + const updateProvider = useCallback( + (providerId: string, updates: Partial) => { + setConfig((prev) => ({ + ...prev, + providers: prev.providers.map((p) => + p.id === providerId ? { ...p, ...updates } : p, + ), + })) + }, + [], + ) + + const deleteProvider = useCallback((providerId: string) => { + setConfig((prev) => { + const provider = prev.providers.find((p) => p.id === providerId) + const modelIds = provider?.models.map((m) => m.id) || [] + + // Clear selected model if it belongs to deleted provider + const newSelectedId = + prev.selectedModelId && modelIds.includes(prev.selectedModelId) + ? undefined + : prev.selectedModelId + + return { + ...prev, + providers: prev.providers.filter((p) => p.id !== providerId), + selectedModelId: newSelectedId, + } + }) + }, []) + + const addModel = useCallback( + (providerId: string, modelId: string): ModelConfig => { + const newModel = createModelConfig(modelId) + setConfig((prev) => ({ + ...prev, + providers: prev.providers.map((p) => + p.id === providerId + ? { ...p, models: [...p.models, newModel] } + : p, + ), + })) + return newModel + }, + [], + ) + + const updateModel = useCallback( + ( + providerId: string, + modelConfigId: string, + updates: Partial, + ) => { + setConfig((prev) => ({ + ...prev, + providers: prev.providers.map((p) => + p.id === providerId + ? { + ...p, + models: p.models.map((m) => + m.id === modelConfigId + ? { ...m, ...updates } + : m, + ), + } + : p, + ), + })) + }, + [], + ) + + const deleteModel = useCallback( + (providerId: string, modelConfigId: string) => { + setConfig((prev) => ({ + ...prev, + providers: prev.providers.map((p) => + p.id === providerId + ? { + ...p, + models: p.models.filter( + (m) => m.id !== modelConfigId, + ), + } + : p, + ), + // Clear selected model if it was deleted + selectedModelId: + prev.selectedModelId === modelConfigId + ? undefined + : prev.selectedModelId, + })) + }, + [], + ) + + const resetConfig = useCallback(() => { + setConfig(createEmptyConfig()) + }, []) + + return { + config, + isLoaded, + models, + selectedModel, + selectedModelId: config.selectedModelId, + setSelectedModelId, + addProvider, + updateProvider, + deleteProvider, + addModel, + updateModel, + deleteModel, + resetConfig, + } +} + +/** + * Get the AI config for the currently selected model. + * Returns format compatible with existing getAIConfig() usage. + */ +export function getSelectedAIConfig(): { + accessCode: string + aiProvider: string + aiBaseUrl: string + aiApiKey: string + aiModel: string +} { + const empty = { + accessCode: "", + aiProvider: "", + aiBaseUrl: "", + aiApiKey: "", + aiModel: "", + } + + if (typeof window === "undefined") return empty + + // Get access code (separate from model config) + const accessCode = localStorage.getItem(STORAGE_KEYS.accessCode) || "" + + // Load multi-model config + const stored = localStorage.getItem(STORAGE_KEYS.modelConfigs) + if (!stored) { + // Fallback to old format for backward compatibility + return { + accessCode, + aiProvider: localStorage.getItem(OLD_KEYS.aiProvider) || "", + aiBaseUrl: localStorage.getItem(OLD_KEYS.aiBaseUrl) || "", + aiApiKey: localStorage.getItem(OLD_KEYS.aiApiKey) || "", + aiModel: localStorage.getItem(OLD_KEYS.aiModel) || "", + } + } + + let config: MultiModelConfig + try { + config = JSON.parse(stored) + } catch { + return { ...empty, accessCode } + } + + // No selected model = use server default + if (!config.selectedModelId) { + return { ...empty, accessCode } + } + + // Find selected model + const model = findModelById(config, config.selectedModelId) + if (!model) { + return { ...empty, accessCode } + } + + return { + accessCode, + aiProvider: model.provider, + aiBaseUrl: model.baseUrl || "", + aiApiKey: model.apiKey, + aiModel: model.modelId, + } +} diff --git a/lib/i18n/dictionaries/en.json b/lib/i18n/dictionaries/en.json index 636abf5..44912cf 100644 --- a/lib/i18n/dictionaries/en.json +++ b/lib/i18n/dictionaries/en.json @@ -180,5 +180,24 @@ "seekingSponsorship": "Call for Sponsorship", "contactMe": "Contact Me", "usageNotice": "Due to high usage, I have changed the model from Claude to minimax-m2 and added some usage limits. See About page for details." + }, + "modelConfig": { + "title": "AI Model Configuration", + "description": "Configure multiple AI providers and models", + "configure": "Configure", + "addProvider": "Add Provider", + "addModel": "Add Model", + "modelId": "Model ID", + "modelLabel": "Display Label", + "streaming": "Enable Streaming", + "deleteProvider": "Delete Provider", + "deleteModel": "Delete Model", + "noModels": "No models configured. Add a model to get started.", + "selectProvider": "Select a provider or add a new one", + "configureMultiple": "Configure multiple AI providers and switch between them easily", + "apiKeyStored": "API keys are stored locally in your browser", + "test": "Test", + "validationError": "Validation failed", + "addModelFirst": "Add at least one model to validate" } } diff --git a/lib/i18n/dictionaries/ja.json b/lib/i18n/dictionaries/ja.json index 0e9f806..68001dd 100644 --- a/lib/i18n/dictionaries/ja.json +++ b/lib/i18n/dictionaries/ja.json @@ -180,5 +180,24 @@ "seekingSponsorship": "スポンサー募集", "contactMe": "お問い合わせ", "usageNotice": "利用量の増加に伴い、コスト削減のためモデルを Claude から minimax-m2 に変更し、いくつかの利用制限を設けました。詳細は概要ページをご覧ください。" + }, + "modelConfig": { + "title": "AIモデル設定", + "description": "複数のAIプロバイダーとモデルを設定", + "configure": "設定", + "addProvider": "プロバイダーを追加", + "addModel": "モデルを追加", + "modelId": "モデルID", + "modelLabel": "表示名", + "streaming": "ストリーミングを有効", + "deleteProvider": "プロバイダーを削除", + "deleteModel": "モデルを削除", + "noModels": "モデルが設定されていません。モデルを追加してください。", + "selectProvider": "プロバイダーを選択または追加してください", + "configureMultiple": "複数のAIプロバイダーを設定して簡単に切り替え", + "apiKeyStored": "APIキーはブラウザにローカル保存されます", + "test": "テスト", + "validationError": "検証に失敗しました", + "addModelFirst": "検証するには少なくとも1つのモデルを追加してください" } } diff --git a/lib/i18n/dictionaries/zh.json b/lib/i18n/dictionaries/zh.json index 3b86c2c..43a8e7e 100644 --- a/lib/i18n/dictionaries/zh.json +++ b/lib/i18n/dictionaries/zh.json @@ -180,5 +180,24 @@ "seekingSponsorship": "寻求赞助(求大佬捞一把)", "contactMe": "联系我", "usageNotice": "由于使用量过高,我已将模型从 Claude 更换为 minimax-m2,并设置了一些用量限制。详情请查看关于页面。" + }, + "modelConfig": { + "title": "AI 模型配置", + "description": "配置多个 AI 提供商和模型", + "configure": "配置", + "addProvider": "添加提供商", + "addModel": "添加模型", + "modelId": "模型 ID", + "modelLabel": "显示名称", + "streaming": "启用流式输出", + "deleteProvider": "删除提供商", + "deleteModel": "删除模型", + "noModels": "尚未配置模型。添加模型以开始使用。", + "selectProvider": "选择一个提供商或添加新的", + "configureMultiple": "配置多个 AI 提供商并轻松切换", + "apiKeyStored": "API 密钥存储在您的浏览器本地", + "test": "测试", + "validationError": "验证失败", + "addModelFirst": "请先添加至少一个模型以进行验证" } } diff --git a/lib/storage.ts b/lib/storage.ts index a782caf..12e7ba8 100644 --- a/lib/storage.ts +++ b/lib/storage.ts @@ -24,4 +24,8 @@ export const STORAGE_KEYS = { aiBaseUrl: "next-ai-draw-io-ai-base-url", aiApiKey: "next-ai-draw-io-ai-api-key", aiModel: "next-ai-draw-io-ai-model", + + // Multi-model configuration + modelConfigs: "next-ai-draw-io-model-configs", + selectedModelId: "next-ai-draw-io-selected-model-id", } as const diff --git a/lib/types/model-config.ts b/lib/types/model-config.ts new file mode 100644 index 0000000..472fc5c --- /dev/null +++ b/lib/types/model-config.ts @@ -0,0 +1,290 @@ +// Types for multi-provider model configuration + +export type ProviderName = + | "openai" + | "anthropic" + | "google" + | "azure" + | "bedrock" + | "openrouter" + | "deepseek" + | "siliconflow" + | "ollama" + | "gateway" + +// Individual model configuration +export interface ModelConfig { + id: string // UUID for this model + modelId: string // e.g., "gpt-4o", "claude-sonnet-4-5" + streaming?: boolean // Default true +} + +// Provider configuration +export interface ProviderConfig { + id: string // UUID for this provider config + provider: ProviderName + name?: string // Custom display name (e.g., "OpenAI Production") + apiKey: string + baseUrl?: string + models: ModelConfig[] + validated?: boolean // Has API key been validated +} + +// The complete multi-model configuration +export interface MultiModelConfig { + version: 1 + providers: ProviderConfig[] + selectedModelId?: string // Currently selected model's UUID +} + +// Flattened model for dropdown display +export interface FlattenedModel { + id: string // Model config UUID + modelId: string // Actual model ID + provider: ProviderName + providerLabel: string // Provider display name + apiKey: string + baseUrl?: string + streaming?: boolean +} + +// Provider metadata +export const PROVIDER_INFO: Record< + ProviderName, + { label: string; defaultBaseUrl?: string } +> = { + openai: { label: "OpenAI" }, + anthropic: { + label: "Anthropic", + defaultBaseUrl: "https://api.anthropic.com/v1", + }, + google: { label: "Google" }, + azure: { label: "Azure OpenAI" }, + bedrock: { label: "Amazon Bedrock" }, + openrouter: { label: "OpenRouter" }, + deepseek: { label: "DeepSeek" }, + siliconflow: { + label: "SiliconFlow", + defaultBaseUrl: "https://api.siliconflow.com/v1", + }, + ollama: { label: "Ollama", defaultBaseUrl: "http://localhost:11434" }, + gateway: { label: "AI Gateway" }, +} + +// Suggested models per provider for quick add +export const SUGGESTED_MODELS: Record = { + openai: [ + // GPT-4o series + "gpt-4o", + "gpt-4o-mini", + "gpt-4o-audio-preview", + // GPT-4.1 series + "gpt-4.1", + "gpt-4.1-mini", + "gpt-4.1-nano", + // GPT-4 Turbo + "gpt-4-turbo", + "gpt-4-turbo-preview", + // Reasoning models + "o1", + "o1-mini", + "o1-preview", + "o3", + "o3-mini", + "o4-mini", + // Legacy + "gpt-4", + "gpt-3.5-turbo", + ], + anthropic: [ + // Claude 4.5 series (latest) + "claude-opus-4-5-20250514", + "claude-sonnet-4-5-20250514", + // Claude 4 series + "claude-opus-4-20250514", + "claude-sonnet-4-20250514", + // Claude 3.7 series + "claude-3-7-sonnet-20250219", + // Claude 3.5 series + "claude-3-5-sonnet-20241022", + "claude-3-5-haiku-20241022", + // Claude 3 series + "claude-3-opus-20240229", + "claude-3-sonnet-20240229", + "claude-3-haiku-20240307", + ], + google: [ + // Gemini 2.5 series + "gemini-2.5-pro", + "gemini-2.5-flash", + "gemini-2.5-flash-preview-05-20", + // Gemini 2.0 series + "gemini-2.0-flash", + "gemini-2.0-flash-exp", + "gemini-2.0-flash-lite", + // Gemini 1.5 series + "gemini-1.5-pro", + "gemini-1.5-flash", + // Legacy + "gemini-pro", + ], + azure: ["gpt-4o", "gpt-4o-mini", "gpt-4-turbo", "gpt-4", "gpt-35-turbo"], + bedrock: [ + // Anthropic Claude + "anthropic.claude-opus-4-5-20250514-v1:0", + "anthropic.claude-sonnet-4-5-20250514-v1:0", + "anthropic.claude-opus-4-20250514-v1:0", + "anthropic.claude-sonnet-4-20250514-v1:0", + "anthropic.claude-3-7-sonnet-20250219-v1:0", + "anthropic.claude-3-5-sonnet-20241022-v2:0", + "anthropic.claude-3-5-haiku-20241022-v1:0", + "anthropic.claude-3-opus-20240229-v1:0", + "anthropic.claude-3-sonnet-20240229-v1:0", + "anthropic.claude-3-haiku-20240307-v1:0", + // Amazon Nova + "amazon.nova-pro-v1:0", + "amazon.nova-lite-v1:0", + "amazon.nova-micro-v1:0", + // Meta Llama + "meta.llama3-3-70b-instruct-v1:0", + "meta.llama3-1-405b-instruct-v1:0", + "meta.llama3-1-70b-instruct-v1:0", + // Mistral + "mistral.mistral-large-2411-v1:0", + "mistral.mistral-small-2503-v1:0", + ], + openrouter: [ + // Anthropic + "anthropic/claude-sonnet-4", + "anthropic/claude-opus-4", + "anthropic/claude-3.5-sonnet", + "anthropic/claude-3.5-haiku", + // OpenAI + "openai/gpt-4o", + "openai/gpt-4o-mini", + "openai/o1", + "openai/o3-mini", + // Google + "google/gemini-2.5-pro", + "google/gemini-2.5-flash", + "google/gemini-2.0-flash-exp:free", + // Meta Llama + "meta-llama/llama-3.3-70b-instruct", + "meta-llama/llama-3.1-405b-instruct", + "meta-llama/llama-3.1-70b-instruct", + // DeepSeek + "deepseek/deepseek-chat", + "deepseek/deepseek-r1", + // Qwen + "qwen/qwen-2.5-72b-instruct", + ], + deepseek: ["deepseek-chat", "deepseek-reasoner", "deepseek-coder"], + siliconflow: [ + // DeepSeek + "deepseek-ai/DeepSeek-V3", + "deepseek-ai/DeepSeek-R1", + "deepseek-ai/DeepSeek-V2.5", + // Qwen + "Qwen/Qwen2.5-72B-Instruct", + "Qwen/Qwen2.5-32B-Instruct", + "Qwen/Qwen2.5-Coder-32B-Instruct", + "Qwen/Qwen2.5-7B-Instruct", + "Qwen/Qwen2-VL-72B-Instruct", + ], + ollama: [ + "llama3.3", + "llama3.2", + "llama3.1", + "qwen2.5", + "qwen2.5-coder", + "deepseek-r1", + "deepseek-coder-v2", + "gemma2", + "phi4", + "mistral", + "codellama", + ], + gateway: [ + "openai/gpt-4o", + "openai/gpt-4o-mini", + "anthropic/claude-sonnet-4-5", + "anthropic/claude-3-5-sonnet", + "google/gemini-2.0-flash", + ], +} + +// Helper to generate UUID +export function generateId(): string { + return `${Date.now()}-${Math.random().toString(36).slice(2, 9)}` +} + +// Create empty config +export function createEmptyConfig(): MultiModelConfig { + return { + version: 1, + providers: [], + selectedModelId: undefined, + } +} + +// Create new provider config +export function createProviderConfig(provider: ProviderName): ProviderConfig { + return { + id: generateId(), + provider, + apiKey: "", + baseUrl: PROVIDER_INFO[provider].defaultBaseUrl, + models: [], + validated: false, + } +} + +// Create new model config +export function createModelConfig(modelId: string): ModelConfig { + return { + id: generateId(), + modelId, + streaming: true, + } +} + +// Get all models as flattened list for dropdown +export function flattenModels(config: MultiModelConfig): FlattenedModel[] { + const models: FlattenedModel[] = [] + + for (const provider of config.providers) { + // Use custom name if provided, otherwise use default provider label + const providerLabel = + provider.name || PROVIDER_INFO[provider.provider].label + + for (const model of provider.models) { + models.push({ + id: model.id, + modelId: model.modelId, + provider: provider.provider, + providerLabel, + apiKey: provider.apiKey, + baseUrl: provider.baseUrl, + streaming: model.streaming, + }) + } + } + + return models +} + +// Find model by ID +export function findModelById( + config: MultiModelConfig, + modelId: string, +): FlattenedModel | undefined { + return flattenModels(config).find((m) => m.id === modelId) +} + +// Get selected model +export function getSelectedModel( + config: MultiModelConfig, +): FlattenedModel | undefined { + if (!config.selectedModelId) return undefined + return findModelById(config, config.selectedModelId) +} diff --git a/package-lock.json b/package-lock.json index 1731ee9..1fa14b6 100644 --- a/package-lock.json +++ b/package-lock.json @@ -27,8 +27,9 @@ "@opentelemetry/exporter-trace-otlp-http": "^0.208.0", "@opentelemetry/sdk-trace-node": "^2.2.0", "@radix-ui/react-collapsible": "^1.1.12", - "@radix-ui/react-dialog": "^1.1.6", + "@radix-ui/react-dialog": "^1.1.15", "@radix-ui/react-label": "^2.1.8", + "@radix-ui/react-popover": "^1.1.15", "@radix-ui/react-scroll-area": "^1.2.3", "@radix-ui/react-select": "^2.2.6", "@radix-ui/react-slot": "^1.1.2", @@ -40,6 +41,7 @@ "base-64": "^1.0.0", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", + "cmdk": "^1.1.1", "js-tiktoken": "^1.0.21", "jsdom": "^26.0.0", "jsonrepair": "^3.13.1", @@ -4655,23 +4657,23 @@ } }, "node_modules/@radix-ui/react-dialog": { - "version": "1.1.6", - "resolved": "https://registry.npmjs.org/@radix-ui/react-dialog/-/react-dialog-1.1.6.tgz", - "integrity": "sha512-/IVhJV5AceX620DUJ4uYVMymzsipdKBzo3edo+omeskCKGm9FRHM0ebIdbPnlQVJqyuHbuBltQUOG2mOTq2IYw==", + "version": "1.1.15", + "resolved": "https://registry.npmjs.org/@radix-ui/react-dialog/-/react-dialog-1.1.15.tgz", + "integrity": "sha512-TCglVRtzlffRNxRMEyR36DGBLJpeusFcgMVD9PZEzAKnUs1lKCgX5u9BmC2Yg+LL9MgZDugFFs1Vl+Jp4t/PGw==", "license": "MIT", "dependencies": { - "@radix-ui/primitive": "1.1.1", - "@radix-ui/react-compose-refs": "1.1.1", - "@radix-ui/react-context": "1.1.1", - "@radix-ui/react-dismissable-layer": "1.1.5", - "@radix-ui/react-focus-guards": "1.1.1", - "@radix-ui/react-focus-scope": "1.1.2", - "@radix-ui/react-id": "1.1.0", - "@radix-ui/react-portal": "1.1.4", - "@radix-ui/react-presence": "1.1.2", - "@radix-ui/react-primitive": "2.0.2", - "@radix-ui/react-slot": "1.1.2", - "@radix-ui/react-use-controllable-state": "1.1.0", + "@radix-ui/primitive": "1.1.3", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-dismissable-layer": "1.1.11", + "@radix-ui/react-focus-guards": "1.1.3", + "@radix-ui/react-focus-scope": "1.1.7", + "@radix-ui/react-id": "1.1.1", + "@radix-ui/react-portal": "1.1.9", + "@radix-ui/react-presence": "1.1.5", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-slot": "1.2.3", + "@radix-ui/react-use-controllable-state": "1.2.2", "aria-hidden": "^1.2.4", "react-remove-scroll": "^2.6.3" }, @@ -4690,14 +4692,214 @@ } } }, - "node_modules/@radix-ui/react-dialog/node_modules/@radix-ui/react-use-controllable-state": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/@radix-ui/react-use-controllable-state/-/react-use-controllable-state-1.1.0.tgz", - "integrity": "sha512-MtfMVJiSr2NjzS0Aa90NPTnvTSg6C/JLCV7ma0W6+OMV78vd8OyRpID+Ng9LxzsPbLeuBnWBA1Nq30AtBIDChw==", + "node_modules/@radix-ui/react-dialog/node_modules/@radix-ui/primitive": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@radix-ui/primitive/-/primitive-1.1.3.tgz", + "integrity": "sha512-JTF99U/6XIjCBo0wqkU5sK10glYe27MRRsfwoiq5zzOEZLHU3A3KCMa5X/azekYRCJ0HlwI0crAXS/5dEHTzDg==", + "license": "MIT" + }, + "node_modules/@radix-ui/react-dialog/node_modules/@radix-ui/react-compose-refs": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-compose-refs/-/react-compose-refs-1.1.2.tgz", + "integrity": "sha512-z4eqJvfiNnFMHIIvXP3CY57y2WJs5g2v3X0zm9mEJkrkNv4rDxu+sg9Jh8EkXyeqBkB7SOcboo9dMVqhyrACIg==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-dialog/node_modules/@radix-ui/react-context": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-context/-/react-context-1.1.2.tgz", + "integrity": "sha512-jCi/QKUM2r1Ju5a3J64TH2A5SpKAgh0LpknyqdQ4m6DCV0xJ2HG1xARRwNGPQfi1SLdLWZ1OJz6F4OMBBNiGJA==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-dialog/node_modules/@radix-ui/react-dismissable-layer": { + "version": "1.1.11", + "resolved": "https://registry.npmjs.org/@radix-ui/react-dismissable-layer/-/react-dismissable-layer-1.1.11.tgz", + "integrity": "sha512-Nqcp+t5cTB8BinFkZgXiMJniQH0PsUt2k51FUhbdfeKvc4ACcG2uQniY/8+h1Yv6Kza4Q7lD7PQV0z0oicE0Mg==", "license": "MIT", "dependencies": { - "@radix-ui/react-use-callback-ref": "1.1.0" + "@radix-ui/primitive": "1.1.3", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-use-callback-ref": "1.1.1", + "@radix-ui/react-use-escape-keydown": "1.1.1" }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-dialog/node_modules/@radix-ui/react-id": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-id/-/react-id-1.1.1.tgz", + "integrity": "sha512-kGkGegYIdQsOb4XjsfM97rXsiHaBwco+hFI66oO4s9LU+PLAC5oJ7khdOVFxkhsmlbpUqDAvXw11CluXP+jkHg==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-use-layout-effect": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-dialog/node_modules/@radix-ui/react-portal": { + "version": "1.1.9", + "resolved": "https://registry.npmjs.org/@radix-ui/react-portal/-/react-portal-1.1.9.tgz", + "integrity": "sha512-bpIxvq03if6UNwXZ+HTK71JLh4APvnXntDc6XOX8UVq4XQOVl7lwok0AvIl+b8zgCw3fSaVTZMpAPPagXbKmHQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-use-layout-effect": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-dialog/node_modules/@radix-ui/react-presence": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@radix-ui/react-presence/-/react-presence-1.1.5.tgz", + "integrity": "sha512-/jfEwNDdQVBCNvjkGit4h6pMOzq8bHkopq458dPt2lMjx+eBQUohZNG9A7DtO/O5ukSbxuaNGXMjHicgwy6rQQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-use-layout-effect": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-dialog/node_modules/@radix-ui/react-primitive": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.3.tgz", + "integrity": "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-slot": "1.2.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-dialog/node_modules/@radix-ui/react-slot": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz", + "integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.2" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-dialog/node_modules/@radix-ui/react-use-callback-ref": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-callback-ref/-/react-use-callback-ref-1.1.1.tgz", + "integrity": "sha512-FkBMwD+qbGQeMu1cOHnuGB6x4yzPjho8ap5WtbEJ26umhgqVXbhekKUQO+hZEL1vU92a3wHwdp0HAcqAUF5iDg==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-dialog/node_modules/@radix-ui/react-use-escape-keydown": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-escape-keydown/-/react-use-escape-keydown-1.1.1.tgz", + "integrity": "sha512-Il0+boE7w/XebUHyBjroE+DbByORGR9KKmITzbR7MyQ4akpORYP/ZmbhAr0DG7RmmBqoOnZdy2QlvajJ2QA59g==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-use-callback-ref": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-dialog/node_modules/@radix-ui/react-use-layout-effect": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-layout-effect/-/react-use-layout-effect-1.1.1.tgz", + "integrity": "sha512-RbJRS4UWQFkzHTTwVymMTUv8EqYhOp8dOOviLj2ugtTiXRaRQS7GLGxZTLL1jWhMeoSCf5zmcZkqTl9IiYfXcQ==", + "license": "MIT", "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" @@ -4751,9 +4953,9 @@ } }, "node_modules/@radix-ui/react-focus-guards": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/@radix-ui/react-focus-guards/-/react-focus-guards-1.1.1.tgz", - "integrity": "sha512-pSIwfrT1a6sIoDASCSpFwOasEwKTZWDw/iBdtnqKO7v6FeOzYJ7U53cPzYFVR3geGGXgVHaH+CdngrrAzqUGxg==", + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-focus-guards/-/react-focus-guards-1.1.3.tgz", + "integrity": "sha512-0rFg/Rj2Q62NCm62jZw0QX7a3sz6QCQU0LpZdNrJX8byRGaGVTqbrW9jAoIAHyMQqsNpeZ81YgSizOt5WXq0Pw==", "license": "MIT", "peerDependencies": { "@types/react": "*", @@ -4766,14 +4968,14 @@ } }, "node_modules/@radix-ui/react-focus-scope": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/@radix-ui/react-focus-scope/-/react-focus-scope-1.1.2.tgz", - "integrity": "sha512-zxwE80FCU7lcXUGWkdt6XpTTCKPitG1XKOwViTxHVKIJhZl9MvIl2dVHeZENCWD9+EdWv05wlaEkRXUykU27RA==", + "version": "1.1.7", + "resolved": "https://registry.npmjs.org/@radix-ui/react-focus-scope/-/react-focus-scope-1.1.7.tgz", + "integrity": "sha512-t2ODlkXBQyn7jkl6TNaw/MtVEVvIGelJDCG41Okq/KwUsJBwQ4XVZsHAVUkK4mBv3ewiAS3PGuUWuY2BoK4ZUw==", "license": "MIT", "dependencies": { - "@radix-ui/react-compose-refs": "1.1.1", - "@radix-ui/react-primitive": "2.0.2", - "@radix-ui/react-use-callback-ref": "1.1.0" + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-use-callback-ref": "1.1.1" }, "peerDependencies": { "@types/react": "*", @@ -4790,6 +4992,77 @@ } } }, + "node_modules/@radix-ui/react-focus-scope/node_modules/@radix-ui/react-compose-refs": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-compose-refs/-/react-compose-refs-1.1.2.tgz", + "integrity": "sha512-z4eqJvfiNnFMHIIvXP3CY57y2WJs5g2v3X0zm9mEJkrkNv4rDxu+sg9Jh8EkXyeqBkB7SOcboo9dMVqhyrACIg==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-focus-scope/node_modules/@radix-ui/react-primitive": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.3.tgz", + "integrity": "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-slot": "1.2.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-focus-scope/node_modules/@radix-ui/react-slot": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz", + "integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.2" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-focus-scope/node_modules/@radix-ui/react-use-callback-ref": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-callback-ref/-/react-use-callback-ref-1.1.1.tgz", + "integrity": "sha512-FkBMwD+qbGQeMu1cOHnuGB6x4yzPjho8ap5WtbEJ26umhgqVXbhekKUQO+hZEL1vU92a3wHwdp0HAcqAUF5iDg==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, "node_modules/@radix-ui/react-id": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/@radix-ui/react-id/-/react-id-1.1.0.tgz", @@ -4887,6 +5160,358 @@ } } }, + "node_modules/@radix-ui/react-popover": { + "version": "1.1.15", + "resolved": "https://registry.npmjs.org/@radix-ui/react-popover/-/react-popover-1.1.15.tgz", + "integrity": "sha512-kr0X2+6Yy/vJzLYJUPCZEc8SfQcf+1COFoAqauJm74umQhta9M7lNJHP7QQS3vkvcGLQUbWpMzwrXYwrYztHKA==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.3", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-dismissable-layer": "1.1.11", + "@radix-ui/react-focus-guards": "1.1.3", + "@radix-ui/react-focus-scope": "1.1.7", + "@radix-ui/react-id": "1.1.1", + "@radix-ui/react-popper": "1.2.8", + "@radix-ui/react-portal": "1.1.9", + "@radix-ui/react-presence": "1.1.5", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-slot": "1.2.3", + "@radix-ui/react-use-controllable-state": "1.2.2", + "aria-hidden": "^1.2.4", + "react-remove-scroll": "^2.6.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-popover/node_modules/@radix-ui/primitive": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@radix-ui/primitive/-/primitive-1.1.3.tgz", + "integrity": "sha512-JTF99U/6XIjCBo0wqkU5sK10glYe27MRRsfwoiq5zzOEZLHU3A3KCMa5X/azekYRCJ0HlwI0crAXS/5dEHTzDg==", + "license": "MIT" + }, + "node_modules/@radix-ui/react-popover/node_modules/@radix-ui/react-arrow": { + "version": "1.1.7", + "resolved": "https://registry.npmjs.org/@radix-ui/react-arrow/-/react-arrow-1.1.7.tgz", + "integrity": "sha512-F+M1tLhO+mlQaOWspE8Wstg+z6PwxwRd8oQ8IXceWz92kfAmalTRf0EjrouQeo7QssEPfCn05B4Ihs1K9WQ/7w==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-primitive": "2.1.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-popover/node_modules/@radix-ui/react-compose-refs": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-compose-refs/-/react-compose-refs-1.1.2.tgz", + "integrity": "sha512-z4eqJvfiNnFMHIIvXP3CY57y2WJs5g2v3X0zm9mEJkrkNv4rDxu+sg9Jh8EkXyeqBkB7SOcboo9dMVqhyrACIg==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-popover/node_modules/@radix-ui/react-context": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-context/-/react-context-1.1.2.tgz", + "integrity": "sha512-jCi/QKUM2r1Ju5a3J64TH2A5SpKAgh0LpknyqdQ4m6DCV0xJ2HG1xARRwNGPQfi1SLdLWZ1OJz6F4OMBBNiGJA==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-popover/node_modules/@radix-ui/react-dismissable-layer": { + "version": "1.1.11", + "resolved": "https://registry.npmjs.org/@radix-ui/react-dismissable-layer/-/react-dismissable-layer-1.1.11.tgz", + "integrity": "sha512-Nqcp+t5cTB8BinFkZgXiMJniQH0PsUt2k51FUhbdfeKvc4ACcG2uQniY/8+h1Yv6Kza4Q7lD7PQV0z0oicE0Mg==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.3", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-use-callback-ref": "1.1.1", + "@radix-ui/react-use-escape-keydown": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-popover/node_modules/@radix-ui/react-id": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-id/-/react-id-1.1.1.tgz", + "integrity": "sha512-kGkGegYIdQsOb4XjsfM97rXsiHaBwco+hFI66oO4s9LU+PLAC5oJ7khdOVFxkhsmlbpUqDAvXw11CluXP+jkHg==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-use-layout-effect": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-popover/node_modules/@radix-ui/react-popper": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/@radix-ui/react-popper/-/react-popper-1.2.8.tgz", + "integrity": "sha512-0NJQ4LFFUuWkE7Oxf0htBKS6zLkkjBH+hM1uk7Ng705ReR8m/uelduy1DBo0PyBXPKVnBA6YBlU94MBGXrSBCw==", + "license": "MIT", + "dependencies": { + "@floating-ui/react-dom": "^2.0.0", + "@radix-ui/react-arrow": "1.1.7", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-use-callback-ref": "1.1.1", + "@radix-ui/react-use-layout-effect": "1.1.1", + "@radix-ui/react-use-rect": "1.1.1", + "@radix-ui/react-use-size": "1.1.1", + "@radix-ui/rect": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-popover/node_modules/@radix-ui/react-portal": { + "version": "1.1.9", + "resolved": "https://registry.npmjs.org/@radix-ui/react-portal/-/react-portal-1.1.9.tgz", + "integrity": "sha512-bpIxvq03if6UNwXZ+HTK71JLh4APvnXntDc6XOX8UVq4XQOVl7lwok0AvIl+b8zgCw3fSaVTZMpAPPagXbKmHQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-use-layout-effect": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-popover/node_modules/@radix-ui/react-presence": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@radix-ui/react-presence/-/react-presence-1.1.5.tgz", + "integrity": "sha512-/jfEwNDdQVBCNvjkGit4h6pMOzq8bHkopq458dPt2lMjx+eBQUohZNG9A7DtO/O5ukSbxuaNGXMjHicgwy6rQQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-use-layout-effect": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-popover/node_modules/@radix-ui/react-primitive": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.3.tgz", + "integrity": "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-slot": "1.2.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-popover/node_modules/@radix-ui/react-slot": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz", + "integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.2" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-popover/node_modules/@radix-ui/react-use-callback-ref": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-callback-ref/-/react-use-callback-ref-1.1.1.tgz", + "integrity": "sha512-FkBMwD+qbGQeMu1cOHnuGB6x4yzPjho8ap5WtbEJ26umhgqVXbhekKUQO+hZEL1vU92a3wHwdp0HAcqAUF5iDg==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-popover/node_modules/@radix-ui/react-use-escape-keydown": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-escape-keydown/-/react-use-escape-keydown-1.1.1.tgz", + "integrity": "sha512-Il0+boE7w/XebUHyBjroE+DbByORGR9KKmITzbR7MyQ4akpORYP/ZmbhAr0DG7RmmBqoOnZdy2QlvajJ2QA59g==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-use-callback-ref": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-popover/node_modules/@radix-ui/react-use-layout-effect": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-layout-effect/-/react-use-layout-effect-1.1.1.tgz", + "integrity": "sha512-RbJRS4UWQFkzHTTwVymMTUv8EqYhOp8dOOviLj2ugtTiXRaRQS7GLGxZTLL1jWhMeoSCf5zmcZkqTl9IiYfXcQ==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-popover/node_modules/@radix-ui/react-use-rect": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-rect/-/react-use-rect-1.1.1.tgz", + "integrity": "sha512-QTYuDesS0VtuHNNvMh+CjlKJ4LJickCMUAqjlE3+j8w+RlRpwyX3apEQKGFzbZGdo7XNG1tXa+bQqIE7HIXT2w==", + "license": "MIT", + "dependencies": { + "@radix-ui/rect": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-popover/node_modules/@radix-ui/react-use-size": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-size/-/react-use-size-1.1.1.tgz", + "integrity": "sha512-ewrXRDTAqAXlkl6t/fkXWNAhFX9I+CkKlw6zjEwk86RSPKwZr3xpBRso655aqYafwtnbpHLj6toFzmd6xdVptQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-use-layout-effect": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-popover/node_modules/@radix-ui/rect": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/rect/-/rect-1.1.1.tgz", + "integrity": "sha512-HPwpGIzkl28mWyZqG52jiqDJ12waP11Pa1lGoiyUkIEuMLBP0oeK/C89esbXrxsky5we7dfd8U58nm0SgAWpVw==", + "license": "MIT" + }, "node_modules/@radix-ui/react-popper": { "version": "1.2.2", "resolved": "https://registry.npmjs.org/@radix-ui/react-popper/-/react-popper-1.2.2.tgz", @@ -5171,46 +5796,6 @@ } } }, - "node_modules/@radix-ui/react-select/node_modules/@radix-ui/react-focus-guards": { - "version": "1.1.3", - "resolved": "https://registry.npmjs.org/@radix-ui/react-focus-guards/-/react-focus-guards-1.1.3.tgz", - "integrity": "sha512-0rFg/Rj2Q62NCm62jZw0QX7a3sz6QCQU0LpZdNrJX8byRGaGVTqbrW9jAoIAHyMQqsNpeZ81YgSizOt5WXq0Pw==", - "license": "MIT", - "peerDependencies": { - "@types/react": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - } - } - }, - "node_modules/@radix-ui/react-select/node_modules/@radix-ui/react-focus-scope": { - "version": "1.1.7", - "resolved": "https://registry.npmjs.org/@radix-ui/react-focus-scope/-/react-focus-scope-1.1.7.tgz", - "integrity": "sha512-t2ODlkXBQyn7jkl6TNaw/MtVEVvIGelJDCG41Okq/KwUsJBwQ4XVZsHAVUkK4mBv3ewiAS3PGuUWuY2BoK4ZUw==", - "license": "MIT", - "dependencies": { - "@radix-ui/react-compose-refs": "1.1.2", - "@radix-ui/react-primitive": "2.1.3", - "@radix-ui/react-use-callback-ref": "1.1.1" - }, - "peerDependencies": { - "@types/react": "*", - "@types/react-dom": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", - "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - }, - "@types/react-dom": { - "optional": true - } - } - }, "node_modules/@radix-ui/react-select/node_modules/@radix-ui/react-id": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/@radix-ui/react-id/-/react-id-1.1.1.tgz", @@ -8853,6 +9438,22 @@ "node": ">=6" } }, + "node_modules/cmdk": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/cmdk/-/cmdk-1.1.1.tgz", + "integrity": "sha512-Vsv7kFaXm+ptHDMZ7izaRsP70GgrW9NBNGswt9OZaVBLlE0SNpDq8eu/VGXyF9r7M0azK3Wy7OlYXsuyYLFzHg==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "^1.1.1", + "@radix-ui/react-dialog": "^1.1.6", + "@radix-ui/react-id": "^1.1.0", + "@radix-ui/react-primitive": "^2.0.2" + }, + "peerDependencies": { + "react": "^18 || ^19 || ^19.0.0-rc", + "react-dom": "^18 || ^19 || ^19.0.0-rc" + } + }, "node_modules/color-convert": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", diff --git a/package.json b/package.json index 4c39e26..f1d31f6 100644 --- a/package.json +++ b/package.json @@ -42,8 +42,9 @@ "@opentelemetry/exporter-trace-otlp-http": "^0.208.0", "@opentelemetry/sdk-trace-node": "^2.2.0", "@radix-ui/react-collapsible": "^1.1.12", - "@radix-ui/react-dialog": "^1.1.6", + "@radix-ui/react-dialog": "^1.1.15", "@radix-ui/react-label": "^2.1.8", + "@radix-ui/react-popover": "^1.1.15", "@radix-ui/react-scroll-area": "^1.2.3", "@radix-ui/react-select": "^2.2.6", "@radix-ui/react-slot": "^1.1.2", @@ -55,6 +56,7 @@ "base-64": "^1.0.0", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", + "cmdk": "^1.1.1", "js-tiktoken": "^1.0.21", "jsdom": "^26.0.0", "jsonrepair": "^3.13.1",