Compare commits
2 Commits
feat/multi
...
fix/electr
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
534c23969c | ||
|
|
3fa4721358 |
60
.github/workflows/electron-release.yml
vendored
@@ -40,7 +40,63 @@ jobs:
|
||||
- name: Install dependencies
|
||||
run: npm ci
|
||||
|
||||
- name: Build and publish Electron app
|
||||
run: npm run dist:${{ matrix.platform }}
|
||||
- name: Build Electron app
|
||||
run: npm run electron:build && npm run electron:prepare && npx electron-builder --${{ matrix.platform }} --publish never
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Upload macOS artifacts
|
||||
if: matrix.platform == 'mac'
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: mac-build
|
||||
path: |
|
||||
release/*.dmg
|
||||
release/*.zip
|
||||
if-no-files-found: error
|
||||
|
||||
- name: Upload Windows artifacts
|
||||
if: matrix.platform == 'win'
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: win-build
|
||||
path: |
|
||||
release/*.exe
|
||||
if-no-files-found: error
|
||||
|
||||
- name: Upload Linux artifacts
|
||||
if: matrix.platform == 'linux'
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: linux-build
|
||||
path: |
|
||||
release/*.AppImage
|
||||
release/*.deb
|
||||
if-no-files-found: error
|
||||
|
||||
release:
|
||||
needs: build
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: write
|
||||
if: startsWith(github.ref, 'refs/tags/')
|
||||
|
||||
steps:
|
||||
- name: Download all artifacts
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
path: artifacts
|
||||
|
||||
- name: Create Release
|
||||
uses: softprops/action-gh-release@v1
|
||||
with:
|
||||
files: |
|
||||
artifacts/**/*.dmg
|
||||
artifacts/**/*.zip
|
||||
artifacts/**/*.exe
|
||||
artifacts/**/*.AppImage
|
||||
artifacts/**/*.deb
|
||||
draft: true
|
||||
generate_release_notes: true
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
@@ -214,11 +214,6 @@ async function handleChatRequest(req: Request): Promise<Response> {
|
||||
baseUrl: req.headers.get("x-ai-base-url"),
|
||||
apiKey: req.headers.get("x-ai-api-key"),
|
||||
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
|
||||
|
||||
@@ -1,213 +0,0 @@
|
||||
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
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -1,156 +0,0 @@
|
||||
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,7 +14,6 @@ import { toast } from "sonner"
|
||||
import { ButtonWithTooltip } from "@/components/button-with-tooltip"
|
||||
import { ErrorToast } from "@/components/error-toast"
|
||||
import { HistoryDialog } from "@/components/history-dialog"
|
||||
import { ModelSelector } from "@/components/model-selector"
|
||||
import { ResetWarningModal } from "@/components/reset-warning-modal"
|
||||
import { SaveDialog } from "@/components/save-dialog"
|
||||
import { Button } from "@/components/ui/button"
|
||||
@@ -29,7 +28,6 @@ import { useDiagram } from "@/contexts/diagram-context"
|
||||
import { useDictionary } from "@/hooks/use-dictionary"
|
||||
import { formatMessage } from "@/lib/i18n/utils"
|
||||
import { isPdfFile, isTextFile } from "@/lib/pdf-utils"
|
||||
import type { FlattenedModel } from "@/lib/types/model-config"
|
||||
import { FilePreviewList } from "./file-preview-list"
|
||||
|
||||
const MAX_IMAGE_SIZE = 2 * 1024 * 1024 // 2MB
|
||||
@@ -158,11 +156,6 @@ interface ChatInputProps {
|
||||
error?: Error | null
|
||||
minimalStyle?: boolean
|
||||
onMinimalStyleChange?: (value: boolean) => void
|
||||
// Model selector props
|
||||
models?: FlattenedModel[]
|
||||
selectedModelId?: string
|
||||
onModelSelect?: (modelId: string | undefined) => void
|
||||
onConfigureModels?: () => void
|
||||
}
|
||||
|
||||
export function ChatInput({
|
||||
@@ -180,10 +173,6 @@ export function ChatInput({
|
||||
error = null,
|
||||
minimalStyle = false,
|
||||
onMinimalStyleChange = () => {},
|
||||
models = [],
|
||||
selectedModelId,
|
||||
onModelSelect = () => {},
|
||||
onConfigureModels = () => {},
|
||||
}: ChatInputProps) {
|
||||
const dict = useDictionary()
|
||||
const {
|
||||
@@ -476,14 +465,6 @@ export function ChatInput({
|
||||
disabled={isDisabled}
|
||||
/>
|
||||
|
||||
<ModelSelector
|
||||
models={models}
|
||||
selectedModelId={selectedModelId}
|
||||
onSelect={onModelSelect}
|
||||
onConfigure={onConfigureModels}
|
||||
disabled={isDisabled}
|
||||
/>
|
||||
|
||||
<div className="w-px h-5 bg-border mx-1" />
|
||||
|
||||
<Button
|
||||
|
||||
@@ -18,12 +18,11 @@ 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 { 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 { getSelectedAIConfig, useModelConfig } from "@/hooks/use-model-config"
|
||||
import { getAIConfig } from "@/lib/ai-config"
|
||||
import { findCachedResponse } from "@/lib/cached-responses"
|
||||
import { isPdfFile, isTextFile } from "@/lib/pdf-utils"
|
||||
import { type FileData, useFileProcessor } from "@/lib/use-file-processor"
|
||||
@@ -147,10 +146,7 @@ export default function ChatPanel({
|
||||
|
||||
const [showHistory, setShowHistory] = useState(false)
|
||||
const [showSettingsDialog, setShowSettingsDialog] = useState(false)
|
||||
const [showModelConfigDialog, setShowModelConfigDialog] = useState(false)
|
||||
|
||||
// Model configuration hook
|
||||
const modelConfig = useModelConfig()
|
||||
const [, setAccessCodeRequired] = useState(false)
|
||||
const [input, setInput] = useState("")
|
||||
const [dailyRequestLimit, setDailyRequestLimit] = useState(0)
|
||||
const [dailyTokenLimit, setDailyTokenLimit] = useState(0)
|
||||
@@ -171,11 +167,12 @@ export default function ChatPanel({
|
||||
fetch("/api/config")
|
||||
.then((res) => res.json())
|
||||
.then((data) => {
|
||||
setAccessCodeRequired(data.accessCodeRequired)
|
||||
setDailyRequestLimit(data.dailyRequestLimit || 0)
|
||||
setDailyTokenLimit(data.dailyTokenLimit || 0)
|
||||
setTpmLimit(data.tpmLimit || 0)
|
||||
})
|
||||
.catch(() => {})
|
||||
.catch(() => setAccessCodeRequired(false))
|
||||
}, [])
|
||||
|
||||
// Quota management using extracted hook
|
||||
@@ -612,7 +609,8 @@ Continue from EXACTLY where you stopped.`,
|
||||
})
|
||||
|
||||
if (error.message.includes("Invalid or missing access code")) {
|
||||
// Show settings dialog to help user fix it
|
||||
// Show settings button and open dialog to help user fix it
|
||||
setAccessCodeRequired(true)
|
||||
setShowSettingsDialog(true)
|
||||
}
|
||||
},
|
||||
@@ -1021,7 +1019,7 @@ Continue from EXACTLY where you stopped.`,
|
||||
autoRetryCountRef.current = 0
|
||||
partialXmlRef.current = ""
|
||||
|
||||
const config = getSelectedAIConfig()
|
||||
const config = getAIConfig()
|
||||
|
||||
sendMessage(
|
||||
{ parts },
|
||||
@@ -1038,20 +1036,6 @@ Continue from EXACTLY where you stopped.`,
|
||||
"x-ai-api-key": config.aiApiKey,
|
||||
}),
|
||||
...(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 && {
|
||||
"x-minimal-style": "true",
|
||||
@@ -1377,10 +1361,6 @@ Continue from EXACTLY where you stopped.`,
|
||||
error={error}
|
||||
minimalStyle={minimalStyle}
|
||||
onMinimalStyleChange={setMinimalStyle}
|
||||
models={modelConfig.models}
|
||||
selectedModelId={modelConfig.selectedModelId}
|
||||
onModelSelect={modelConfig.setSelectedModelId}
|
||||
onConfigureModels={() => setShowModelConfigDialog(true)}
|
||||
/>
|
||||
</footer>
|
||||
|
||||
@@ -1394,12 +1374,6 @@ Continue from EXACTLY where you stopped.`,
|
||||
onToggleDarkMode={onToggleDarkMode}
|
||||
/>
|
||||
|
||||
<ModelConfigDialog
|
||||
open={showModelConfigDialog}
|
||||
onOpenChange={setShowModelConfigDialog}
|
||||
modelConfig={modelConfig}
|
||||
/>
|
||||
|
||||
<ResetWarningModal
|
||||
open={showNewChatDialog}
|
||||
onOpenChange={setShowNewChatDialog}
|
||||
|
||||
@@ -1,216 +0,0 @@
|
||||
"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,6 +12,13 @@ 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"
|
||||
|
||||
@@ -28,6 +35,10 @@ 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
|
||||
@@ -53,6 +64,10 @@ 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
|
||||
@@ -89,6 +104,12 @@ 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])
|
||||
@@ -176,6 +197,190 @@ 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>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="space-y-0.5">
|
||||
<Label htmlFor="theme-toggle">
|
||||
|
||||
@@ -1,157 +0,0 @@
|
||||
"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,
|
||||
}
|
||||
@@ -1,191 +0,0 @@
|
||||
"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,
|
||||
}
|
||||
@@ -1,48 +0,0 @@
|
||||
"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 }
|
||||
@@ -1,373 +0,0 @@
|
||||
"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,11 +33,6 @@ export interface ClientOverrides {
|
||||
baseUrl?: string | null
|
||||
apiKey?: 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
|
||||
@@ -46,7 +41,6 @@ const ALLOWED_CLIENT_PROVIDERS: ProviderName[] = [
|
||||
"anthropic",
|
||||
"google",
|
||||
"azure",
|
||||
"bedrock",
|
||||
"openrouter",
|
||||
"deepseek",
|
||||
"siliconflow",
|
||||
@@ -543,25 +537,12 @@ export function getAIModel(overrides?: ClientOverrides): ModelConfig {
|
||||
|
||||
switch (provider) {
|
||||
case "bedrock": {
|
||||
// Use client-provided credentials if available, otherwise fall back to IAM/env vars
|
||||
const hasClientCredentials =
|
||||
overrides?.awsAccessKeyId && overrides?.awsSecretAccessKey
|
||||
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(),
|
||||
})
|
||||
// Use credential provider chain for IAM role support (Lambda, EC2, etc.)
|
||||
// Falls back to env vars (AWS_ACCESS_KEY_ID, AWS_SECRET_ACCESS_KEY) for local dev
|
||||
const bedrockProvider = createAmazonBedrock({
|
||||
region: process.env.AWS_REGION || "us-west-2",
|
||||
credentialProvider: fromNodeProviderChain(),
|
||||
})
|
||||
model = bedrockProvider(modelId)
|
||||
// Add Anthropic beta options if using Claude models via Bedrock
|
||||
if (modelId.includes("anthropic.claude")) {
|
||||
|
||||
@@ -180,24 +180,5 @@
|
||||
"seekingSponsorship": "Call for Sponsorship",
|
||||
"contactMe": "Contact Me",
|
||||
"usageNotice": "Due to high usage, I have changed the model from Claude to minimax-m2 and added some usage limits. See About page for details."
|
||||
},
|
||||
"modelConfig": {
|
||||
"title": "AI Model Configuration",
|
||||
"description": "Configure multiple AI providers and models",
|
||||
"configure": "Configure",
|
||||
"addProvider": "Add Provider",
|
||||
"addModel": "Add Model",
|
||||
"modelId": "Model ID",
|
||||
"modelLabel": "Display Label",
|
||||
"streaming": "Enable Streaming",
|
||||
"deleteProvider": "Delete Provider",
|
||||
"deleteModel": "Delete Model",
|
||||
"noModels": "No models configured. Add a model to get started.",
|
||||
"selectProvider": "Select a provider or add a new one",
|
||||
"configureMultiple": "Configure multiple AI providers and switch between them easily",
|
||||
"apiKeyStored": "API keys are stored locally in your browser",
|
||||
"test": "Test",
|
||||
"validationError": "Validation failed",
|
||||
"addModelFirst": "Add at least one model to validate"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -180,24 +180,5 @@
|
||||
"seekingSponsorship": "スポンサー募集",
|
||||
"contactMe": "お問い合わせ",
|
||||
"usageNotice": "利用量の増加に伴い、コスト削減のためモデルを Claude から minimax-m2 に変更し、いくつかの利用制限を設けました。詳細は概要ページをご覧ください。"
|
||||
},
|
||||
"modelConfig": {
|
||||
"title": "AIモデル設定",
|
||||
"description": "複数のAIプロバイダーとモデルを設定",
|
||||
"configure": "設定",
|
||||
"addProvider": "プロバイダーを追加",
|
||||
"addModel": "モデルを追加",
|
||||
"modelId": "モデルID",
|
||||
"modelLabel": "表示名",
|
||||
"streaming": "ストリーミングを有効",
|
||||
"deleteProvider": "プロバイダーを削除",
|
||||
"deleteModel": "モデルを削除",
|
||||
"noModels": "モデルが設定されていません。モデルを追加してください。",
|
||||
"selectProvider": "プロバイダーを選択または追加してください",
|
||||
"configureMultiple": "複数のAIプロバイダーを設定して簡単に切り替え",
|
||||
"apiKeyStored": "APIキーはブラウザにローカル保存されます",
|
||||
"test": "テスト",
|
||||
"validationError": "検証に失敗しました",
|
||||
"addModelFirst": "検証するには少なくとも1つのモデルを追加してください"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -180,24 +180,5 @@
|
||||
"seekingSponsorship": "寻求赞助(求大佬捞一把)",
|
||||
"contactMe": "联系我",
|
||||
"usageNotice": "由于使用量过高,我已将模型从 Claude 更换为 minimax-m2,并设置了一些用量限制。详情请查看关于页面。"
|
||||
},
|
||||
"modelConfig": {
|
||||
"title": "AI 模型配置",
|
||||
"description": "配置多个 AI 提供商和模型",
|
||||
"configure": "配置",
|
||||
"addProvider": "添加提供商",
|
||||
"addModel": "添加模型",
|
||||
"modelId": "模型 ID",
|
||||
"modelLabel": "显示名称",
|
||||
"streaming": "启用流式输出",
|
||||
"deleteProvider": "删除提供商",
|
||||
"deleteModel": "删除模型",
|
||||
"noModels": "尚未配置模型。添加模型以开始使用。",
|
||||
"selectProvider": "选择一个提供商或添加新的",
|
||||
"configureMultiple": "配置多个 AI 提供商并轻松切换",
|
||||
"apiKeyStored": "API 密钥存储在您的浏览器本地",
|
||||
"test": "测试",
|
||||
"validationError": "验证失败",
|
||||
"addModelFirst": "请先添加至少一个模型以进行验证"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -24,8 +24,4 @@ export const STORAGE_KEYS = {
|
||||
aiBaseUrl: "next-ai-draw-io-ai-base-url",
|
||||
aiApiKey: "next-ai-draw-io-ai-api-key",
|
||||
aiModel: "next-ai-draw-io-ai-model",
|
||||
|
||||
// Multi-model configuration
|
||||
modelConfigs: "next-ai-draw-io-model-configs",
|
||||
selectedModelId: "next-ai-draw-io-selected-model-id",
|
||||
} as const
|
||||
|
||||
@@ -1,277 +0,0 @@
|
||||
// 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)
|
||||
}
|
||||
911
package-lock.json
generated
@@ -41,14 +41,12 @@
|
||||
"@openrouter/ai-sdk-provider": "^1.2.3",
|
||||
"@opentelemetry/exporter-trace-otlp-http": "^0.208.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-dialog": "^1.1.15",
|
||||
"@radix-ui/react-dialog": "^1.1.6",
|
||||
"@radix-ui/react-label": "^2.1.8",
|
||||
"@radix-ui/react-popover": "^1.1.15",
|
||||
"@radix-ui/react-scroll-area": "^1.2.3",
|
||||
"@radix-ui/react-select": "^2.2.6",
|
||||
"@radix-ui/react-slot": "^1.2.4",
|
||||
"@radix-ui/react-slot": "^1.1.2",
|
||||
"@radix-ui/react-switch": "^1.2.6",
|
||||
"@radix-ui/react-tooltip": "^1.1.8",
|
||||
"@radix-ui/react-use-controllable-state": "^1.2.2",
|
||||
@@ -57,7 +55,6 @@
|
||||
"base-64": "^1.0.0",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"cmdk": "^1.1.1",
|
||||
"js-tiktoken": "^1.0.21",
|
||||
"jsdom": "^26.0.0",
|
||||
"jsonrepair": "^3.13.1",
|
||||
|
||||
|
Before Width: | Height: | Size: 14 KiB After Width: | Height: | Size: 14 KiB |
|
Before Width: | Height: | Size: 111 KiB After Width: | Height: | Size: 54 KiB |
|
Before Width: | Height: | Size: 20 KiB After Width: | Height: | Size: 15 KiB |
|
Before Width: | Height: | Size: 111 KiB After Width: | Height: | Size: 54 KiB |