mirror of
https://github.com/DayuanJiang/next-ai-draw-io.git
synced 2026-01-02 22:32:27 +08:00
feat: multi-provider model configuration with UI/UX improvements (#355)
* 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 * feat: improve model config UI and move selector to chat input - Move model selector from header to chat input (left of send button) - Add per-model validation status (queued, running, valid, invalid) - Filter model selector to only show verified models - Add editable model IDs in config dialog - Add custom model input field alongside suggested models dropdown - Fix hover states on provider buttons and select triggers - Update OpenAI suggested models with GPT-5 series - Add alert-dialog component for delete confirmation * refactor: revert shadcn component changes, apply hover fix at usage site * feat: add AWS credentials support for Bedrock provider - Add AWS Access Key ID, Secret Access Key, Region fields for Bedrock - Show different credential fields based on provider type - Update validation API to handle Bedrock with AWS credentials - Add region selector with common AWS regions * fix: reset Test button after validation completes * fix: reset validation button to Test after success * fix: complete bedrock support and UI/UX improvements - Add bedrock to ALLOWED_CLIENT_PROVIDERS for client credentials - Pass AWS credentials through full chain (headers → API → provider) - Replace non-existent GPT-5 models with real ones (o1, o3-mini) - Add accessibility: aria-labels, focus-visible rings, inline errors - Add more AWS regions (Ohio, London, Paris, Mumbai, Seoul, São Paulo) - Fix setTimeout cleanup with useRef on component unmount - Fix TypeScript type consistency in getSelectedAIConfig fallback * chore: remove unused code - Remove unused setAccessCodeRequired state in chat-panel.tsx - Remove unused getSelectedModel export in model-config.ts * fix: UI/UX improvements for model configuration dialog - Add gradient header styling with icon badge - Change Configuration section icon from Key to Settings2 - Add duplicate model detection with warning banner and inline removal - Filter out already-added models from suggestions dropdown - Add type-to-confirm for deleting providers with 3+ models - Enhance delete confirmation dialog with warning icon - Improve model selector discoverability (show model name + chevron) - Add truncation for long model names with title tooltip - Remove AI provider settings from Settings dialog (now in Model Config) - Extract ValidationButton into reusable component * fix: prevent duplicate model IDs within same provider - Block adding model if ID already exists in provider - Block editing model ID to match existing model in provider * fix: improve duplicate model ID notifications - Add toast notification when trying to add duplicate model - Allow free typing when editing model ID, validate on blur - Show warning toast instead of blocking input * fix: improve duplicate model validation UX in config dialog - Add inline error display for duplicate model IDs - Show red border on input when error exists - Validate on blur with shake animation for edit errors - Prevent saving empty model names - Clear errors when user starts typing - Simplify error styling (small red text, no heavy chips)
This commit is contained in:
@@ -214,6 +214,11 @@ async function handleChatRequest(req: Request): Promise<Response> {
|
|||||||
baseUrl: req.headers.get("x-ai-base-url"),
|
baseUrl: req.headers.get("x-ai-base-url"),
|
||||||
apiKey: req.headers.get("x-ai-api-key"),
|
apiKey: req.headers.get("x-ai-api-key"),
|
||||||
modelId: req.headers.get("x-ai-model"),
|
modelId: req.headers.get("x-ai-model"),
|
||||||
|
// AWS Bedrock credentials
|
||||||
|
awsAccessKeyId: req.headers.get("x-aws-access-key-id"),
|
||||||
|
awsSecretAccessKey: req.headers.get("x-aws-secret-access-key"),
|
||||||
|
awsRegion: req.headers.get("x-aws-region"),
|
||||||
|
awsSessionToken: req.headers.get("x-aws-session-token"),
|
||||||
}
|
}
|
||||||
|
|
||||||
// Read minimal style preference from header
|
// Read minimal style preference from header
|
||||||
|
|||||||
213
app/api/validate-model/route.ts
Normal file
213
app/api/validate-model/route.ts
Normal file
@@ -0,0 +1,213 @@
|
|||||||
|
import { createAmazonBedrock } from "@ai-sdk/amazon-bedrock"
|
||||||
|
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
|
||||||
|
// AWS Bedrock specific
|
||||||
|
awsAccessKeyId?: string
|
||||||
|
awsSecretAccessKey?: string
|
||||||
|
awsRegion?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function POST(req: Request) {
|
||||||
|
try {
|
||||||
|
const body: ValidateRequest = await req.json()
|
||||||
|
const {
|
||||||
|
provider,
|
||||||
|
apiKey,
|
||||||
|
baseUrl,
|
||||||
|
modelId,
|
||||||
|
awsAccessKeyId,
|
||||||
|
awsSecretAccessKey,
|
||||||
|
awsRegion,
|
||||||
|
} = body
|
||||||
|
|
||||||
|
if (!provider || !modelId) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ valid: false, error: "Provider and model ID are required" },
|
||||||
|
{ status: 400 },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate credentials based on provider
|
||||||
|
if (provider === "bedrock") {
|
||||||
|
if (!awsAccessKeyId || !awsSecretAccessKey || !awsRegion) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{
|
||||||
|
valid: false,
|
||||||
|
error: "AWS credentials (Access Key ID, Secret Access Key, Region) are required",
|
||||||
|
},
|
||||||
|
{ status: 400 },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
} else 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 "bedrock": {
|
||||||
|
const bedrock = createAmazonBedrock({
|
||||||
|
accessKeyId: awsAccessKeyId,
|
||||||
|
secretAccessKey: awsSecretAccessKey,
|
||||||
|
region: awsRegion,
|
||||||
|
})
|
||||||
|
model = bedrock(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
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
156
components/ai-elements/model-selector.tsx
Normal file
156
components/ai-elements/model-selector.tsx
Normal file
@@ -0,0 +1,156 @@
|
|||||||
|
import { Cloud } from "lucide-react"
|
||||||
|
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) => {
|
||||||
|
// Use Lucide icon for bedrock since models.dev doesn't have a good AWS icon
|
||||||
|
if (provider === "amazon-bedrock") {
|
||||||
|
return <Cloud className={cn("size-4", className)} />
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<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} />
|
||||||
|
)
|
||||||
@@ -14,6 +14,7 @@ import { toast } from "sonner"
|
|||||||
import { ButtonWithTooltip } from "@/components/button-with-tooltip"
|
import { ButtonWithTooltip } from "@/components/button-with-tooltip"
|
||||||
import { ErrorToast } from "@/components/error-toast"
|
import { ErrorToast } from "@/components/error-toast"
|
||||||
import { HistoryDialog } from "@/components/history-dialog"
|
import { HistoryDialog } from "@/components/history-dialog"
|
||||||
|
import { ModelSelector } from "@/components/model-selector"
|
||||||
import { ResetWarningModal } from "@/components/reset-warning-modal"
|
import { ResetWarningModal } from "@/components/reset-warning-modal"
|
||||||
import { SaveDialog } from "@/components/save-dialog"
|
import { SaveDialog } from "@/components/save-dialog"
|
||||||
import { Button } from "@/components/ui/button"
|
import { Button } from "@/components/ui/button"
|
||||||
@@ -28,6 +29,7 @@ import { useDiagram } from "@/contexts/diagram-context"
|
|||||||
import { useDictionary } from "@/hooks/use-dictionary"
|
import { useDictionary } from "@/hooks/use-dictionary"
|
||||||
import { formatMessage } from "@/lib/i18n/utils"
|
import { formatMessage } from "@/lib/i18n/utils"
|
||||||
import { isPdfFile, isTextFile } from "@/lib/pdf-utils"
|
import { isPdfFile, isTextFile } from "@/lib/pdf-utils"
|
||||||
|
import type { FlattenedModel } from "@/lib/types/model-config"
|
||||||
import { FilePreviewList } from "./file-preview-list"
|
import { FilePreviewList } from "./file-preview-list"
|
||||||
|
|
||||||
const MAX_IMAGE_SIZE = 2 * 1024 * 1024 // 2MB
|
const MAX_IMAGE_SIZE = 2 * 1024 * 1024 // 2MB
|
||||||
@@ -156,6 +158,11 @@ interface ChatInputProps {
|
|||||||
error?: Error | null
|
error?: Error | null
|
||||||
minimalStyle?: boolean
|
minimalStyle?: boolean
|
||||||
onMinimalStyleChange?: (value: boolean) => void
|
onMinimalStyleChange?: (value: boolean) => void
|
||||||
|
// Model selector props
|
||||||
|
models?: FlattenedModel[]
|
||||||
|
selectedModelId?: string
|
||||||
|
onModelSelect?: (modelId: string | undefined) => void
|
||||||
|
onConfigureModels?: () => void
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ChatInput({
|
export function ChatInput({
|
||||||
@@ -173,6 +180,10 @@ export function ChatInput({
|
|||||||
error = null,
|
error = null,
|
||||||
minimalStyle = false,
|
minimalStyle = false,
|
||||||
onMinimalStyleChange = () => {},
|
onMinimalStyleChange = () => {},
|
||||||
|
models = [],
|
||||||
|
selectedModelId,
|
||||||
|
onModelSelect = () => {},
|
||||||
|
onConfigureModels = () => {},
|
||||||
}: ChatInputProps) {
|
}: ChatInputProps) {
|
||||||
const dict = useDictionary()
|
const dict = useDictionary()
|
||||||
const {
|
const {
|
||||||
@@ -465,6 +476,14 @@ export function ChatInput({
|
|||||||
disabled={isDisabled}
|
disabled={isDisabled}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<ModelSelector
|
||||||
|
models={models}
|
||||||
|
selectedModelId={selectedModelId}
|
||||||
|
onSelect={onModelSelect}
|
||||||
|
onConfigure={onConfigureModels}
|
||||||
|
disabled={isDisabled}
|
||||||
|
/>
|
||||||
|
|
||||||
<div className="w-px h-5 bg-border mx-1" />
|
<div className="w-px h-5 bg-border mx-1" />
|
||||||
|
|
||||||
<Button
|
<Button
|
||||||
|
|||||||
@@ -18,11 +18,12 @@ import { FaGithub } from "react-icons/fa"
|
|||||||
import { Toaster, toast } from "sonner"
|
import { Toaster, toast } from "sonner"
|
||||||
import { ButtonWithTooltip } from "@/components/button-with-tooltip"
|
import { ButtonWithTooltip } from "@/components/button-with-tooltip"
|
||||||
import { ChatInput } from "@/components/chat-input"
|
import { ChatInput } from "@/components/chat-input"
|
||||||
|
import { ModelConfigDialog } from "@/components/model-config-dialog"
|
||||||
import { ResetWarningModal } from "@/components/reset-warning-modal"
|
import { ResetWarningModal } from "@/components/reset-warning-modal"
|
||||||
import { SettingsDialog } from "@/components/settings-dialog"
|
import { SettingsDialog } from "@/components/settings-dialog"
|
||||||
import { useDiagram } from "@/contexts/diagram-context"
|
import { useDiagram } from "@/contexts/diagram-context"
|
||||||
import { useDictionary } from "@/hooks/use-dictionary"
|
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 { findCachedResponse } from "@/lib/cached-responses"
|
||||||
import { isPdfFile, isTextFile } from "@/lib/pdf-utils"
|
import { isPdfFile, isTextFile } from "@/lib/pdf-utils"
|
||||||
import { type FileData, useFileProcessor } from "@/lib/use-file-processor"
|
import { type FileData, useFileProcessor } from "@/lib/use-file-processor"
|
||||||
@@ -146,7 +147,10 @@ export default function ChatPanel({
|
|||||||
|
|
||||||
const [showHistory, setShowHistory] = useState(false)
|
const [showHistory, setShowHistory] = useState(false)
|
||||||
const [showSettingsDialog, setShowSettingsDialog] = useState(false)
|
const [showSettingsDialog, setShowSettingsDialog] = useState(false)
|
||||||
const [, setAccessCodeRequired] = useState(false)
|
const [showModelConfigDialog, setShowModelConfigDialog] = useState(false)
|
||||||
|
|
||||||
|
// Model configuration hook
|
||||||
|
const modelConfig = useModelConfig()
|
||||||
const [input, setInput] = useState("")
|
const [input, setInput] = useState("")
|
||||||
const [dailyRequestLimit, setDailyRequestLimit] = useState(0)
|
const [dailyRequestLimit, setDailyRequestLimit] = useState(0)
|
||||||
const [dailyTokenLimit, setDailyTokenLimit] = useState(0)
|
const [dailyTokenLimit, setDailyTokenLimit] = useState(0)
|
||||||
@@ -167,12 +171,11 @@ export default function ChatPanel({
|
|||||||
fetch("/api/config")
|
fetch("/api/config")
|
||||||
.then((res) => res.json())
|
.then((res) => res.json())
|
||||||
.then((data) => {
|
.then((data) => {
|
||||||
setAccessCodeRequired(data.accessCodeRequired)
|
|
||||||
setDailyRequestLimit(data.dailyRequestLimit || 0)
|
setDailyRequestLimit(data.dailyRequestLimit || 0)
|
||||||
setDailyTokenLimit(data.dailyTokenLimit || 0)
|
setDailyTokenLimit(data.dailyTokenLimit || 0)
|
||||||
setTpmLimit(data.tpmLimit || 0)
|
setTpmLimit(data.tpmLimit || 0)
|
||||||
})
|
})
|
||||||
.catch(() => setAccessCodeRequired(false))
|
.catch(() => {})
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
// Quota management using extracted hook
|
// Quota management using extracted hook
|
||||||
@@ -609,8 +612,7 @@ Continue from EXACTLY where you stopped.`,
|
|||||||
})
|
})
|
||||||
|
|
||||||
if (error.message.includes("Invalid or missing access code")) {
|
if (error.message.includes("Invalid or missing access code")) {
|
||||||
// Show settings button and open dialog to help user fix it
|
// Show settings dialog to help user fix it
|
||||||
setAccessCodeRequired(true)
|
|
||||||
setShowSettingsDialog(true)
|
setShowSettingsDialog(true)
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -1019,7 +1021,7 @@ Continue from EXACTLY where you stopped.`,
|
|||||||
autoRetryCountRef.current = 0
|
autoRetryCountRef.current = 0
|
||||||
partialXmlRef.current = ""
|
partialXmlRef.current = ""
|
||||||
|
|
||||||
const config = getAIConfig()
|
const config = getSelectedAIConfig()
|
||||||
|
|
||||||
sendMessage(
|
sendMessage(
|
||||||
{ parts },
|
{ parts },
|
||||||
@@ -1036,6 +1038,20 @@ Continue from EXACTLY where you stopped.`,
|
|||||||
"x-ai-api-key": config.aiApiKey,
|
"x-ai-api-key": config.aiApiKey,
|
||||||
}),
|
}),
|
||||||
...(config.aiModel && { "x-ai-model": config.aiModel }),
|
...(config.aiModel && { "x-ai-model": config.aiModel }),
|
||||||
|
// AWS Bedrock credentials
|
||||||
|
...(config.awsAccessKeyId && {
|
||||||
|
"x-aws-access-key-id": config.awsAccessKeyId,
|
||||||
|
}),
|
||||||
|
...(config.awsSecretAccessKey && {
|
||||||
|
"x-aws-secret-access-key":
|
||||||
|
config.awsSecretAccessKey,
|
||||||
|
}),
|
||||||
|
...(config.awsRegion && {
|
||||||
|
"x-aws-region": config.awsRegion,
|
||||||
|
}),
|
||||||
|
...(config.awsSessionToken && {
|
||||||
|
"x-aws-session-token": config.awsSessionToken,
|
||||||
|
}),
|
||||||
}),
|
}),
|
||||||
...(minimalStyle && {
|
...(minimalStyle && {
|
||||||
"x-minimal-style": "true",
|
"x-minimal-style": "true",
|
||||||
@@ -1361,6 +1377,10 @@ Continue from EXACTLY where you stopped.`,
|
|||||||
error={error}
|
error={error}
|
||||||
minimalStyle={minimalStyle}
|
minimalStyle={minimalStyle}
|
||||||
onMinimalStyleChange={setMinimalStyle}
|
onMinimalStyleChange={setMinimalStyle}
|
||||||
|
models={modelConfig.models}
|
||||||
|
selectedModelId={modelConfig.selectedModelId}
|
||||||
|
onModelSelect={modelConfig.setSelectedModelId}
|
||||||
|
onConfigureModels={() => setShowModelConfigDialog(true)}
|
||||||
/>
|
/>
|
||||||
</footer>
|
</footer>
|
||||||
|
|
||||||
@@ -1374,6 +1394,12 @@ Continue from EXACTLY where you stopped.`,
|
|||||||
onToggleDarkMode={onToggleDarkMode}
|
onToggleDarkMode={onToggleDarkMode}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<ModelConfigDialog
|
||||||
|
open={showModelConfigDialog}
|
||||||
|
onOpenChange={setShowModelConfigDialog}
|
||||||
|
modelConfig={modelConfig}
|
||||||
|
/>
|
||||||
|
|
||||||
<ResetWarningModal
|
<ResetWarningModal
|
||||||
open={showNewChatDialog}
|
open={showNewChatDialog}
|
||||||
onOpenChange={setShowNewChatDialog}
|
onOpenChange={setShowNewChatDialog}
|
||||||
|
|||||||
1492
components/model-config-dialog.tsx
Normal file
1492
components/model-config-dialog.tsx
Normal file
File diff suppressed because it is too large
Load Diff
216
components/model-selector.tsx
Normal file
216
components/model-selector.tsx
Normal file
@@ -0,0 +1,216 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import { Bot, Check, ChevronDown, Server, 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",
|
||||||
|
gateway: "vercel",
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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)
|
||||||
|
// Only show validated models in the selector
|
||||||
|
const validatedModels = useMemo(
|
||||||
|
() => models.filter((m) => m.validated === true),
|
||||||
|
[models],
|
||||||
|
)
|
||||||
|
const groupedModels = useMemo(
|
||||||
|
() => groupModelsByProvider(validatedModels),
|
||||||
|
[validatedModels],
|
||||||
|
)
|
||||||
|
|
||||||
|
// 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
|
||||||
|
? `${selectedModel.modelId} (click to change)`
|
||||||
|
: "Using server default model (click to change)"
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ModelSelectorRoot open={open} onOpenChange={setOpen}>
|
||||||
|
<ModelSelectorTrigger asChild>
|
||||||
|
<ButtonWithTooltip
|
||||||
|
tooltipContent={tooltipContent}
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
disabled={disabled}
|
||||||
|
className="hover:bg-accent gap-1.5 h-8 max-w-[180px] px-2"
|
||||||
|
>
|
||||||
|
<Bot className="h-4 w-4 flex-shrink-0 text-muted-foreground" />
|
||||||
|
<span className="text-xs truncate">
|
||||||
|
{selectedModel ? selectedModel.modelId : "Default"}
|
||||||
|
</span>
|
||||||
|
<ChevronDown className="h-3 w-3 flex-shrink-0 text-muted-foreground" />
|
||||||
|
</ButtonWithTooltip>
|
||||||
|
</ModelSelectorTrigger>
|
||||||
|
<ModelSelectorContent title="Select Model">
|
||||||
|
<ModelSelectorInput placeholder="Search models..." />
|
||||||
|
<ModelSelectorList>
|
||||||
|
<ModelSelectorEmpty>
|
||||||
|
{validatedModels.length === 0 && models.length > 0
|
||||||
|
? "No verified models. Test your models first."
|
||||||
|
: "No models found."}
|
||||||
|
</ModelSelectorEmpty>
|
||||||
|
|
||||||
|
{/* Server Default Option */}
|
||||||
|
<ModelSelectorGroup heading="Default">
|
||||||
|
<ModelSelectorItem
|
||||||
|
value="__server_default__"
|
||||||
|
onSelect={handleSelect}
|
||||||
|
className={cn(
|
||||||
|
"cursor-pointer",
|
||||||
|
!selectedModelId && "bg-accent",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<Check
|
||||||
|
className={cn(
|
||||||
|
"mr-2 h-4 w-4",
|
||||||
|
!selectedModelId
|
||||||
|
? "opacity-100"
|
||||||
|
: "opacity-0",
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<Server className="mr-2 h-4 w-4 text-muted-foreground" />
|
||||||
|
<ModelSelectorName>
|
||||||
|
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>
|
||||||
|
{/* Info text */}
|
||||||
|
<div className="px-3 py-2 text-xs text-muted-foreground border-t">
|
||||||
|
Only verified models are shown
|
||||||
|
</div>
|
||||||
|
</ModelSelectorList>
|
||||||
|
</ModelSelectorContent>
|
||||||
|
</ModelSelectorRoot>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -12,13 +12,6 @@ import {
|
|||||||
} from "@/components/ui/dialog"
|
} from "@/components/ui/dialog"
|
||||||
import { Input } from "@/components/ui/input"
|
import { Input } from "@/components/ui/input"
|
||||||
import { Label } from "@/components/ui/label"
|
import { Label } from "@/components/ui/label"
|
||||||
import {
|
|
||||||
Select,
|
|
||||||
SelectContent,
|
|
||||||
SelectItem,
|
|
||||||
SelectTrigger,
|
|
||||||
SelectValue,
|
|
||||||
} from "@/components/ui/select"
|
|
||||||
import { Switch } from "@/components/ui/switch"
|
import { Switch } from "@/components/ui/switch"
|
||||||
import { useDictionary } from "@/hooks/use-dictionary"
|
import { useDictionary } from "@/hooks/use-dictionary"
|
||||||
|
|
||||||
@@ -35,10 +28,6 @@ interface SettingsDialogProps {
|
|||||||
export const STORAGE_ACCESS_CODE_KEY = "next-ai-draw-io-access-code"
|
export const STORAGE_ACCESS_CODE_KEY = "next-ai-draw-io-access-code"
|
||||||
export const STORAGE_CLOSE_PROTECTION_KEY = "next-ai-draw-io-close-protection"
|
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"
|
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 {
|
function getStoredAccessCodeRequired(): boolean | null {
|
||||||
if (typeof window === "undefined") return null
|
if (typeof window === "undefined") return null
|
||||||
@@ -64,10 +53,6 @@ export function SettingsDialog({
|
|||||||
const [accessCodeRequired, setAccessCodeRequired] = useState(
|
const [accessCodeRequired, setAccessCodeRequired] = useState(
|
||||||
() => getStoredAccessCodeRequired() ?? false,
|
() => getStoredAccessCodeRequired() ?? false,
|
||||||
)
|
)
|
||||||
const [provider, setProvider] = useState("")
|
|
||||||
const [baseUrl, setBaseUrl] = useState("")
|
|
||||||
const [apiKey, setApiKey] = useState("")
|
|
||||||
const [modelId, setModelId] = useState("")
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
// Only fetch if not cached in localStorage
|
// Only fetch if not cached in localStorage
|
||||||
@@ -104,12 +89,6 @@ export function SettingsDialog({
|
|||||||
// Default to true if not set
|
// Default to true if not set
|
||||||
setCloseProtection(storedCloseProtection !== "false")
|
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("")
|
setError("")
|
||||||
}
|
}
|
||||||
}, [open])
|
}, [open])
|
||||||
@@ -197,190 +176,6 @@ export function SettingsDialog({
|
|||||||
)}
|
)}
|
||||||
</div>
|
</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>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<div className="space-y-0.5">
|
<div className="space-y-0.5">
|
||||||
<Label htmlFor="theme-toggle">
|
<Label htmlFor="theme-toggle">
|
||||||
|
|||||||
157
components/ui/alert-dialog.tsx
Normal file
157
components/ui/alert-dialog.tsx
Normal file
@@ -0,0 +1,157 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import * as React from "react"
|
||||||
|
import * as AlertDialogPrimitive from "@radix-ui/react-alert-dialog"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
import { buttonVariants } from "@/components/ui/button"
|
||||||
|
|
||||||
|
function AlertDialog({
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof AlertDialogPrimitive.Root>) {
|
||||||
|
return <AlertDialogPrimitive.Root data-slot="alert-dialog" {...props} />
|
||||||
|
}
|
||||||
|
|
||||||
|
function AlertDialogTrigger({
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof AlertDialogPrimitive.Trigger>) {
|
||||||
|
return (
|
||||||
|
<AlertDialogPrimitive.Trigger data-slot="alert-dialog-trigger" {...props} />
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function AlertDialogPortal({
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof AlertDialogPrimitive.Portal>) {
|
||||||
|
return (
|
||||||
|
<AlertDialogPrimitive.Portal data-slot="alert-dialog-portal" {...props} />
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function AlertDialogOverlay({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof AlertDialogPrimitive.Overlay>) {
|
||||||
|
return (
|
||||||
|
<AlertDialogPrimitive.Overlay
|
||||||
|
data-slot="alert-dialog-overlay"
|
||||||
|
className={cn(
|
||||||
|
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/50",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function AlertDialogContent({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof AlertDialogPrimitive.Content>) {
|
||||||
|
return (
|
||||||
|
<AlertDialogPortal>
|
||||||
|
<AlertDialogOverlay />
|
||||||
|
<AlertDialogPrimitive.Content
|
||||||
|
data-slot="alert-dialog-content"
|
||||||
|
className={cn(
|
||||||
|
"bg-background 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 fixed top-[50%] left-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border p-6 shadow-lg duration-200 sm:max-w-lg",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
</AlertDialogPortal>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function AlertDialogHeader({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<"div">) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="alert-dialog-header"
|
||||||
|
className={cn("flex flex-col gap-2 text-center sm:text-left", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function AlertDialogFooter({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<"div">) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="alert-dialog-footer"
|
||||||
|
className={cn(
|
||||||
|
"flex flex-col-reverse gap-2 sm:flex-row sm:justify-end",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function AlertDialogTitle({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof AlertDialogPrimitive.Title>) {
|
||||||
|
return (
|
||||||
|
<AlertDialogPrimitive.Title
|
||||||
|
data-slot="alert-dialog-title"
|
||||||
|
className={cn("text-lg font-semibold", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function AlertDialogDescription({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof AlertDialogPrimitive.Description>) {
|
||||||
|
return (
|
||||||
|
<AlertDialogPrimitive.Description
|
||||||
|
data-slot="alert-dialog-description"
|
||||||
|
className={cn("text-muted-foreground text-sm", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function AlertDialogAction({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof AlertDialogPrimitive.Action>) {
|
||||||
|
return (
|
||||||
|
<AlertDialogPrimitive.Action
|
||||||
|
className={cn(buttonVariants(), className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function AlertDialogCancel({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof AlertDialogPrimitive.Cancel>) {
|
||||||
|
return (
|
||||||
|
<AlertDialogPrimitive.Cancel
|
||||||
|
className={cn(buttonVariants({ variant: "outline" }), className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export {
|
||||||
|
AlertDialog,
|
||||||
|
AlertDialogPortal,
|
||||||
|
AlertDialogOverlay,
|
||||||
|
AlertDialogTrigger,
|
||||||
|
AlertDialogContent,
|
||||||
|
AlertDialogHeader,
|
||||||
|
AlertDialogFooter,
|
||||||
|
AlertDialogTitle,
|
||||||
|
AlertDialogDescription,
|
||||||
|
AlertDialogAction,
|
||||||
|
AlertDialogCancel,
|
||||||
|
}
|
||||||
191
components/ui/command.tsx
Normal file
191
components/ui/command.tsx
Normal 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
48
components/ui/popover.tsx
Normal 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 }
|
||||||
373
hooks/use-model-config.ts
Normal file
373
hooks/use-model-config.ts
Normal file
@@ -0,0 +1,373 @@
|
|||||||
|
"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<ProviderConfig>,
|
||||||
|
) => void
|
||||||
|
deleteProvider: (providerId: string) => void
|
||||||
|
addModel: (providerId: string, modelId: string) => ModelConfig
|
||||||
|
updateModel: (
|
||||||
|
providerId: string,
|
||||||
|
modelConfigId: string,
|
||||||
|
updates: Partial<ModelConfig>,
|
||||||
|
) => void
|
||||||
|
deleteModel: (providerId: string, modelConfigId: string) => void
|
||||||
|
resetConfig: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useModelConfig(): UseModelConfigReturn {
|
||||||
|
const [config, setConfig] = useState<MultiModelConfig>(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<ProviderConfig>) => {
|
||||||
|
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<ModelConfig>,
|
||||||
|
) => {
|
||||||
|
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
|
||||||
|
// AWS Bedrock credentials
|
||||||
|
awsAccessKeyId: string
|
||||||
|
awsSecretAccessKey: string
|
||||||
|
awsRegion: string
|
||||||
|
awsSessionToken: string
|
||||||
|
} {
|
||||||
|
const empty = {
|
||||||
|
accessCode: "",
|
||||||
|
aiProvider: "",
|
||||||
|
aiBaseUrl: "",
|
||||||
|
aiApiKey: "",
|
||||||
|
aiModel: "",
|
||||||
|
awsAccessKeyId: "",
|
||||||
|
awsSecretAccessKey: "",
|
||||||
|
awsRegion: "",
|
||||||
|
awsSessionToken: "",
|
||||||
|
}
|
||||||
|
|
||||||
|
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) || "",
|
||||||
|
// Old format didn't support AWS
|
||||||
|
awsAccessKeyId: "",
|
||||||
|
awsSecretAccessKey: "",
|
||||||
|
awsRegion: "",
|
||||||
|
awsSessionToken: "",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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,
|
||||||
|
// AWS Bedrock credentials
|
||||||
|
awsAccessKeyId: model.awsAccessKeyId || "",
|
||||||
|
awsSecretAccessKey: model.awsSecretAccessKey || "",
|
||||||
|
awsRegion: model.awsRegion || "",
|
||||||
|
awsSessionToken: model.awsSessionToken || "",
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -33,6 +33,11 @@ export interface ClientOverrides {
|
|||||||
baseUrl?: string | null
|
baseUrl?: string | null
|
||||||
apiKey?: string | null
|
apiKey?: string | null
|
||||||
modelId?: string | null
|
modelId?: string | null
|
||||||
|
// AWS Bedrock credentials
|
||||||
|
awsAccessKeyId?: string | null
|
||||||
|
awsSecretAccessKey?: string | null
|
||||||
|
awsRegion?: string | null
|
||||||
|
awsSessionToken?: string | null
|
||||||
}
|
}
|
||||||
|
|
||||||
// Providers that can be used with client-provided API keys
|
// Providers that can be used with client-provided API keys
|
||||||
@@ -41,6 +46,7 @@ const ALLOWED_CLIENT_PROVIDERS: ProviderName[] = [
|
|||||||
"anthropic",
|
"anthropic",
|
||||||
"google",
|
"google",
|
||||||
"azure",
|
"azure",
|
||||||
|
"bedrock",
|
||||||
"openrouter",
|
"openrouter",
|
||||||
"deepseek",
|
"deepseek",
|
||||||
"siliconflow",
|
"siliconflow",
|
||||||
@@ -537,10 +543,23 @@ export function getAIModel(overrides?: ClientOverrides): ModelConfig {
|
|||||||
|
|
||||||
switch (provider) {
|
switch (provider) {
|
||||||
case "bedrock": {
|
case "bedrock": {
|
||||||
// Use credential provider chain for IAM role support (Lambda, EC2, etc.)
|
// Use client-provided credentials if available, otherwise fall back to IAM/env vars
|
||||||
// Falls back to env vars (AWS_ACCESS_KEY_ID, AWS_SECRET_ACCESS_KEY) for local dev
|
const hasClientCredentials =
|
||||||
const bedrockProvider = createAmazonBedrock({
|
overrides?.awsAccessKeyId && overrides?.awsSecretAccessKey
|
||||||
region: process.env.AWS_REGION || "us-west-2",
|
const bedrockRegion =
|
||||||
|
overrides?.awsRegion || process.env.AWS_REGION || "us-west-2"
|
||||||
|
|
||||||
|
const bedrockProvider = hasClientCredentials
|
||||||
|
? createAmazonBedrock({
|
||||||
|
region: bedrockRegion,
|
||||||
|
accessKeyId: overrides.awsAccessKeyId!,
|
||||||
|
secretAccessKey: overrides.awsSecretAccessKey!,
|
||||||
|
...(overrides?.awsSessionToken && {
|
||||||
|
sessionToken: overrides.awsSessionToken,
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
: createAmazonBedrock({
|
||||||
|
region: bedrockRegion,
|
||||||
credentialProvider: fromNodeProviderChain(),
|
credentialProvider: fromNodeProviderChain(),
|
||||||
})
|
})
|
||||||
model = bedrockProvider(modelId)
|
model = bedrockProvider(modelId)
|
||||||
|
|||||||
@@ -180,5 +180,24 @@
|
|||||||
"seekingSponsorship": "Call for Sponsorship",
|
"seekingSponsorship": "Call for Sponsorship",
|
||||||
"contactMe": "Contact Me",
|
"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."
|
"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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -180,5 +180,24 @@
|
|||||||
"seekingSponsorship": "スポンサー募集",
|
"seekingSponsorship": "スポンサー募集",
|
||||||
"contactMe": "お問い合わせ",
|
"contactMe": "お問い合わせ",
|
||||||
"usageNotice": "利用量の増加に伴い、コスト削減のためモデルを Claude から minimax-m2 に変更し、いくつかの利用制限を設けました。詳細は概要ページをご覧ください。"
|
"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つのモデルを追加してください"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -180,5 +180,24 @@
|
|||||||
"seekingSponsorship": "寻求赞助(求大佬捞一把)",
|
"seekingSponsorship": "寻求赞助(求大佬捞一把)",
|
||||||
"contactMe": "联系我",
|
"contactMe": "联系我",
|
||||||
"usageNotice": "由于使用量过高,我已将模型从 Claude 更换为 minimax-m2,并设置了一些用量限制。详情请查看关于页面。"
|
"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": "请先添加至少一个模型以进行验证"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -24,4 +24,8 @@ export const STORAGE_KEYS = {
|
|||||||
aiBaseUrl: "next-ai-draw-io-ai-base-url",
|
aiBaseUrl: "next-ai-draw-io-ai-base-url",
|
||||||
aiApiKey: "next-ai-draw-io-ai-api-key",
|
aiApiKey: "next-ai-draw-io-ai-api-key",
|
||||||
aiModel: "next-ai-draw-io-ai-model",
|
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
|
} as const
|
||||||
|
|||||||
277
lib/types/model-config.ts
Normal file
277
lib/types/model-config.ts
Normal file
@@ -0,0 +1,277 @@
|
|||||||
|
// Types for multi-provider model configuration
|
||||||
|
|
||||||
|
export type ProviderName =
|
||||||
|
| "openai"
|
||||||
|
| "anthropic"
|
||||||
|
| "google"
|
||||||
|
| "azure"
|
||||||
|
| "bedrock"
|
||||||
|
| "openrouter"
|
||||||
|
| "deepseek"
|
||||||
|
| "siliconflow"
|
||||||
|
| "gateway"
|
||||||
|
|
||||||
|
// Individual model configuration
|
||||||
|
export interface ModelConfig {
|
||||||
|
id: string // UUID for this model
|
||||||
|
modelId: string // e.g., "gpt-4o", "claude-sonnet-4-5"
|
||||||
|
validated?: boolean // Has this model been validated
|
||||||
|
validationError?: string // Error message if validation failed
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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
|
||||||
|
// AWS Bedrock specific fields
|
||||||
|
awsAccessKeyId?: string
|
||||||
|
awsSecretAccessKey?: string
|
||||||
|
awsRegion?: string
|
||||||
|
awsSessionToken?: string // Optional, for temporary credentials
|
||||||
|
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
|
||||||
|
// AWS Bedrock specific fields
|
||||||
|
awsAccessKeyId?: string
|
||||||
|
awsSecretAccessKey?: string
|
||||||
|
awsRegion?: string
|
||||||
|
awsSessionToken?: string
|
||||||
|
validated?: boolean // Has this model been validated
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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",
|
||||||
|
},
|
||||||
|
gateway: { label: "AI Gateway" },
|
||||||
|
}
|
||||||
|
|
||||||
|
// Suggested models per provider for quick add
|
||||||
|
export const SUGGESTED_MODELS: Record<ProviderName, string[]> = {
|
||||||
|
openai: [
|
||||||
|
// GPT-4o series (latest)
|
||||||
|
"gpt-4o",
|
||||||
|
"gpt-4o-mini",
|
||||||
|
"gpt-4o-2024-11-20",
|
||||||
|
// GPT-4 Turbo
|
||||||
|
"gpt-4-turbo",
|
||||||
|
"gpt-4-turbo-preview",
|
||||||
|
// o1/o3 reasoning models
|
||||||
|
"o1",
|
||||||
|
"o1-mini",
|
||||||
|
"o1-preview",
|
||||||
|
"o3-mini",
|
||||||
|
// GPT-4
|
||||||
|
"gpt-4",
|
||||||
|
// GPT-3.5
|
||||||
|
"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",
|
||||||
|
],
|
||||||
|
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,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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,
|
||||||
|
// AWS Bedrock fields
|
||||||
|
awsAccessKeyId: provider.awsAccessKeyId,
|
||||||
|
awsSecretAccessKey: provider.awsSecretAccessKey,
|
||||||
|
awsRegion: provider.awsRegion,
|
||||||
|
awsSessionToken: provider.awsSessionToken,
|
||||||
|
validated: model.validated,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return models
|
||||||
|
}
|
||||||
|
|
||||||
|
// Find model by ID
|
||||||
|
export function findModelById(
|
||||||
|
config: MultiModelConfig,
|
||||||
|
modelId: string,
|
||||||
|
): FlattenedModel | undefined {
|
||||||
|
return flattenModels(config).find((m) => m.id === modelId)
|
||||||
|
}
|
||||||
913
package-lock.json
generated
913
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -41,12 +41,14 @@
|
|||||||
"@openrouter/ai-sdk-provider": "^1.2.3",
|
"@openrouter/ai-sdk-provider": "^1.2.3",
|
||||||
"@opentelemetry/exporter-trace-otlp-http": "^0.208.0",
|
"@opentelemetry/exporter-trace-otlp-http": "^0.208.0",
|
||||||
"@opentelemetry/sdk-trace-node": "^2.2.0",
|
"@opentelemetry/sdk-trace-node": "^2.2.0",
|
||||||
|
"@radix-ui/react-alert-dialog": "^1.1.15",
|
||||||
"@radix-ui/react-collapsible": "^1.1.12",
|
"@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-label": "^2.1.8",
|
||||||
|
"@radix-ui/react-popover": "^1.1.15",
|
||||||
"@radix-ui/react-scroll-area": "^1.2.3",
|
"@radix-ui/react-scroll-area": "^1.2.3",
|
||||||
"@radix-ui/react-select": "^2.2.6",
|
"@radix-ui/react-select": "^2.2.6",
|
||||||
"@radix-ui/react-slot": "^1.1.2",
|
"@radix-ui/react-slot": "^1.2.4",
|
||||||
"@radix-ui/react-switch": "^1.2.6",
|
"@radix-ui/react-switch": "^1.2.6",
|
||||||
"@radix-ui/react-tooltip": "^1.1.8",
|
"@radix-ui/react-tooltip": "^1.1.8",
|
||||||
"@radix-ui/react-use-controllable-state": "^1.2.2",
|
"@radix-ui/react-use-controllable-state": "^1.2.2",
|
||||||
@@ -55,6 +57,7 @@
|
|||||||
"base-64": "^1.0.0",
|
"base-64": "^1.0.0",
|
||||||
"class-variance-authority": "^0.7.1",
|
"class-variance-authority": "^0.7.1",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
|
"cmdk": "^1.1.1",
|
||||||
"js-tiktoken": "^1.0.21",
|
"js-tiktoken": "^1.0.21",
|
||||||
"jsdom": "^26.0.0",
|
"jsdom": "^26.0.0",
|
||||||
"jsonrepair": "^3.13.1",
|
"jsonrepair": "^3.13.1",
|
||||||
|
|||||||
Reference in New Issue
Block a user