Compare commits

...

10 Commits

Author SHA1 Message Date
Dayuan Jiang
b4fc259de8 chore: bump version to 0.4.6 (#364)
* chore: bump version to 0.4.6

* style: auto-format with Biome

---------

Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2025-12-23 09:09:39 +09:00
Dayuan Jiang
28f9a81e7b chore: add build-time arg for showing About and Notice (#360) 2025-12-23 01:06:42 +09:00
Dayuan Jiang
0f67884ead fix: include instrumentation.ts in standalone build for Langfuse (#359)
Add outputFileTracingIncludes to next.config.ts to ensure instrumentation.ts
is included in standalone builds (required for App Runner deployment)
2025-12-23 01:03:11 +09:00
Dayuan Jiang
3521495ead chore: conditionally show about and notice based on env var (#358) 2025-12-23 00:32:22 +09:00
Dayuan Jiang
6446454cd7 fix: add SSRF protection to validate-model endpoint (#357)
Block private IPs, localhost, cloud metadata endpoints (169.254.169.254),
and internal hostnames in custom baseUrl parameter to prevent server-side
request forgery attacks.
2025-12-23 00:26:01 +09:00
Biki Kalita
84959637db Support subdirectory deployment and fix API path handling (#311)
* feat: support subdirectory deployment (NEXT_PUBLIC_BASE_PATH)

* removed unwanted check and fix favicon issue

* Use getAssetUrl for manifest assets to avoid undefined NEXT_PUBLIC_BASE_PATH

* Add validation warning for NEXT_PUBLIC_BASE_PATH format

---------

Co-authored-by: dayuan.jiang <jdy.toh@gmail.com>
2025-12-22 23:28:55 +09:00
pointerhacker
9e9ea10beb fix:feature/sglang-provider (#302)
Co-authored-by: zhaochaojin <zhaochaojin@didiglobal.com>
Co-authored-by: dayuan.jiang <jdy.toh@gmail.com>
2025-12-22 23:13:45 +09:00
Biki Kalita
deae5c2c38 Fix: Localize TPM rate-limit toast via i18n (#353)
* TMP error toast hardcoded english fixed

* fix: correct JA/ZH translations to use tokens instead of requests

---------

Co-authored-by: dayuan.jiang <jdy.toh@gmail.com>
2025-12-22 23:00:20 +09:00
Twelveeee
6e2d98e52d move Language Selector into SettingDialog (#352)
* fix:custom model setting bug

* refactor: consolidate aiProvider checks for cleaner code

* fix:Integrated the language selection option into the `SettingsDialog`

* fix:useSearchParams() should be wrapped in a suspense boundary at page

* fix: improve semantic HTML and maintainability

- Replace nested button>a with proper anchor element for GitHub link
- Use i18n.locales.map() with LANGUAGE_LABELS for language options

---------

Co-authored-by: dayuan.jiang <jdy.toh@gmail.com>
2025-12-22 22:54:25 +09:00
Dayuan Jiang
85cb441e26 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)
2025-12-22 22:36:36 +09:00
32 changed files with 4625 additions and 592 deletions

View File

@@ -63,6 +63,8 @@ jobs:
cache-from: type=gha cache-from: type=gha
cache-to: type=gha,mode=max cache-to: type=gha,mode=max
platforms: linux/amd64,linux/arm64 platforms: linux/amd64,linux/arm64
build-args: |
NEXT_PUBLIC_SHOW_ABOUT_AND_NOTICE=true
# Push to AWS ECR for App Runner auto-deploy # Push to AWS ECR for App Runner auto-deploy
- name: Configure AWS credentials - name: Configure AWS credentials

View File

@@ -26,6 +26,10 @@ ENV NEXT_TELEMETRY_DISABLED=1
ARG NEXT_PUBLIC_DRAWIO_BASE_URL=https://embed.diagrams.net ARG NEXT_PUBLIC_DRAWIO_BASE_URL=https://embed.diagrams.net
ENV NEXT_PUBLIC_DRAWIO_BASE_URL=${NEXT_PUBLIC_DRAWIO_BASE_URL} ENV NEXT_PUBLIC_DRAWIO_BASE_URL=${NEXT_PUBLIC_DRAWIO_BASE_URL}
# Build-time argument to show About link and Notice icon
ARG NEXT_PUBLIC_SHOW_ABOUT_AND_NOTICE=false
ENV NEXT_PUBLIC_SHOW_ABOUT_AND_NOTICE=${NEXT_PUBLIC_SHOW_ABOUT_AND_NOTICE}
# Build Next.js application (standalone mode) # Build Next.js application (standalone mode)
RUN npm run build RUN npm run build

View File

@@ -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

View File

@@ -0,0 +1,281 @@
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"
/**
* SECURITY: Check if URL points to private/internal network (SSRF protection)
* Blocks: localhost, private IPs, link-local, AWS metadata service
*/
function isPrivateUrl(urlString: string): boolean {
try {
const url = new URL(urlString)
const hostname = url.hostname.toLowerCase()
// Block localhost
if (
hostname === "localhost" ||
hostname === "127.0.0.1" ||
hostname === "::1"
) {
return true
}
// Block AWS/cloud metadata endpoints
if (
hostname === "169.254.169.254" ||
hostname === "metadata.google.internal"
) {
return true
}
// Check for private IPv4 ranges
const ipv4Match = hostname.match(
/^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/,
)
if (ipv4Match) {
const [, a, b] = ipv4Match.map(Number)
// 10.0.0.0/8
if (a === 10) return true
// 172.16.0.0/12
if (a === 172 && b >= 16 && b <= 31) return true
// 192.168.0.0/16
if (a === 192 && b === 168) return true
// 169.254.0.0/16 (link-local)
if (a === 169 && b === 254) return true
// 127.0.0.0/8 (loopback)
if (a === 127) return true
}
// Block common internal hostnames
if (
hostname.endsWith(".local") ||
hostname.endsWith(".internal") ||
hostname.endsWith(".localhost")
) {
return true
}
return false
} catch {
// Invalid URL - block it
return true
}
}
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 },
)
}
// SECURITY: Block SSRF attacks via custom baseUrl
if (baseUrl && isPrivateUrl(baseUrl)) {
return NextResponse.json(
{ valid: false, error: "Invalid base URL" },
{ 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
)
}
}

View File

@@ -1,24 +1,24 @@
import type { MetadataRoute } from "next" import type { MetadataRoute } from "next"
import { getAssetUrl } from "@/lib/base-path"
export default function manifest(): MetadataRoute.Manifest { export default function manifest(): MetadataRoute.Manifest {
return { return {
name: "Next AI Draw.io", name: "Next AI Draw.io",
short_name: "AIDraw.io", short_name: "AIDraw.io",
description: description:
"Create AWS architecture diagrams, flowcharts, and technical diagrams using AI. Free online tool integrating draw.io with AI assistance for professional diagram creation.", "Create AWS architecture diagrams, flowcharts, and technical diagrams using AI. Free online tool integrating draw.io with AI assistance for professional diagram creation.",
start_url: "/", start_url: getAssetUrl("/"),
display: "standalone", display: "standalone",
background_color: "#f9fafb", background_color: "#f9fafb",
theme_color: "#171d26", theme_color: "#171d26",
icons: [ icons: [
{ {
src: "/favicon-192x192.png", src: getAssetUrl("/favicon-192x192.png"),
sizes: "192x192", sizes: "192x192",
type: "image/png", type: "image/png",
purpose: "any", purpose: "any",
}, },
{ {
src: "/favicon-512x512.png", src: getAssetUrl("/favicon-512x512.png"),
sizes: "512x512", sizes: "512x512",
type: "image/png", type: "image/png",
purpose: "any", purpose: "any",

View 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} />
)

View File

@@ -9,6 +9,7 @@ import {
Zap, Zap,
} from "lucide-react" } from "lucide-react"
import { useDictionary } from "@/hooks/use-dictionary" import { useDictionary } from "@/hooks/use-dictionary"
import { getAssetUrl } from "@/lib/base-path"
interface ExampleCardProps { interface ExampleCardProps {
icon: React.ReactNode icon: React.ReactNode
@@ -79,7 +80,7 @@ export default function ExamplePanel({
setInput("Replicate this flowchart.") setInput("Replicate this flowchart.")
try { try {
const response = await fetch("/example.png") const response = await fetch(getAssetUrl("/example.png"))
const blob = await response.blob() const blob = await response.blob()
const file = new File([blob], "example.png", { type: "image/png" }) const file = new File([blob], "example.png", { type: "image/png" })
setFiles([file]) setFiles([file])
@@ -92,7 +93,7 @@ export default function ExamplePanel({
setInput("Replicate this in aws style") setInput("Replicate this in aws style")
try { try {
const response = await fetch("/architecture.png") const response = await fetch(getAssetUrl("/architecture.png"))
const blob = await response.blob() const blob = await response.blob()
const file = new File([blob], "architecture.png", { const file = new File([blob], "architecture.png", {
type: "image/png", type: "image/png",
@@ -107,7 +108,7 @@ export default function ExamplePanel({
setInput("Summarize this paper as a diagram") setInput("Summarize this paper as a diagram")
try { try {
const response = await fetch("/chain-of-thought.txt") const response = await fetch(getAssetUrl("/chain-of-thought.txt"))
const blob = await response.blob() const blob = await response.blob()
const file = new File([blob], "chain-of-thought.txt", { const file = new File([blob], "chain-of-thought.txt", {
type: "text/plain", type: "text/plain",

View File

@@ -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

View File

@@ -27,6 +27,7 @@ import {
ReasoningTrigger, ReasoningTrigger,
} from "@/components/ai-elements/reasoning" } from "@/components/ai-elements/reasoning"
import { ScrollArea } from "@/components/ui/scroll-area" import { ScrollArea } from "@/components/ui/scroll-area"
import { getApiEndpoint } from "@/lib/base-path"
import { import {
applyDiagramOperations, applyDiagramOperations,
convertToLegalXml, convertToLegalXml,
@@ -291,7 +292,7 @@ export function ChatMessageDisplay({
setFeedback((prev) => ({ ...prev, [messageId]: value })) setFeedback((prev) => ({ ...prev, [messageId]: value }))
try { try {
await fetch("/api/log-feedback", { await fetch(getApiEndpoint("/api/log-feedback"), {
method: "POST", method: "POST",
headers: { "Content-Type": "application/json" }, headers: { "Content-Type": "application/json" },
body: JSON.stringify({ body: JSON.stringify({

View File

@@ -18,18 +18,24 @@ 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 {
Tooltip,
TooltipContent,
TooltipTrigger,
} from "@/components/ui/tooltip"
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 { getApiEndpoint } from "@/lib/base-path"
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"
import { useQuotaManager } from "@/lib/use-quota-manager" import { useQuotaManager } from "@/lib/use-quota-manager"
import { formatXML, isMxCellXmlComplete, wrapWithMxFile } from "@/lib/utils" import { formatXML, isMxCellXmlComplete, wrapWithMxFile } from "@/lib/utils"
import { ChatMessageDisplay } from "./chat-message-display" import { ChatMessageDisplay } from "./chat-message-display"
import LanguageToggle from "./language-toggle"
// localStorage keys for persistence // localStorage keys for persistence
const STORAGE_MESSAGES_KEY = "next-ai-draw-io-messages" const STORAGE_MESSAGES_KEY = "next-ai-draw-io-messages"
@@ -146,7 +152,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)
@@ -164,15 +173,14 @@ export default function ChatPanel({
// Check config on mount // Check config on mount
useEffect(() => { useEffect(() => {
fetch("/api/config") fetch(getApiEndpoint("/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
@@ -236,7 +244,7 @@ export default function ChatPanel({
setMessages, setMessages,
} = useChat({ } = useChat({
transport: new DefaultChatTransport({ transport: new DefaultChatTransport({
api: "/api/chat", api: getApiEndpoint("/api/chat"),
}), }),
async onToolCall({ toolCall }) { async onToolCall({ toolCall }) {
if (DEBUG) { if (DEBUG) {
@@ -609,8 +617,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 +1026,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 +1043,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",
@@ -1248,32 +1269,36 @@ Continue from EXACTLY where you stopped.`,
Next AI Drawio Next AI Drawio
</h1> </h1>
</div> </div>
{!isMobile && ( {!isMobile &&
<Link process.env.NEXT_PUBLIC_SHOW_ABOUT_AND_NOTICE ===
href="/about" "true" && (
target="_blank" <Link
rel="noopener noreferrer" href="/about"
className="text-sm text-muted-foreground hover:text-foreground transition-colors ml-2" target="_blank"
> rel="noopener noreferrer"
About className="text-sm text-muted-foreground hover:text-foreground transition-colors ml-2"
</Link>
)}
{!isMobile && (
<Link
href="/about"
target="_blank"
rel="noopener noreferrer"
>
<ButtonWithTooltip
tooltipContent="Due to high usage, I have changed the model to minimax-m2 and added some usage limits. See About page for details."
variant="ghost"
size="icon"
className="h-6 w-6 text-amber-500 hover:text-amber-600"
> >
<AlertTriangle className="h-4 w-4" /> About
</ButtonWithTooltip> </Link>
</Link> )}
)} {!isMobile &&
process.env.NEXT_PUBLIC_SHOW_ABOUT_AND_NOTICE ===
"true" && (
<Link
href="/about"
target="_blank"
rel="noopener noreferrer"
>
<ButtonWithTooltip
tooltipContent="Due to high usage, I have changed the model to minimax-m2 and added some usage limits. See About page for details."
variant="ghost"
size="icon"
className="h-6 w-6 text-amber-500 hover:text-amber-600"
>
<AlertTriangle className="h-4 w-4" />
</ButtonWithTooltip>
</Link>
)}
</div> </div>
<div className="flex items-center gap-1 justify-end overflow-visible"> <div className="flex items-center gap-1 justify-end overflow-visible">
<ButtonWithTooltip <ButtonWithTooltip
@@ -1288,16 +1313,23 @@ Continue from EXACTLY where you stopped.`,
/> />
</ButtonWithTooltip> </ButtonWithTooltip>
<div className="w-px h-5 bg-border mx-1" /> <div className="w-px h-5 bg-border mx-1" />
<a
href="https://github.com/DayuanJiang/next-ai-draw-io" <Tooltip>
target="_blank" <TooltipTrigger asChild>
rel="noopener noreferrer" <a
className="p-2 rounded-lg text-muted-foreground hover:text-foreground hover:bg-accent transition-colors" href="https://github.com/DayuanJiang/next-ai-draw-io"
> target="_blank"
<FaGithub rel="noopener noreferrer"
className={`${isMobile ? "w-4 h-4" : "w-5 h-5"}`} className="inline-flex items-center justify-center h-9 w-9 rounded-md text-muted-foreground hover:text-foreground hover:bg-accent transition-colors"
/> >
</a> <FaGithub
className={`${isMobile ? "w-4 h-4" : "w-5 h-5"}`}
/>
</a>
</TooltipTrigger>
<TooltipContent>{dict.nav.github}</TooltipContent>
</Tooltip>
<ButtonWithTooltip <ButtonWithTooltip
tooltipContent={dict.nav.settings} tooltipContent={dict.nav.settings}
variant="ghost" variant="ghost"
@@ -1310,7 +1342,6 @@ Continue from EXACTLY where you stopped.`,
/> />
</ButtonWithTooltip> </ButtonWithTooltip>
<div className="hidden sm:flex items-center gap-2"> <div className="hidden sm:flex items-center gap-2">
<LanguageToggle />
{!isMobile && ( {!isMobile && (
<ButtonWithTooltip <ButtonWithTooltip
tooltipContent={dict.nav.hidePanel} tooltipContent={dict.nav.hidePanel}
@@ -1361,6 +1392,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 +1409,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}

View File

@@ -1,108 +0,0 @@
"use client"
import { Globe } from "lucide-react"
import { usePathname, useRouter, useSearchParams } from "next/navigation"
import { Suspense, useEffect, useRef, useState } from "react"
import { i18n, type Locale } from "@/lib/i18n/config"
const LABELS: Record<string, string> = {
en: "EN",
zh: "中文",
ja: "日本語",
}
function LanguageToggleInner({ className = "" }: { className?: string }) {
const router = useRouter()
const pathname = usePathname() || "/"
const search = useSearchParams()
const [open, setOpen] = useState(false)
const [value, setValue] = useState<Locale>(i18n.defaultLocale)
const ref = useRef<HTMLDivElement | null>(null)
useEffect(() => {
const seg = pathname.split("/").filter(Boolean)
const first = seg[0]
if (first && i18n.locales.includes(first as Locale))
setValue(first as Locale)
else setValue(i18n.defaultLocale)
}, [pathname])
useEffect(() => {
function onDoc(e: MouseEvent) {
if (!ref.current) return
if (!ref.current.contains(e.target as Node)) setOpen(false)
}
if (open) document.addEventListener("mousedown", onDoc)
return () => document.removeEventListener("mousedown", onDoc)
}, [open])
const changeLocale = (lang: string) => {
const parts = pathname.split("/")
if (parts.length > 1 && i18n.locales.includes(parts[1] as Locale)) {
parts[1] = lang
} else {
parts.splice(1, 0, lang)
}
const newPath = parts.join("/") || "/"
const searchStr = search?.toString() ? `?${search.toString()}` : ""
setOpen(false)
router.push(newPath + searchStr)
}
return (
<div className={`relative inline-flex ${className}`} ref={ref}>
<button
aria-haspopup="menu"
aria-expanded={open}
onClick={() => setOpen((s) => !s)}
className="p-2 rounded-full hover:bg-accent/20 transition-colors text-muted-foreground"
aria-label="Change language"
>
<Globe className="w-5 h-5" />
</button>
{open && (
<div className="absolute right-0 top-full mt-2 w-40 bg-popover dark:bg-popover text-popover-foreground rounded-xl shadow-md border border-border/30 overflow-hidden z-50">
<div className="grid gap-0 divide-y divide-border/30">
{i18n.locales.map((loc) => (
<button
key={loc}
onClick={() => changeLocale(loc)}
className={`flex items-center gap-2 px-4 py-2 text-sm w-full text-left hover:bg-accent/10 transition-colors ${value === loc ? "bg-accent/10 font-semibold" : ""}`}
>
<span className="flex-1">
{LABELS[loc] ?? loc}
</span>
{value === loc && (
<span className="text-xs opacity-70">
</span>
)}
</button>
))}
</div>
</div>
)}
</div>
)
}
export default function LanguageToggle({
className = "",
}: {
className?: string
}) {
return (
<Suspense
fallback={
<button
className="p-2 rounded-full text-muted-foreground opacity-50"
disabled
>
<Globe className="w-5 h-5" />
</button>
}
>
<LanguageToggleInner className={className} />
</Suspense>
)
}

File diff suppressed because it is too large Load Diff

View 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>
)
}

View File

@@ -1,7 +1,8 @@
"use client" "use client"
import { Moon, Sun } from "lucide-react" import { Moon, Sun } from "lucide-react"
import { useEffect, useState } from "react" import { usePathname, useRouter, useSearchParams } from "next/navigation"
import { Suspense, useEffect, useState } from "react"
import { Button } from "@/components/ui/button" import { Button } from "@/components/ui/button"
import { import {
Dialog, Dialog,
@@ -21,6 +22,14 @@ import {
} from "@/components/ui/select" } 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"
import { getApiEndpoint } from "@/lib/base-path"
import { i18n, type Locale } from "@/lib/i18n/config"
const LANGUAGE_LABELS: Record<Locale, string> = {
en: "English",
zh: "中文",
ja: "日本語",
}
interface SettingsDialogProps { interface SettingsDialogProps {
open: boolean open: boolean
@@ -35,10 +44,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
@@ -47,7 +52,7 @@ function getStoredAccessCodeRequired(): boolean | null {
return stored === "true" return stored === "true"
} }
export function SettingsDialog({ function SettingsContent({
open, open,
onOpenChange, onOpenChange,
onCloseProtectionChange, onCloseProtectionChange,
@@ -57,6 +62,9 @@ export function SettingsDialog({
onToggleDarkMode, onToggleDarkMode,
}: SettingsDialogProps) { }: SettingsDialogProps) {
const dict = useDictionary() const dict = useDictionary()
const router = useRouter()
const pathname = usePathname() || "/"
const search = useSearchParams()
const [accessCode, setAccessCode] = useState("") const [accessCode, setAccessCode] = useState("")
const [closeProtection, setCloseProtection] = useState(true) const [closeProtection, setCloseProtection] = useState(true)
const [isVerifying, setIsVerifying] = useState(false) const [isVerifying, setIsVerifying] = useState(false)
@@ -64,16 +72,13 @@ export function SettingsDialog({
const [accessCodeRequired, setAccessCodeRequired] = useState( const [accessCodeRequired, setAccessCodeRequired] = useState(
() => getStoredAccessCodeRequired() ?? false, () => getStoredAccessCodeRequired() ?? false,
) )
const [provider, setProvider] = useState("") const [currentLang, setCurrentLang] = useState("en")
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
if (getStoredAccessCodeRequired() !== null) return if (getStoredAccessCodeRequired() !== null) return
fetch("/api/config") fetch(getApiEndpoint("/api/config"))
.then((res) => { .then((res) => {
if (!res.ok) throw new Error(`HTTP ${res.status}`) if (!res.ok) throw new Error(`HTTP ${res.status}`)
return res.json() return res.json()
@@ -92,6 +97,17 @@ export function SettingsDialog({
}) })
}, []) }, [])
// Detect current language from pathname
useEffect(() => {
const seg = pathname.split("/").filter(Boolean)
const first = seg[0]
if (first && i18n.locales.includes(first as Locale)) {
setCurrentLang(first)
} else {
setCurrentLang(i18n.defaultLocale)
}
}, [pathname])
useEffect(() => { useEffect(() => {
if (open) { if (open) {
const storedCode = const storedCode =
@@ -104,16 +120,22 @@ 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])
const changeLanguage = (lang: string) => {
const parts = pathname.split("/")
if (parts.length > 1 && i18n.locales.includes(parts[1] as Locale)) {
parts[1] = lang
} else {
parts.splice(1, 0, lang)
}
const newPath = parts.join("/") || "/"
const searchStr = search?.toString() ? `?${search.toString()}` : ""
router.push(newPath + searchStr)
}
const handleSave = async () => { const handleSave = async () => {
if (!accessCodeRequired) return if (!accessCodeRequired) return
@@ -121,12 +143,15 @@ export function SettingsDialog({
setIsVerifying(true) setIsVerifying(true)
try { try {
const response = await fetch("/api/verify-access-code", { const response = await fetch(
method: "POST", getApiEndpoint("/api/verify-access-code"),
headers: { {
"x-access-code": accessCode.trim(), method: "POST",
headers: {
"x-access-code": accessCode.trim(),
},
}, },
}) )
const data = await response.json() const data = await response.json()
@@ -152,312 +177,166 @@ export function SettingsDialog({
} }
return ( return (
<Dialog open={open} onOpenChange={onOpenChange}> <DialogContent className="sm:max-w-md">
<DialogContent className="sm:max-w-md"> <DialogHeader>
<DialogHeader> <DialogTitle>{dict.settings.title}</DialogTitle>
<DialogTitle>{dict.settings.title}</DialogTitle> <DialogDescription>
<DialogDescription> {dict.settings.description}
{dict.settings.description} </DialogDescription>
</DialogDescription> </DialogHeader>
</DialogHeader> <div className="space-y-4 py-2">
<div className="space-y-4 py-2"> {accessCodeRequired && (
{accessCodeRequired && (
<div className="space-y-2">
<Label htmlFor="access-code">
{dict.settings.accessCode}
</Label>
<div className="flex gap-2">
<Input
id="access-code"
type="password"
value={accessCode}
onChange={(e) =>
setAccessCode(e.target.value)
}
onKeyDown={handleKeyDown}
placeholder={
dict.settings.accessCodePlaceholder
}
autoComplete="off"
/>
<Button
onClick={handleSave}
disabled={isVerifying || !accessCode.trim()}
>
{isVerifying ? "..." : dict.common.save}
</Button>
</div>
<p className="text-[0.8rem] text-muted-foreground">
{dict.settings.accessCodeDescription}
</p>
{error && (
<p className="text-[0.8rem] text-destructive">
{error}
</p>
)}
</div>
)}
<div className="space-y-2"> <div className="space-y-2">
<Label>{dict.settings.aiProvider}</Label> <Label htmlFor="access-code">
{dict.settings.accessCode}
</Label>
<div className="flex gap-2">
<Input
id="access-code"
type="password"
value={accessCode}
onChange={(e) => setAccessCode(e.target.value)}
onKeyDown={handleKeyDown}
placeholder={
dict.settings.accessCodePlaceholder
}
autoComplete="off"
/>
<Button
onClick={handleSave}
disabled={isVerifying || !accessCode.trim()}
>
{isVerifying ? "..." : dict.common.save}
</Button>
</div>
<p className="text-[0.8rem] text-muted-foreground"> <p className="text-[0.8rem] text-muted-foreground">
{dict.settings.aiProviderDescription} {dict.settings.accessCodeDescription}
</p> </p>
<div className="space-y-3 pt-2"> {error && (
<div className="space-y-2"> <p className="text-[0.8rem] text-destructive">
<Label htmlFor="ai-provider"> {error}
{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">
{dict.settings.theme}
</Label>
<p className="text-[0.8rem] text-muted-foreground">
{dict.settings.themeDescription}
</p> </p>
</div> )}
<Button
id="theme-toggle"
variant="outline"
size="icon"
onClick={onToggleDarkMode}
>
{darkMode ? (
<Sun className="h-4 w-4" />
) : (
<Moon className="h-4 w-4" />
)}
</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="drawio-ui"> <Label htmlFor="language-select">
{dict.settings.drawioStyle} {dict.settings.language}
</Label> </Label>
<p className="text-[0.8rem] text-muted-foreground"> <p className="text-[0.8rem] text-muted-foreground">
{dict.settings.drawioStyleDescription}{" "} {dict.settings.languageDescription}
{drawioUi === "min" </p>
? dict.settings.minimal </div>
: dict.settings.sketch} <Select value={currentLang} onValueChange={changeLanguage}>
</p> <SelectTrigger id="language-select" className="w-32">
</div> <SelectValue />
<Button </SelectTrigger>
id="drawio-ui" <SelectContent>
variant="outline" {i18n.locales.map((locale) => (
size="sm" <SelectItem key={locale} value={locale}>
onClick={onToggleDrawioUi} {LANGUAGE_LABELS[locale]}
> </SelectItem>
{dict.settings.switchTo}{" "} ))}
</SelectContent>
</Select>
</div>
<div className="flex items-center justify-between">
<div className="space-y-0.5">
<Label htmlFor="theme-toggle">
{dict.settings.theme}
</Label>
<p className="text-[0.8rem] text-muted-foreground">
{dict.settings.themeDescription}
</p>
</div>
<Button
id="theme-toggle"
variant="outline"
size="icon"
onClick={onToggleDarkMode}
>
{darkMode ? (
<Sun className="h-4 w-4" />
) : (
<Moon className="h-4 w-4" />
)}
</Button>
</div>
<div className="flex items-center justify-between">
<div className="space-y-0.5">
<Label htmlFor="drawio-ui">
{dict.settings.drawioStyle}
</Label>
<p className="text-[0.8rem] text-muted-foreground">
{dict.settings.drawioStyleDescription}{" "}
{drawioUi === "min" {drawioUi === "min"
? dict.settings.sketch ? dict.settings.minimal
: dict.settings.minimal} : dict.settings.sketch}
</Button> </p>
</div> </div>
<Button
id="drawio-ui"
variant="outline"
size="sm"
onClick={onToggleDrawioUi}
>
{dict.settings.switchTo}{" "}
{drawioUi === "min"
? dict.settings.sketch
: dict.settings.minimal}
</Button>
</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="close-protection"> <Label htmlFor="close-protection">
{dict.settings.closeProtection} {dict.settings.closeProtection}
</Label> </Label>
<p className="text-[0.8rem] text-muted-foreground"> <p className="text-[0.8rem] text-muted-foreground">
{dict.settings.closeProtectionDescription} {dict.settings.closeProtectionDescription}
</p> </p>
</div>
<Switch
id="close-protection"
checked={closeProtection}
onCheckedChange={(checked) => {
setCloseProtection(checked)
localStorage.setItem(
STORAGE_CLOSE_PROTECTION_KEY,
checked.toString(),
)
onCloseProtectionChange?.(checked)
}}
/>
</div> </div>
<Switch
id="close-protection"
checked={closeProtection}
onCheckedChange={(checked) => {
setCloseProtection(checked)
localStorage.setItem(
STORAGE_CLOSE_PROTECTION_KEY,
checked.toString(),
)
onCloseProtectionChange?.(checked)
}}
/>
</div> </div>
<div className="pt-4 border-t border-border/50"> </div>
<p className="text-[0.75rem] text-muted-foreground text-center"> <div className="pt-4 border-t border-border/50">
Version {process.env.APP_VERSION} <p className="text-[0.75rem] text-muted-foreground text-center">
</p> Version {process.env.APP_VERSION}
</div> </p>
</DialogContent> </div>
</DialogContent>
)
}
export function SettingsDialog(props: SettingsDialogProps) {
return (
<Dialog open={props.open} onOpenChange={props.onOpenChange}>
<Suspense
fallback={
<DialogContent className="sm:max-w-md">
<div className="h-64 flex items-center justify-center">
<div className="animate-spin h-8 w-8 border-4 border-primary border-t-transparent rounded-full" />
</div>
</DialogContent>
}
>
<SettingsContent {...props} />
</Suspense>
</Dialog> </Dialog>
) )
} }

View 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
View File

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

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

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

View File

@@ -5,6 +5,7 @@ import { createContext, useContext, useEffect, useRef, useState } from "react"
import type { DrawIoEmbedRef } from "react-drawio" import type { DrawIoEmbedRef } from "react-drawio"
import { STORAGE_DIAGRAM_XML_KEY } from "@/components/chat-panel" import { STORAGE_DIAGRAM_XML_KEY } from "@/components/chat-panel"
import type { ExportFormat } from "@/components/save-dialog" import type { ExportFormat } from "@/components/save-dialog"
import { getApiEndpoint } from "@/lib/base-path"
import { extractDiagramXML, validateAndFixXml } from "../lib/utils" import { extractDiagramXML, validateAndFixXml } from "../lib/utils"
interface DiagramContextType { interface DiagramContextType {
@@ -329,7 +330,7 @@ export function DiagramProvider({ children }: { children: React.ReactNode }) {
sessionId?: string, sessionId?: string,
) => { ) => {
try { try {
await fetch("/api/log-save", { await fetch(getApiEndpoint("/api/log-save"), {
method: "POST", method: "POST",
headers: { "Content-Type": "application/json" }, headers: { "Content-Type": "application/json" },
body: JSON.stringify({ filename, format, sessionId }), body: JSON.stringify({ filename, format, sessionId }),

View File

@@ -7,6 +7,11 @@ services:
context: . context: .
args: args:
- NEXT_PUBLIC_DRAWIO_BASE_URL=http://localhost:8080 - NEXT_PUBLIC_DRAWIO_BASE_URL=http://localhost:8080
# Uncomment below for subdirectory deployment
# - NEXT_PUBLIC_BASE_PATH=/nextaidrawio
ports: ["3000:3000"] ports: ["3000:3000"]
env_file: .env env_file: .env
environment:
# For subdirectory deployment, uncomment and set your path:
# NEXT_PUBLIC_BASE_PATH: /nextaidrawio
depends_on: [drawio] depends_on: [drawio]

View File

@@ -68,6 +68,10 @@ AI_MODEL=global.anthropic.claude-sonnet-4-5-20250929-v1:0
# SILICONFLOW_API_KEY=sk-... # SILICONFLOW_API_KEY=sk-...
# SILICONFLOW_BASE_URL=https://api.siliconflow.com/v1 # Optional: switch to https://api.siliconflow.cn/v1 if needed # SILICONFLOW_BASE_URL=https://api.siliconflow.com/v1 # Optional: switch to https://api.siliconflow.cn/v1 if needed
# SGLang Configuration (OpenAI-compatible)
# SGLANG_API_KEY=your-sglang-api-key
# SGLANG_BASE_URL=http://127.0.0.1:8000/v1 # Your SGLang endpoint
# Vercel AI Gateway Configuration # Vercel AI Gateway Configuration
# Get your API key from: https://vercel.com/ai-gateway # Get your API key from: https://vercel.com/ai-gateway
# Model format: "provider/model" e.g., "openai/gpt-4o", "anthropic/claude-sonnet-4-5" # Model format: "provider/model" e.g., "openai/gpt-4o", "anthropic/claude-sonnet-4-5"
@@ -93,6 +97,12 @@ AI_MODEL=global.anthropic.claude-sonnet-4-5-20250929-v1:0
# NEXT_PUBLIC_DRAWIO_BASE_URL=https://embed.diagrams.net # Default: https://embed.diagrams.net # NEXT_PUBLIC_DRAWIO_BASE_URL=https://embed.diagrams.net # Default: https://embed.diagrams.net
# Use this to point to a self-hosted draw.io instance # Use this to point to a self-hosted draw.io instance
# Subdirectory Deployment (Optional)
# For deploying to a subdirectory (e.g., https://example.com/nextaidrawio)
# Set this to your subdirectory path with leading slash (e.g., /nextaidrawio)
# Leave empty for root deployment (default)
# NEXT_PUBLIC_BASE_PATH=/nextaidrawio
# PDF Input Feature (Optional) # PDF Input Feature (Optional)
# Enable PDF file upload to extract text and generate diagrams # Enable PDF file upload to extract text and generate diagrams
# Enabled by default. Set to "false" to disable. # Enabled by default. Set to "false" to disable.

373
hooks/use-model-config.ts Normal file
View 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 || "",
}
}

View File

@@ -19,6 +19,7 @@ export type ProviderName =
| "openrouter" | "openrouter"
| "deepseek" | "deepseek"
| "siliconflow" | "siliconflow"
| "sglang"
| "gateway" | "gateway"
interface ModelConfig { interface ModelConfig {
@@ -33,6 +34,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,9 +47,11 @@ const ALLOWED_CLIENT_PROVIDERS: ProviderName[] = [
"anthropic", "anthropic",
"google", "google",
"azure", "azure",
"bedrock",
"openrouter", "openrouter",
"deepseek", "deepseek",
"siliconflow", "siliconflow",
"sglang",
"gateway", "gateway",
] ]
@@ -337,6 +345,7 @@ function buildProviderOptions(
case "deepseek": case "deepseek":
case "openrouter": case "openrouter":
case "siliconflow": case "siliconflow":
case "sglang":
case "gateway": { case "gateway": {
// These providers don't have reasoning configs in AI SDK yet // These providers don't have reasoning configs in AI SDK yet
// Gateway passes through to underlying providers which handle their own configs // Gateway passes through to underlying providers which handle their own configs
@@ -361,6 +370,7 @@ const PROVIDER_ENV_VARS: Record<ProviderName, string | null> = {
openrouter: "OPENROUTER_API_KEY", openrouter: "OPENROUTER_API_KEY",
deepseek: "DEEPSEEK_API_KEY", deepseek: "DEEPSEEK_API_KEY",
siliconflow: "SILICONFLOW_API_KEY", siliconflow: "SILICONFLOW_API_KEY",
sglang: "SGLANG_API_KEY",
gateway: "AI_GATEWAY_API_KEY", gateway: "AI_GATEWAY_API_KEY",
} }
@@ -426,7 +436,7 @@ function validateProviderCredentials(provider: ProviderName): void {
* Get the AI model based on environment variables * Get the AI model based on environment variables
* *
* Environment variables: * Environment variables:
* - AI_PROVIDER: The provider to use (bedrock, openai, anthropic, google, azure, ollama, openrouter, deepseek, siliconflow) * - AI_PROVIDER: The provider to use (bedrock, openai, anthropic, google, azure, ollama, openrouter, deepseek, siliconflow, sglang, gateway)
* - AI_MODEL: The model ID/name for the selected provider * - AI_MODEL: The model ID/name for the selected provider
* *
* Provider-specific env vars: * Provider-specific env vars:
@@ -442,6 +452,8 @@ function validateProviderCredentials(provider: ProviderName): void {
* - DEEPSEEK_BASE_URL: DeepSeek endpoint (optional) * - DEEPSEEK_BASE_URL: DeepSeek endpoint (optional)
* - SILICONFLOW_API_KEY: SiliconFlow API key * - SILICONFLOW_API_KEY: SiliconFlow API key
* - SILICONFLOW_BASE_URL: SiliconFlow endpoint (optional, defaults to https://api.siliconflow.com/v1) * - SILICONFLOW_BASE_URL: SiliconFlow endpoint (optional, defaults to https://api.siliconflow.com/v1)
* - SGLANG_API_KEY: SGLang API key
* - SGLANG_BASE_URL: SGLang endpoint (optional)
*/ */
export function getAIModel(overrides?: ClientOverrides): ModelConfig { export function getAIModel(overrides?: ClientOverrides): ModelConfig {
// SECURITY: Prevent SSRF attacks (GHSA-9qf7-mprq-9qgm) // SECURITY: Prevent SSRF attacks (GHSA-9qf7-mprq-9qgm)
@@ -510,6 +522,7 @@ export function getAIModel(overrides?: ClientOverrides): ModelConfig {
`- OPENROUTER_API_KEY for OpenRouter\n` + `- OPENROUTER_API_KEY for OpenRouter\n` +
`- AZURE_API_KEY for Azure\n` + `- AZURE_API_KEY for Azure\n` +
`- SILICONFLOW_API_KEY for SiliconFlow\n` + `- SILICONFLOW_API_KEY for SiliconFlow\n` +
`- SGLANG_API_KEY for SGLang\n` +
`Or set AI_PROVIDER=ollama for local Ollama.`, `Or set AI_PROVIDER=ollama for local Ollama.`,
) )
} else { } else {
@@ -537,12 +550,25 @@ 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 =
credentialProvider: fromNodeProviderChain(), 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(),
})
model = bedrockProvider(modelId) model = bedrockProvider(modelId)
// Add Anthropic beta options if using Claude models via Bedrock // Add Anthropic beta options if using Claude models via Bedrock
if (modelId.includes("anthropic.claude")) { if (modelId.includes("anthropic.claude")) {
@@ -679,6 +705,112 @@ export function getAIModel(overrides?: ClientOverrides): ModelConfig {
break break
} }
case "sglang": {
const apiKey = overrides?.apiKey || process.env.SGLANG_API_KEY
const baseURL = overrides?.baseUrl || process.env.SGLANG_BASE_URL
const sglangProvider = createOpenAI({
apiKey,
baseURL,
// Add a custom fetch wrapper to intercept and fix the stream from sglang
fetch: async (url, options) => {
const response = await fetch(url, options)
if (!response.body) {
return response
}
// Create a transform stream to fix the non-compliant sglang stream
let buffer = ""
const decoder = new TextDecoder()
const transformStream = new TransformStream({
transform(chunk, controller) {
buffer += decoder.decode(chunk, { stream: true })
// Process all complete messages in the buffer
let messageEndPos
while (
(messageEndPos = buffer.indexOf("\n\n")) !== -1
) {
const message = buffer.substring(
0,
messageEndPos,
)
buffer = buffer.substring(messageEndPos + 2) // Move past the '\n\n'
if (message.startsWith("data: ")) {
const jsonStr = message.substring(6).trim()
if (jsonStr === "[DONE]") {
controller.enqueue(
new TextEncoder().encode(
message + "\n\n",
),
)
continue
}
try {
const data = JSON.parse(jsonStr)
const delta = data.choices?.[0]?.delta
if (delta) {
// Fix 1: remove invalid empty role
if (delta.role === "") {
delete delta.role
}
// Fix 2: remove non-standard reasoning_content field
if ("reasoning_content" in delta) {
delete delta.reasoning_content
}
}
// Re-serialize and forward the corrected data with the correct SSE format
controller.enqueue(
new TextEncoder().encode(
`data: ${JSON.stringify(data)}\n\n`,
),
)
} catch (e) {
// If parsing fails, forward the original message to avoid breaking the stream.
controller.enqueue(
new TextEncoder().encode(
message + "\n\n",
),
)
}
} else if (message.trim() !== "") {
// Pass through other message types (e.g., 'event: ...')
controller.enqueue(
new TextEncoder().encode(
message + "\n\n",
),
)
}
}
},
flush(controller) {
// If there's anything left in the buffer, forward it.
if (buffer.trim()) {
controller.enqueue(
new TextEncoder().encode(buffer),
)
}
},
})
const transformedBody =
response.body.pipeThrough(transformStream)
// Return a new response with the transformed body
return new Response(transformedBody, {
status: response.status,
statusText: response.statusText,
headers: response.headers,
})
},
})
model = sglangProvider.chat(modelId)
break
}
case "gateway": { case "gateway": {
// Vercel AI Gateway - unified access to multiple AI providers // Vercel AI Gateway - unified access to multiple AI providers
// Model format: "provider/model" e.g., "openai/gpt-4o", "anthropic/claude-sonnet-4-5" // Model format: "provider/model" e.g., "openai/gpt-4o", "anthropic/claude-sonnet-4-5"
@@ -702,7 +834,7 @@ export function getAIModel(overrides?: ClientOverrides): ModelConfig {
default: default:
throw new Error( throw new Error(
`Unknown AI provider: ${provider}. Supported providers: bedrock, openai, anthropic, google, azure, ollama, openrouter, deepseek, siliconflow, gateway`, `Unknown AI provider: ${provider}. Supported providers: bedrock, openai, anthropic, google, azure, ollama, openrouter, deepseek, siliconflow, sglang, gateway`,
) )
} }

37
lib/base-path.ts Normal file
View File

@@ -0,0 +1,37 @@
/**
* Get the base path for API calls and static assets
* This is used for subdirectory deployment support
*
* Example: If deployed at https://example.com/nextaidrawio, this returns "/nextaidrawio"
* For root deployment, this returns ""
*
* Set NEXT_PUBLIC_BASE_PATH environment variable to your subdirectory path (e.g., /nextaidrawio)
*/
export function getBasePath(): string {
// Read from environment variable (must start with NEXT_PUBLIC_ to be available on client)
const basePath = process.env.NEXT_PUBLIC_BASE_PATH || ""
if (basePath && !basePath.startsWith("/")) {
console.warn("NEXT_PUBLIC_BASE_PATH should start with /")
}
return basePath
}
/**
* Get full API endpoint URL
* @param endpoint - API endpoint path (e.g., "/api/chat", "/api/config")
* @returns Full API path with base path prefix
*/
export function getApiEndpoint(endpoint: string): string {
const basePath = getBasePath()
return `${basePath}${endpoint}`
}
/**
* Get full static asset URL
* @param assetPath - Asset path (e.g., "/example.png", "/chain-of-thought.txt")
* @returns Full asset path with base path prefix
*/
export function getAssetUrl(assetPath: string): string {
const basePath = getBasePath()
return `${basePath}${assetPath}`
}

View File

@@ -14,6 +14,7 @@
"about": "About", "about": "About",
"editor": "Editor", "editor": "Editor",
"newChat": "Start fresh chat", "newChat": "Start fresh chat",
"github": "GitHub",
"settings": "Settings", "settings": "Settings",
"hidePanel": "Hide chat panel (Ctrl+B)", "hidePanel": "Hide chat panel (Ctrl+B)",
"showPanel": "Show chat panel (Ctrl+B)", "showPanel": "Show chat panel (Ctrl+B)",
@@ -87,6 +88,8 @@
"overrides": "Overrides", "overrides": "Overrides",
"clearSettings": "Clear Settings", "clearSettings": "Clear Settings",
"useServerDefault": "Use Server Default", "useServerDefault": "Use Server Default",
"language": "Language",
"languageDescription": "Choose your interface language.",
"theme": "Theme", "theme": "Theme",
"themeDescription": "Dark/Light mode for interface and DrawIO canvas.", "themeDescription": "Dark/Light mode for interface and DrawIO canvas.",
"drawioStyle": "DrawIO Style", "drawioStyle": "DrawIO Style",
@@ -147,6 +150,7 @@
"tokenLimit": "Daily Token Limit Reached", "tokenLimit": "Daily Token Limit Reached",
"tpmLimit": "Rate Limit", "tpmLimit": "Rate Limit",
"tpmMessage": "Too many requests. Please wait a moment.", "tpmMessage": "Too many requests. Please wait a moment.",
"tpmMessageDetailed": "Rate limit reached ({limit} tokens/min). Please wait {seconds} seconds before sending another request.",
"messageApi": "Oops — you've reached the daily API limit for this demo! As an indie developer covering all the API costs myself, I have to set these limits to keep things sustainable.", "messageApi": "Oops — you've reached the daily API limit for this demo! As an indie developer covering all the API costs myself, I have to set these limits to keep things sustainable.",
"messageToken": "Oops — you've reached the daily token limit for this demo! As an indie developer covering all the API costs myself, I have to set these limits to keep things sustainable.", "messageToken": "Oops — you've reached the daily token limit for this demo! As an indie developer covering all the API costs myself, I have to set these limits to keep things sustainable.",
"tip": "<strong>Tip:</strong> You can use your own API key (click the Settings icon) or self-host the project to bypass these limits.", "tip": "<strong>Tip:</strong> You can use your own API key (click the Settings icon) or self-host the project to bypass these limits.",
@@ -180,5 +184,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"
} }
} }

View File

@@ -14,6 +14,7 @@
"about": "概要", "about": "概要",
"editor": "エディタ", "editor": "エディタ",
"newChat": "新しいチャットを開始", "newChat": "新しいチャットを開始",
"github": "GitHub",
"settings": "設定", "settings": "設定",
"hidePanel": "チャットパネルを非表示 (Ctrl+B)", "hidePanel": "チャットパネルを非表示 (Ctrl+B)",
"showPanel": "チャットパネルを表示 (Ctrl+B)", "showPanel": "チャットパネルを表示 (Ctrl+B)",
@@ -87,6 +88,8 @@
"overrides": "上書き", "overrides": "上書き",
"clearSettings": "設定をクリア", "clearSettings": "設定をクリア",
"useServerDefault": "サーバーデフォルトを使用", "useServerDefault": "サーバーデフォルトを使用",
"language": "言語",
"languageDescription": "インターフェース言語を選択します。",
"theme": "テーマ", "theme": "テーマ",
"themeDescription": "インターフェースと DrawIO キャンバスのダーク/ライトモード。", "themeDescription": "インターフェースと DrawIO キャンバスのダーク/ライトモード。",
"drawioStyle": "DrawIO スタイル", "drawioStyle": "DrawIO スタイル",
@@ -147,6 +150,7 @@
"tokenLimit": "1日のトークン制限に達しました", "tokenLimit": "1日のトークン制限に達しました",
"tpmLimit": "レート制限", "tpmLimit": "レート制限",
"tpmMessage": "リクエストが多すぎます。しばらくお待ちください。", "tpmMessage": "リクエストが多すぎます。しばらくお待ちください。",
"tpmMessageDetailed": "レート制限に達しました({limit}トークン/分)。{seconds}秒待ってからもう一度リクエストしてください。",
"messageApi": "おっと — このデモの1日の API 制限に達しました!個人開発者として API コストをすべて負担しているため、持続可能性を保つためにこれらの制限を設定する必要があります。", "messageApi": "おっと — このデモの1日の API 制限に達しました!個人開発者として API コストをすべて負担しているため、持続可能性を保つためにこれらの制限を設定する必要があります。",
"messageToken": "おっと — このデモの1日のトークン制限に達しました個人開発者として API コストをすべて負担しているため、持続可能性を保つためにこれらの制限を設定する必要があります。", "messageToken": "おっと — このデモの1日のトークン制限に達しました個人開発者として API コストをすべて負担しているため、持続可能性を保つためにこれらの制限を設定する必要があります。",
"tip": "<strong>ヒント:</strong>独自の API キーを使用する(設定アイコンをクリック)か、プロジェクトをセルフホストしてこれらの制限を回避できます。", "tip": "<strong>ヒント:</strong>独自の API キーを使用する(設定アイコンをクリック)か、プロジェクトをセルフホストしてこれらの制限を回避できます。",
@@ -180,5 +184,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つのモデルを追加してください"
} }
} }

View File

@@ -14,6 +14,7 @@
"about": "关于", "about": "关于",
"editor": "编辑器", "editor": "编辑器",
"newChat": "开始新对话", "newChat": "开始新对话",
"github": "GitHub",
"settings": "设置", "settings": "设置",
"hidePanel": "隐藏聊天面板 (Ctrl+B)", "hidePanel": "隐藏聊天面板 (Ctrl+B)",
"showPanel": "显示聊天面板 (Ctrl+B)", "showPanel": "显示聊天面板 (Ctrl+B)",
@@ -87,6 +88,8 @@
"overrides": "覆盖", "overrides": "覆盖",
"clearSettings": "清除设置", "clearSettings": "清除设置",
"useServerDefault": "使用服务器默认值", "useServerDefault": "使用服务器默认值",
"language": "语言",
"languageDescription": "选择界面语言。",
"theme": "主题", "theme": "主题",
"themeDescription": "界面和 DrawIO 画布的深色/浅色模式。", "themeDescription": "界面和 DrawIO 画布的深色/浅色模式。",
"drawioStyle": "DrawIO 样式", "drawioStyle": "DrawIO 样式",
@@ -147,6 +150,7 @@
"tokenLimit": "已达每日令牌限制", "tokenLimit": "已达每日令牌限制",
"tpmLimit": "速率限制", "tpmLimit": "速率限制",
"tpmMessage": "请求过多。请稍等片刻。", "tpmMessage": "请求过多。请稍等片刻。",
"tpmMessageDetailed": "达到速率限制({limit} 令牌/分钟)。请等待 {seconds} 秒后再发送请求。",
"messageApi": "糟糕 — 您已达到此演示的每日 API 限制!作为一名独立开发者,我自己承担所有 API 费用,因此必须设置这些限制以保持可持续性。", "messageApi": "糟糕 — 您已达到此演示的每日 API 限制!作为一名独立开发者,我自己承担所有 API 费用,因此必须设置这些限制以保持可持续性。",
"messageToken": "糟糕 — 您已达到此演示的每日令牌限制!作为一名独立开发者,我自己承担所有 API 费用,因此必须设置这些限制以保持可持续性。", "messageToken": "糟糕 — 您已达到此演示的每日令牌限制!作为一名独立开发者,我自己承担所有 API 费用,因此必须设置这些限制以保持可持续性。",
"tip": "<strong>提示:</strong>您可以使用自己的 API 密钥(点击设置图标)或自托管项目来绕过这些限制。", "tip": "<strong>提示:</strong>您可以使用自己的 API 密钥(点击设置图标)或自托管项目来绕过这些限制。",
@@ -180,5 +184,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": "请先添加至少一个模型以进行验证"
} }
} }

View File

@@ -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
View 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)
}

View File

@@ -3,6 +3,8 @@
import { useCallback, useMemo } from "react" import { useCallback, useMemo } from "react"
import { toast } from "sonner" import { toast } from "sonner"
import { QuotaLimitToast } from "@/components/quota-limit-toast" import { QuotaLimitToast } from "@/components/quota-limit-toast"
import { useDictionary } from "@/hooks/use-dictionary"
import { formatMessage } from "@/lib/i18n/utils"
import { STORAGE_KEYS } from "@/lib/storage" import { STORAGE_KEYS } from "@/lib/storage"
export interface QuotaConfig { export interface QuotaConfig {
@@ -40,6 +42,8 @@ export function useQuotaManager(config: QuotaConfig): {
} { } {
const { dailyRequestLimit, dailyTokenLimit, tpmLimit } = config const { dailyRequestLimit, dailyTokenLimit, tpmLimit } = config
const dict = useDictionary()
// Check if user has their own API key configured (bypass limits) // Check if user has their own API key configured (bypass limits)
const hasOwnApiKey = useCallback((): boolean => { const hasOwnApiKey = useCallback((): boolean => {
const provider = localStorage.getItem(STORAGE_KEYS.aiProvider) const provider = localStorage.getItem(STORAGE_KEYS.aiProvider)
@@ -221,11 +225,12 @@ export function useQuotaManager(config: QuotaConfig): {
const showTPMLimitToast = useCallback(() => { const showTPMLimitToast = useCallback(() => {
const limitDisplay = const limitDisplay =
tpmLimit >= 1000 ? `${tpmLimit / 1000}k` : String(tpmLimit) tpmLimit >= 1000 ? `${tpmLimit / 1000}k` : String(tpmLimit)
toast.error( const message = formatMessage(dict.quota.tpmMessageDetailed, {
`Rate limit reached (${limitDisplay} tokens/min). Please wait 60 seconds before sending another request.`, limit: limitDisplay,
{ duration: 8000 }, seconds: 60,
) })
}, [tpmLimit]) toast.error(message, { duration: 8000 })
}, [tpmLimit, dict])
return { return {
// Check functions // Check functions

View File

@@ -4,9 +4,16 @@ import packageJson from "./package.json"
const nextConfig: NextConfig = { const nextConfig: NextConfig = {
/* config options here */ /* config options here */
output: "standalone", output: "standalone",
// Support for subdirectory deployment (e.g., https://example.com/nextaidrawio)
// Set NEXT_PUBLIC_BASE_PATH environment variable to your subdirectory path (e.g., /nextaidrawio)
basePath: process.env.NEXT_PUBLIC_BASE_PATH || "",
env: { env: {
APP_VERSION: packageJson.version, APP_VERSION: packageJson.version,
}, },
// Include instrumentation.ts in standalone build for Langfuse telemetry
outputFileTracingIncludes: {
"*": ["./instrumentation.ts"],
},
} }
export default nextConfig export default nextConfig

917
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,6 +1,6 @@
{ {
"name": "next-ai-draw-io", "name": "next-ai-draw-io",
"version": "0.4.5", "version": "0.4.6",
"license": "Apache-2.0", "license": "Apache-2.0",
"private": true, "private": true,
"main": "dist-electron/main/index.js", "main": "dist-electron/main/index.js",
@@ -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",