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
This commit is contained in:
dayuan.jiang
2025-12-22 17:58:05 +09:00
parent b088a0653e
commit 1e916aa86e
16 changed files with 2791 additions and 276 deletions

View File

@@ -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<typeof Dialog>
export const ModelSelector = (props: ModelSelectorProps) => (
<Dialog {...props} />
)
export type ModelSelectorTriggerProps = ComponentProps<typeof DialogTrigger>
export const ModelSelectorTrigger = (props: ModelSelectorTriggerProps) => (
<DialogTrigger {...props} />
)
export type ModelSelectorContentProps = ComponentProps<typeof DialogContent> & {
title?: ReactNode
}
export const ModelSelectorContent = ({
className,
children,
title = "Model Selector",
...props
}: ModelSelectorContentProps) => (
<DialogContent className={cn("p-0", className)} {...props}>
<DialogTitle className="sr-only">{title}</DialogTitle>
<Command className="**:data-[slot=command-input-wrapper]:h-auto">
{children}
</Command>
</DialogContent>
)
export type ModelSelectorDialogProps = ComponentProps<typeof CommandDialog>
export const ModelSelectorDialog = (props: ModelSelectorDialogProps) => (
<CommandDialog {...props} />
)
export type ModelSelectorInputProps = ComponentProps<typeof CommandInput>
export const ModelSelectorInput = ({
className,
...props
}: ModelSelectorInputProps) => (
<CommandInput className={cn("h-auto py-3.5", className)} {...props} />
)
export type ModelSelectorListProps = ComponentProps<typeof CommandList>
export const ModelSelectorList = (props: ModelSelectorListProps) => (
<CommandList {...props} />
)
export type ModelSelectorEmptyProps = ComponentProps<typeof CommandEmpty>
export const ModelSelectorEmpty = (props: ModelSelectorEmptyProps) => (
<CommandEmpty {...props} />
)
export type ModelSelectorGroupProps = ComponentProps<typeof CommandGroup>
export const ModelSelectorGroup = (props: ModelSelectorGroupProps) => (
<CommandGroup {...props} />
)
export type ModelSelectorItemProps = ComponentProps<typeof CommandItem>
export const ModelSelectorItem = (props: ModelSelectorItemProps) => (
<CommandItem {...props} />
)
export type ModelSelectorShortcutProps = ComponentProps<typeof CommandShortcut>
export const ModelSelectorShortcut = (props: ModelSelectorShortcutProps) => (
<CommandShortcut {...props} />
)
export type ModelSelectorSeparatorProps = ComponentProps<
typeof CommandSeparator
>
export const ModelSelectorSeparator = (props: ModelSelectorSeparatorProps) => (
<CommandSeparator {...props} />
)
export type ModelSelectorLogoProps = Omit<
ComponentProps<"img">,
"src" | "alt"
> & {
provider: string
}
export const ModelSelectorLogo = ({
provider,
className,
...props
}: ModelSelectorLogoProps) => (
<img
{...props}
alt={`${provider} logo`}
className={cn("size-4 dark:invert", className)}
height={16}
src={`https://models.dev/logos/${provider}.svg`}
width={16}
/>
)
export type ModelSelectorLogoGroupProps = ComponentProps<"div">
export const ModelSelectorLogoGroup = ({
className,
...props
}: ModelSelectorLogoGroupProps) => (
<div
className={cn(
"-space-x-1 flex shrink-0 items-center [&>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) => (
<span className={cn("flex-1 truncate text-left", className)} {...props} />
)

View File

@@ -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"}`}
/>
</a>
{!isMobile && modelConfig.isLoaded && (
<ModelSelector
models={modelConfig.models}
selectedModelId={modelConfig.selectedModelId}
onSelect={modelConfig.setSelectedModelId}
onConfigure={() =>
setShowModelConfigDialog(true)
}
/>
)}
<ButtonWithTooltip
tooltipContent={dict.nav.settings}
variant="ghost"
@@ -1368,12 +1384,19 @@ Continue from EXACTLY where you stopped.`,
open={showSettingsDialog}
onOpenChange={setShowSettingsDialog}
onCloseProtectionChange={onCloseProtectionChange}
onOpenModelConfig={() => setShowModelConfigDialog(true)}
drawioUi={drawioUi}
onToggleDrawioUi={onToggleDrawioUi}
darkMode={darkMode}
onToggleDarkMode={onToggleDarkMode}
/>
<ModelConfigDialog
open={showModelConfigDialog}
onOpenChange={setShowModelConfigDialog}
modelConfig={modelConfig}
/>
<ResetWarningModal
open={showNewChatDialog}
onOpenChange={setShowNewChatDialog}

View File

@@ -0,0 +1,607 @@
"use client"
import { Check, Eye, EyeOff, Loader2, Plus, Trash2, X } from "lucide-react"
import { useCallback, useState } from "react"
import { Button } from "@/components/ui/button"
import {
Command,
CommandEmpty,
CommandGroup,
CommandInput,
CommandItem,
CommandList,
} from "@/components/ui/command"
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog"
import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label"
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@/components/ui/popover"
import { ScrollArea } from "@/components/ui/scroll-area"
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select"
import { Switch } from "@/components/ui/switch"
import { useDictionary } from "@/hooks/use-dictionary"
import type { UseModelConfigReturn } from "@/hooks/use-model-config"
import type {
ModelConfig,
ProviderConfig,
ProviderName,
} from "@/lib/types/model-config"
import { PROVIDER_INFO, SUGGESTED_MODELS } from "@/lib/types/model-config"
interface ModelConfigDialogProps {
open: boolean
onOpenChange: (open: boolean) => void
modelConfig: UseModelConfigReturn
}
type ValidationStatus = "idle" | "validating" | "success" | "error"
export function ModelConfigDialog({
open,
onOpenChange,
modelConfig,
}: ModelConfigDialogProps) {
const dict = useDictionary()
const [selectedProviderId, setSelectedProviderId] = useState<string | null>(
null,
)
const [showApiKey, setShowApiKey] = useState(false)
const [validationStatus, setValidationStatus] =
useState<ValidationStatus>("idle")
const [validationError, setValidationError] = useState<string>("")
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 (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-2xl max-h-[85vh] overflow-hidden flex flex-col">
<DialogHeader>
<DialogTitle>
{dict.modelConfig?.title || "AI Model Configuration"}
</DialogTitle>
<DialogDescription>
{dict.modelConfig?.description ||
"Configure multiple AI providers and models"}
</DialogDescription>
</DialogHeader>
<div className="flex flex-1 gap-4 min-h-0 overflow-hidden">
{/* Provider List (Left Sidebar) */}
<div className="w-48 flex-shrink-0 flex flex-col gap-2">
<Label className="text-xs text-muted-foreground uppercase tracking-wide">
Providers
</Label>
<ScrollArea className="flex-1">
<div className="flex flex-col gap-1 pr-2">
{config.providers.map((provider) => (
<button
key={provider.id}
type="button"
onClick={() => {
setSelectedProviderId(provider.id)
setValidationStatus(
provider.validated
? "success"
: "idle",
)
setShowApiKey(false)
}}
className={`flex items-center gap-2 px-3 py-2 rounded-md text-left text-sm transition-colors ${
selectedProviderId === provider.id
? "bg-accent text-accent-foreground"
: "hover:bg-accent/50"
}`}
>
<span className="flex-1 truncate">
{getProviderDisplayName(provider)}
</span>
{provider.validated && (
<Check className="h-3.5 w-3.5 text-green-500" />
)}
</button>
))}
</div>
</ScrollArea>
{/* Add Provider */}
{availableProviders.length > 0 && (
<Select
onValueChange={(v) =>
handleAddProvider(v as ProviderName)
}
>
<SelectTrigger className="h-8 text-xs">
<Plus className="h-3.5 w-3.5 mr-1" />
<SelectValue placeholder="Add Provider" />
</SelectTrigger>
<SelectContent>
{availableProviders.map((p) => (
<SelectItem key={p} value={p}>
{PROVIDER_INFO[p].label}
</SelectItem>
))}
</SelectContent>
</Select>
)}
</div>
{/* Provider Details (Right Panel) */}
<ScrollArea className="flex-1">
{selectedProvider ? (
<div className="space-y-4 pr-3">
{/* Provider Header */}
<div className="flex items-center justify-between">
<h3 className="font-medium">
{
PROVIDER_INFO[
selectedProvider.provider
].label
}
</h3>
</div>
{/* Provider Name */}
<div className="space-y-2">
<Label htmlFor="provider-name">
Display Name
</Label>
<Input
id="provider-name"
value={selectedProvider.name || ""}
onChange={(e) =>
handleProviderUpdate(
"name",
e.target.value,
)
}
placeholder={
PROVIDER_INFO[
selectedProvider.provider
].label
}
/>
<p className="text-xs text-muted-foreground">
Custom name to identify this provider
(e.g., &quot;OpenAI Production&quot;)
</p>
</div>
{/* API Key */}
<div className="space-y-2">
<Label htmlFor="api-key">API Key</Label>
<div className="flex gap-2">
<div className="relative flex-1">
<Input
id="api-key"
type={
showApiKey
? "text"
: "password"
}
value={selectedProvider.apiKey}
onChange={(e) =>
handleProviderUpdate(
"apiKey",
e.target.value,
)
}
placeholder="Enter API key"
className="pr-10"
/>
<button
type="button"
onClick={() =>
setShowApiKey(!showApiKey)
}
className="absolute right-3 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground"
>
{showApiKey ? (
<EyeOff className="h-4 w-4" />
) : (
<Eye className="h-4 w-4" />
)}
</button>
</div>
<Button
variant="outline"
size="sm"
onClick={handleValidate}
disabled={
!selectedProvider.apiKey ||
validationStatus ===
"validating"
}
>
{validationStatus ===
"validating" ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : validationStatus ===
"success" ? (
<Check className="h-4 w-4 text-green-500" />
) : (
"Test"
)}
</Button>
</div>
{validationStatus === "error" &&
validationError && (
<p className="text-xs text-destructive">
{validationError}
</p>
)}
</div>
{/* Base URL */}
<div className="space-y-2">
<Label htmlFor="base-url">
Base URL (optional)
</Label>
<Input
id="base-url"
value={selectedProvider.baseUrl || ""}
onChange={(e) =>
handleProviderUpdate(
"baseUrl",
e.target.value,
)
}
placeholder={
PROVIDER_INFO[
selectedProvider.provider
].defaultBaseUrl ||
"Custom endpoint URL"
}
/>
</div>
{/* Models Section */}
<div className="space-y-2">
<div className="flex items-center justify-between">
<Label>Models</Label>
<Popover
open={modelPopoverOpen}
onOpenChange={(open) => {
setModelPopoverOpen(open)
if (!open)
setModelSearchValue("")
}}
>
<PopoverTrigger asChild>
<Button
variant="outline"
size="sm"
className="h-7 text-xs"
>
<Plus className="h-3 w-3 mr-1" />
Add Model
</Button>
</PopoverTrigger>
<PopoverContent
className="w-72 p-0 z-[60]"
align="end"
>
<Command shouldFilter={true}>
<CommandInput
placeholder="Search or type custom model..."
value={modelSearchValue}
onValueChange={
setModelSearchValue
}
/>
<CommandList>
<CommandEmpty>
<span className="text-muted-foreground">
{modelSearchValue.trim()
? "Press Enter to add custom model"
: "Type a model ID..."}
</span>
</CommandEmpty>
{/* Custom model option - appears when search doesn't match suggestions */}
{modelSearchValue.trim() &&
!suggestedModels.some(
(m) =>
m
.toLowerCase()
.includes(
modelSearchValue.toLowerCase(),
),
) && (
<CommandGroup heading="Custom">
<CommandItem
value={`custom-${modelSearchValue.trim()}`}
onSelect={() => {
handleAddModel(
modelSearchValue.trim(),
)
setModelSearchValue(
"",
)
setModelPopoverOpen(
false,
)
}}
className="text-xs cursor-pointer"
>
Add
&quot;
{modelSearchValue.trim()}
&quot;
</CommandItem>
</CommandGroup>
)}
<CommandGroup heading="Suggested">
{suggestedModels.map(
(modelId) => (
<CommandItem
key={
modelId
}
value={
modelId
}
onSelect={() => {
handleAddModel(
modelId,
)
setModelSearchValue(
"",
)
setModelPopoverOpen(
false,
)
}}
className="text-xs cursor-pointer"
>
{
modelId
}
</CommandItem>
),
)}
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
</div>
{/* Model List */}
<div className="space-y-2">
{selectedProvider.models.length ===
0 ? (
<p className="text-sm text-muted-foreground py-4 text-center">
No models configured. Add a
model to get started.
</p>
) : (
selectedProvider.models.map(
(model) => (
<div
key={model.id}
className="flex items-center gap-2 p-2 rounded-md border bg-card"
>
<Input
value={
model.modelId
}
onChange={(e) =>
handleModelUpdate(
model.id,
"modelId",
e.target
.value,
)
}
placeholder="Model ID (e.g., gpt-4o)"
className="h-8 text-xs flex-1"
/>
<div className="flex items-center gap-2">
<Switch
checked={
model.streaming !==
false
}
onCheckedChange={(
checked,
) =>
handleModelUpdate(
model.id,
"streaming",
checked,
)
}
/>
<span className="text-xs text-muted-foreground w-12">
Stream
</span>
<Button
variant="ghost"
size="icon"
className="h-7 w-7"
onClick={() =>
handleDeleteModel(
model.id,
)
}
>
<X className="h-3.5 w-3.5" />
</Button>
</div>
</div>
),
)
)}
</div>
</div>
{/* Delete Provider */}
<div className="pt-4 border-t">
<Button
variant="destructive"
size="sm"
onClick={handleDeleteProvider}
className="w-full"
>
<Trash2 className="h-4 w-4 mr-2" />
Delete Provider
</Button>
</div>
</div>
) : (
<div className="flex flex-col items-center justify-center h-full text-center text-muted-foreground">
<p className="mb-2">
Select a provider or add a new one
</p>
<p className="text-xs">
Configure multiple AI providers and switch
between them easily
</p>
</div>
)}
</ScrollArea>
</div>
{/* Footer */}
<div className="pt-4 border-t text-xs text-muted-foreground text-center">
API keys are stored locally in your browser
</div>
</DialogContent>
</Dialog>
)
}

View File

@@ -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<string, string> = {
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<string, { provider: string; models: FlattenedModel[] }> {
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 (
<ModelSelectorRoot open={open} onOpenChange={setOpen}>
<ModelSelectorTrigger asChild>
<ButtonWithTooltip
tooltipContent={tooltipContent}
variant="ghost"
size="icon"
disabled={disabled}
className="hover:bg-accent"
>
<Bot className="h-5 w-5 text-muted-foreground" />
</ButtonWithTooltip>
</ModelSelectorTrigger>
<ModelSelectorContent title="Select Model">
<ModelSelectorInput placeholder="Search models..." />
<ModelSelectorList>
<ModelSelectorEmpty>No models found.</ModelSelectorEmpty>
{/* Server Default Option */}
<ModelSelectorGroup>
<ModelSelectorItem
value="__server_default__"
onSelect={handleSelect}
className="cursor-pointer"
>
<Check
className={cn(
"mr-2 h-4 w-4",
!selectedModelId
? "opacity-100"
: "opacity-0",
)}
/>
<ModelSelectorName className="text-muted-foreground">
Server Default
</ModelSelectorName>
</ModelSelectorItem>
</ModelSelectorGroup>
{/* Configured Models by Provider */}
{Array.from(groupedModels.entries()).map(
([
providerLabel,
{ provider, models: providerModels },
]) => (
<ModelSelectorGroup
key={providerLabel}
heading={providerLabel}
>
{providerModels.map((model) => (
<ModelSelectorItem
key={model.id}
value={model.modelId}
onSelect={() => handleSelect(model.id)}
className="cursor-pointer"
>
<Check
className={cn(
"mr-2 h-4 w-4",
selectedModelId === model.id
? "opacity-100"
: "opacity-0",
)}
/>
<ModelSelectorLogo
provider={
PROVIDER_LOGO_MAP[provider] ||
provider
}
className="mr-2"
/>
<ModelSelectorName>
{model.modelId}
</ModelSelectorName>
</ModelSelectorItem>
))}
</ModelSelectorGroup>
),
)}
{/* Configure Option */}
<ModelSelectorSeparator />
<ModelSelectorGroup>
<ModelSelectorItem
value="__configure__"
onSelect={handleSelect}
className="cursor-pointer"
>
<Settings2 className="mr-2 h-4 w-4" />
<ModelSelectorName>
Configure Models...
</ModelSelectorName>
</ModelSelectorItem>
</ModelSelectorGroup>
</ModelSelectorList>
</ModelSelectorContent>
</ModelSelectorRoot>
)
}

View File

@@ -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({
)}
</div>
)}
<div className="space-y-2">
<Label>{dict.settings.aiProvider}</Label>
<p className="text-[0.8rem] text-muted-foreground">
{dict.settings.aiProviderDescription}
</p>
<div className="space-y-3 pt-2">
<div className="space-y-2">
<Label htmlFor="ai-provider">
{dict.settings.provider}
</Label>
<Select
value={provider || "default"}
onValueChange={(value) => {
const actualValue =
value === "default" ? "" : value
setProvider(actualValue)
localStorage.setItem(
STORAGE_AI_PROVIDER_KEY,
actualValue,
)
}}
>
<SelectTrigger id="ai-provider">
<SelectValue
placeholder={
dict.settings.useServerDefault
}
/>
</SelectTrigger>
<SelectContent>
<SelectItem value="default">
{dict.settings.useServerDefault}
</SelectItem>
<SelectItem value="openai">
{dict.providers.openai}
</SelectItem>
<SelectItem value="anthropic">
{dict.providers.anthropic}
</SelectItem>
<SelectItem value="google">
{dict.providers.google}
</SelectItem>
<SelectItem value="azure">
{dict.providers.azure}
</SelectItem>
<SelectItem value="openrouter">
{dict.providers.openrouter}
</SelectItem>
<SelectItem value="deepseek">
{dict.providers.deepseek}
</SelectItem>
<SelectItem value="siliconflow">
{dict.providers.siliconflow}
</SelectItem>
</SelectContent>
</Select>
</div>
{provider && provider !== "default" && (
<>
<div className="space-y-2">
<Label htmlFor="ai-model">
{dict.settings.modelId}
</Label>
<Input
id="ai-model"
value={modelId}
onChange={(e) => {
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
}
/>
</div>
<div className="space-y-2">
<Label htmlFor="ai-api-key">
{dict.settings.apiKey}
</Label>
<Input
id="ai-api-key"
type="password"
value={apiKey}
onChange={(e) => {
setApiKey(e.target.value)
localStorage.setItem(
STORAGE_AI_API_KEY_KEY,
e.target.value,
)
}}
placeholder={
dict.settings.apiKeyPlaceholder
}
autoComplete="off"
/>
<p className="text-[0.8rem] text-muted-foreground">
{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"}
</p>
</div>
<div className="space-y-2">
<Label htmlFor="ai-base-url">
{dict.settings.baseUrl}
</Label>
<Input
id="ai-base-url"
value={baseUrl}
onChange={(e) => {
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
}
/>
</div>
<Button
variant="outline"
size="sm"
className="w-full"
onClick={() => {
localStorage.removeItem(
STORAGE_AI_PROVIDER_KEY,
)
localStorage.removeItem(
STORAGE_AI_BASE_URL_KEY,
)
localStorage.removeItem(
STORAGE_AI_API_KEY_KEY,
)
localStorage.removeItem(
STORAGE_AI_MODEL_KEY,
)
setProvider("")
setBaseUrl("")
setApiKey("")
setModelId("")
}}
>
{dict.settings.clearSettings}
</Button>
</>
)}
<div className="flex items-center justify-between">
<div className="space-y-0.5">
<Label>{dict.settings.aiProvider}</Label>
<p className="text-[0.8rem] text-muted-foreground">
{dict.settings.aiProviderDescription}
</p>
</div>
<Button
variant="outline"
size="sm"
onClick={() => {
onOpenChange(false)
onOpenModelConfig()
}}
>
<Settings2 className="h-4 w-4 mr-2" />
{dict.modelConfig?.configure || "Configure"}
</Button>
</div>
<div className="flex items-center justify-between">

191
components/ui/command.tsx Normal file
View File

@@ -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<typeof CommandPrimitive>) {
return (
<CommandPrimitive
data-slot="command"
className={cn(
"bg-popover text-popover-foreground flex h-full w-full flex-col overflow-hidden rounded-md",
className
)}
{...props}
/>
)
}
function CommandDialog({
title = "Command Palette",
description = "Search for a command to run...",
children,
className,
...props
}: React.ComponentProps<typeof Dialog> & {
title?: string
description?: string
className?: string
}) {
return (
<Dialog {...props}>
<DialogHeader className="sr-only">
<DialogTitle>{title}</DialogTitle>
<DialogDescription>{description}</DialogDescription>
</DialogHeader>
<DialogContent className={cn("overflow-hidden p-0", className)}>
<Command className="[&_[cmdk-group-heading]]:text-muted-foreground **:data-[slot=command-input-wrapper]:h-12 [&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:font-medium [&_[cmdk-group]]:px-2 [&_[cmdk-group]:not([hidden])_~[cmdk-group]]:pt-0 [&_[cmdk-input-wrapper]_svg]:h-5 [&_[cmdk-input-wrapper]_svg]:w-5 [&_[cmdk-input]]:h-12 [&_[cmdk-item]]:px-2 [&_[cmdk-item]]:py-3 [&_[cmdk-item]_svg]:h-5 [&_[cmdk-item]_svg]:w-5">
{children}
</Command>
</DialogContent>
</Dialog>
)
}
function CommandInput({
className,
...props
}: React.ComponentProps<typeof CommandPrimitive.Input>) {
return (
<div
data-slot="command-input-wrapper"
className="flex h-9 items-center gap-2 border-b px-3"
>
<SearchIcon className="size-4 shrink-0 opacity-50" />
<CommandPrimitive.Input
data-slot="command-input"
className={cn(
"placeholder:text-muted-foreground flex h-10 w-full rounded-md bg-transparent py-3 text-sm outline-hidden disabled:cursor-not-allowed disabled:opacity-50",
className
)}
{...props}
/>
</div>
)
}
function CommandList({
className,
...props
}: React.ComponentProps<typeof CommandPrimitive.List>) {
return (
<CommandPrimitive.List
data-slot="command-list"
className={cn(
"max-h-[300px] scroll-py-1 overflow-x-hidden overflow-y-auto",
className
)}
{...props}
/>
)
}
function CommandEmpty({
...props
}: React.ComponentProps<typeof CommandPrimitive.Empty>) {
return (
<CommandPrimitive.Empty
data-slot="command-empty"
className="py-6 text-center text-sm"
{...props}
/>
)
}
function CommandGroup({
className,
...props
}: React.ComponentProps<typeof CommandPrimitive.Group>) {
return (
<CommandPrimitive.Group
data-slot="command-group"
className={cn(
"text-foreground [&_[cmdk-group-heading]]:text-muted-foreground overflow-hidden p-1 [&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:py-1.5 [&_[cmdk-group-heading]]:text-xs [&_[cmdk-group-heading]]:font-medium",
className
)}
{...props}
/>
)
}
function CommandSeparator({
className,
...props
}: React.ComponentProps<typeof CommandPrimitive.Separator>) {
return (
<CommandPrimitive.Separator
data-slot="command-separator"
className={cn("bg-border -mx-1 h-px", className)}
{...props}
/>
)
}
function CommandItem({
className,
...props
}: React.ComponentProps<typeof CommandPrimitive.Item>) {
return (
<CommandPrimitive.Item
data-slot="command-item"
className={cn(
"data-[selected=true]:bg-accent data-[selected=true]:text-accent-foreground [&_svg:not([class*='text-'])]:text-muted-foreground relative flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[disabled=true]:pointer-events-none data-[disabled=true]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
onMouseEnter={(e) => {
// 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 (
<span
data-slot="command-shortcut"
className={cn(
"text-muted-foreground ml-auto text-xs tracking-widest",
className
)}
{...props}
/>
)
}
export {
Command,
CommandDialog,
CommandInput,
CommandList,
CommandEmpty,
CommandGroup,
CommandItem,
CommandShortcut,
CommandSeparator,
}

48
components/ui/popover.tsx Normal file
View File

@@ -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<typeof PopoverPrimitive.Root>) {
return <PopoverPrimitive.Root data-slot="popover" {...props} />
}
function PopoverTrigger({
...props
}: React.ComponentProps<typeof PopoverPrimitive.Trigger>) {
return <PopoverPrimitive.Trigger data-slot="popover-trigger" {...props} />
}
function PopoverContent({
className,
align = "center",
sideOffset = 4,
...props
}: React.ComponentProps<typeof PopoverPrimitive.Content>) {
return (
<PopoverPrimitive.Portal>
<PopoverPrimitive.Content
data-slot="popover-content"
align={align}
sideOffset={sideOffset}
className={cn(
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 w-72 origin-(--radix-popover-content-transform-origin) rounded-md border p-4 shadow-md outline-hidden",
className
)}
{...props}
/>
</PopoverPrimitive.Portal>
)
}
function PopoverAnchor({
...props
}: React.ComponentProps<typeof PopoverPrimitive.Anchor>) {
return <PopoverPrimitive.Anchor data-slot="popover-anchor" {...props} />
}
export { Popover, PopoverTrigger, PopoverContent, PopoverAnchor }