Compare commits

..

12 Commits

Author SHA1 Message Date
dayuan.jiang
6086c4177a 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:27:49 +09:00
dayuan.jiang
33fd2a16e6 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
2025-12-22 21:57:02 +09:00
dayuan.jiang
41c450516c 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
2025-12-22 21:53:56 +09:00
dayuan.jiang
0e8783ccfb 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
2025-12-22 21:49:29 +09:00
dayuan.jiang
7cf6d7e7bd chore: remove unused code
- Remove unused setAccessCodeRequired state in chat-panel.tsx
- Remove unused getSelectedModel export in model-config.ts
2025-12-22 20:46:12 +09:00
dayuan.jiang
7ed7b29274 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
2025-12-22 20:40:12 +09:00
dayuan.jiang
1be0cfa06c fix: reset validation button to Test after success 2025-12-22 20:13:57 +09:00
dayuan.jiang
1f6ef7ac90 fix: reset Test button after validation completes 2025-12-22 20:11:46 +09:00
dayuan.jiang
56ca9d3f48 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
2025-12-22 20:09:16 +09:00
dayuan.jiang
e089702949 refactor: revert shadcn component changes, apply hover fix at usage site 2025-12-22 20:02:58 +09:00
dayuan.jiang
89b0a96b95 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
2025-12-22 20:00:45 +09:00
dayuan.jiang
1e916aa86e 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
2025-12-22 17:58:05 +09:00
17 changed files with 291 additions and 542 deletions

View File

@@ -11,66 +11,6 @@ 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
@@ -102,14 +42,6 @@ export async function POST(req: Request) {
)
}
// 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) {

View File

@@ -1,24 +1,24 @@
import type { MetadataRoute } from "next"
import { getAssetUrl } from "@/lib/base-path"
export default function manifest(): MetadataRoute.Manifest {
return {
name: "Next AI Draw.io",
short_name: "AIDraw.io",
description:
"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: getAssetUrl("/"),
start_url: "/",
display: "standalone",
background_color: "#f9fafb",
theme_color: "#171d26",
icons: [
{
src: getAssetUrl("/favicon-192x192.png"),
src: "/favicon-192x192.png",
sizes: "192x192",
type: "image/png",
purpose: "any",
},
{
src: getAssetUrl("/favicon-512x512.png"),
src: "/favicon-512x512.png",
sizes: "512x512",
type: "image/png",
purpose: "any",

View File

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

View File

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

View File

@@ -21,21 +21,16 @@ import { ChatInput } from "@/components/chat-input"
import { ModelConfigDialog } from "@/components/model-config-dialog"
import { ResetWarningModal } from "@/components/reset-warning-modal"
import { SettingsDialog } from "@/components/settings-dialog"
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from "@/components/ui/tooltip"
import { useDiagram } from "@/contexts/diagram-context"
import { useDictionary } from "@/hooks/use-dictionary"
import { getSelectedAIConfig, useModelConfig } from "@/hooks/use-model-config"
import { getApiEndpoint } from "@/lib/base-path"
import { findCachedResponse } from "@/lib/cached-responses"
import { isPdfFile, isTextFile } from "@/lib/pdf-utils"
import { type FileData, useFileProcessor } from "@/lib/use-file-processor"
import { useQuotaManager } from "@/lib/use-quota-manager"
import { formatXML, isMxCellXmlComplete, wrapWithMxFile } from "@/lib/utils"
import { ChatMessageDisplay } from "./chat-message-display"
import LanguageToggle from "./language-toggle"
// localStorage keys for persistence
const STORAGE_MESSAGES_KEY = "next-ai-draw-io-messages"
@@ -173,7 +168,7 @@ export default function ChatPanel({
// Check config on mount
useEffect(() => {
fetch(getApiEndpoint("/api/config"))
fetch("/api/config")
.then((res) => res.json())
.then((data) => {
setDailyRequestLimit(data.dailyRequestLimit || 0)
@@ -244,7 +239,7 @@ export default function ChatPanel({
setMessages,
} = useChat({
transport: new DefaultChatTransport({
api: getApiEndpoint("/api/chat"),
api: "/api/chat",
}),
async onToolCall({ toolCall }) {
if (DEBUG) {
@@ -1269,36 +1264,32 @@ Continue from EXACTLY where you stopped.`,
Next AI Drawio
</h1>
</div>
{!isMobile &&
process.env.NEXT_PUBLIC_SHOW_ABOUT_AND_NOTICE ===
"true" && (
<Link
href="/about"
target="_blank"
rel="noopener noreferrer"
className="text-sm text-muted-foreground hover:text-foreground transition-colors ml-2"
{!isMobile && (
<Link
href="/about"
target="_blank"
rel="noopener noreferrer"
className="text-sm text-muted-foreground hover:text-foreground transition-colors ml-2"
>
About
</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"
>
About
</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>
)}
<AlertTriangle className="h-4 w-4" />
</ButtonWithTooltip>
</Link>
)}
</div>
<div className="flex items-center gap-1 justify-end overflow-visible">
<ButtonWithTooltip
@@ -1313,23 +1304,16 @@ Continue from EXACTLY where you stopped.`,
/>
</ButtonWithTooltip>
<div className="w-px h-5 bg-border mx-1" />
<Tooltip>
<TooltipTrigger asChild>
<a
href="https://github.com/DayuanJiang/next-ai-draw-io"
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center justify-center h-9 w-9 rounded-md text-muted-foreground hover:text-foreground hover:bg-accent transition-colors"
>
<FaGithub
className={`${isMobile ? "w-4 h-4" : "w-5 h-5"}`}
/>
</a>
</TooltipTrigger>
<TooltipContent>{dict.nav.github}</TooltipContent>
</Tooltip>
<a
href="https://github.com/DayuanJiang/next-ai-draw-io"
target="_blank"
rel="noopener noreferrer"
className="p-2 rounded-lg text-muted-foreground hover:text-foreground hover:bg-accent transition-colors"
>
<FaGithub
className={`${isMobile ? "w-4 h-4" : "w-5 h-5"}`}
/>
</a>
<ButtonWithTooltip
tooltipContent={dict.nav.settings}
variant="ghost"
@@ -1342,6 +1326,7 @@ Continue from EXACTLY where you stopped.`,
/>
</ButtonWithTooltip>
<div className="hidden sm:flex items-center gap-2">
<LanguageToggle />
{!isMobile && (
<ButtonWithTooltip
tooltipContent={dict.nav.hidePanel}

View File

@@ -0,0 +1,108 @@
"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>
)
}

View File

@@ -1,8 +1,7 @@
"use client"
import { Moon, Sun } from "lucide-react"
import { usePathname, useRouter, useSearchParams } from "next/navigation"
import { Suspense, useEffect, useState } from "react"
import { useEffect, useState } from "react"
import { Button } from "@/components/ui/button"
import {
Dialog,
@@ -13,23 +12,8 @@ import {
} from "@/components/ui/dialog"
import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label"
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select"
import { Switch } from "@/components/ui/switch"
import { useDictionary } from "@/hooks/use-dictionary"
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 {
open: boolean
@@ -52,7 +36,7 @@ function getStoredAccessCodeRequired(): boolean | null {
return stored === "true"
}
function SettingsContent({
export function SettingsDialog({
open,
onOpenChange,
onCloseProtectionChange,
@@ -62,9 +46,6 @@ function SettingsContent({
onToggleDarkMode,
}: SettingsDialogProps) {
const dict = useDictionary()
const router = useRouter()
const pathname = usePathname() || "/"
const search = useSearchParams()
const [accessCode, setAccessCode] = useState("")
const [closeProtection, setCloseProtection] = useState(true)
const [isVerifying, setIsVerifying] = useState(false)
@@ -72,13 +53,12 @@ function SettingsContent({
const [accessCodeRequired, setAccessCodeRequired] = useState(
() => getStoredAccessCodeRequired() ?? false,
)
const [currentLang, setCurrentLang] = useState("en")
useEffect(() => {
// Only fetch if not cached in localStorage
if (getStoredAccessCodeRequired() !== null) return
fetch(getApiEndpoint("/api/config"))
fetch("/api/config")
.then((res) => {
if (!res.ok) throw new Error(`HTTP ${res.status}`)
return res.json()
@@ -97,17 +77,6 @@ function SettingsContent({
})
}, [])
// 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(() => {
if (open) {
const storedCode =
@@ -124,18 +93,6 @@ function SettingsContent({
}
}, [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 () => {
if (!accessCodeRequired) return
@@ -143,15 +100,12 @@ function SettingsContent({
setIsVerifying(true)
try {
const response = await fetch(
getApiEndpoint("/api/verify-access-code"),
{
method: "POST",
headers: {
"x-access-code": accessCode.trim(),
},
const response = await fetch("/api/verify-access-code", {
method: "POST",
headers: {
"x-access-code": accessCode.trim(),
},
)
})
const data = await response.json()
@@ -177,166 +131,128 @@ function SettingsContent({
}
return (
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle>{dict.settings.title}</DialogTitle>
<DialogDescription>
{dict.settings.description}
</DialogDescription>
</DialogHeader>
<div className="space-y-4 py-2">
{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}
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle>{dict.settings.title}</DialogTitle>
<DialogDescription>
{dict.settings.description}
</DialogDescription>
</DialogHeader>
<div className="space-y-4 py-2">
{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>
)}
</div>
)}
<div className="flex items-center justify-between">
<div className="space-y-0.5">
<Label htmlFor="language-select">
{dict.settings.language}
</Label>
<p className="text-[0.8rem] text-muted-foreground">
{dict.settings.languageDescription}
</p>
</div>
<Select value={currentLang} onValueChange={changeLanguage}>
<SelectTrigger id="language-select" className="w-32">
<SelectValue />
</SelectTrigger>
<SelectContent>
{i18n.locales.map((locale) => (
<SelectItem key={locale} value={locale}>
{LANGUAGE_LABELS[locale]}
</SelectItem>
))}
</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"
? dict.settings.minimal
: dict.settings.sketch}
</p>
</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="space-y-0.5">
<Label htmlFor="close-protection">
{dict.settings.closeProtection}
</Label>
<p className="text-[0.8rem] text-muted-foreground">
{dict.settings.closeProtectionDescription}
</p>
</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">
<p className="text-[0.75rem] text-muted-foreground text-center">
Version {process.env.APP_VERSION}
</p>
</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" />
{error && (
<p className="text-[0.8rem] text-destructive">
{error}
</p>
)}
</div>
</DialogContent>
}
>
<SettingsContent {...props} />
</Suspense>
)}
<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"
? dict.settings.minimal
: dict.settings.sketch}
</p>
</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="space-y-0.5">
<Label htmlFor="close-protection">
{dict.settings.closeProtection}
</Label>
<p className="text-[0.8rem] text-muted-foreground">
{dict.settings.closeProtectionDescription}
</p>
</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">
<p className="text-[0.75rem] text-muted-foreground text-center">
Version {process.env.APP_VERSION}
</p>
</div>
</DialogContent>
</Dialog>
)
}

View File

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

View File

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

View File

@@ -68,10 +68,6 @@ AI_MODEL=global.anthropic.claude-sonnet-4-5-20250929-v1:0
# SILICONFLOW_API_KEY=sk-...
# 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
# Get your API key from: https://vercel.com/ai-gateway
# Model format: "provider/model" e.g., "openai/gpt-4o", "anthropic/claude-sonnet-4-5"
@@ -97,12 +93,6 @@ 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
# 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)
# Enable PDF file upload to extract text and generate diagrams
# Enabled by default. Set to "false" to disable.

View File

@@ -19,7 +19,6 @@ export type ProviderName =
| "openrouter"
| "deepseek"
| "siliconflow"
| "sglang"
| "gateway"
interface ModelConfig {
@@ -51,7 +50,6 @@ const ALLOWED_CLIENT_PROVIDERS: ProviderName[] = [
"openrouter",
"deepseek",
"siliconflow",
"sglang",
"gateway",
]
@@ -345,7 +343,6 @@ function buildProviderOptions(
case "deepseek":
case "openrouter":
case "siliconflow":
case "sglang":
case "gateway": {
// These providers don't have reasoning configs in AI SDK yet
// Gateway passes through to underlying providers which handle their own configs
@@ -370,7 +367,6 @@ const PROVIDER_ENV_VARS: Record<ProviderName, string | null> = {
openrouter: "OPENROUTER_API_KEY",
deepseek: "DEEPSEEK_API_KEY",
siliconflow: "SILICONFLOW_API_KEY",
sglang: "SGLANG_API_KEY",
gateway: "AI_GATEWAY_API_KEY",
}
@@ -436,7 +432,7 @@ function validateProviderCredentials(provider: ProviderName): void {
* Get the AI model based on environment variables
*
* Environment variables:
* - AI_PROVIDER: The provider to use (bedrock, openai, anthropic, google, azure, ollama, openrouter, deepseek, siliconflow, sglang, gateway)
* - AI_PROVIDER: The provider to use (bedrock, openai, anthropic, google, azure, ollama, openrouter, deepseek, siliconflow)
* - AI_MODEL: The model ID/name for the selected provider
*
* Provider-specific env vars:
@@ -452,8 +448,6 @@ function validateProviderCredentials(provider: ProviderName): void {
* - DEEPSEEK_BASE_URL: DeepSeek endpoint (optional)
* - SILICONFLOW_API_KEY: SiliconFlow API key
* - 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 {
// SECURITY: Prevent SSRF attacks (GHSA-9qf7-mprq-9qgm)
@@ -522,7 +516,6 @@ export function getAIModel(overrides?: ClientOverrides): ModelConfig {
`- OPENROUTER_API_KEY for OpenRouter\n` +
`- AZURE_API_KEY for Azure\n` +
`- SILICONFLOW_API_KEY for SiliconFlow\n` +
`- SGLANG_API_KEY for SGLang\n` +
`Or set AI_PROVIDER=ollama for local Ollama.`,
)
} else {
@@ -705,112 +698,6 @@ export function getAIModel(overrides?: ClientOverrides): ModelConfig {
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": {
// Vercel AI Gateway - unified access to multiple AI providers
// Model format: "provider/model" e.g., "openai/gpt-4o", "anthropic/claude-sonnet-4-5"
@@ -834,7 +721,7 @@ export function getAIModel(overrides?: ClientOverrides): ModelConfig {
default:
throw new Error(
`Unknown AI provider: ${provider}. Supported providers: bedrock, openai, anthropic, google, azure, ollama, openrouter, deepseek, siliconflow, sglang, gateway`,
`Unknown AI provider: ${provider}. Supported providers: bedrock, openai, anthropic, google, azure, ollama, openrouter, deepseek, siliconflow, gateway`,
)
}

View File

@@ -1,37 +0,0 @@
/**
* 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,7 +14,6 @@
"about": "About",
"editor": "Editor",
"newChat": "Start fresh chat",
"github": "GitHub",
"settings": "Settings",
"hidePanel": "Hide chat panel (Ctrl+B)",
"showPanel": "Show chat panel (Ctrl+B)",
@@ -88,8 +87,6 @@
"overrides": "Overrides",
"clearSettings": "Clear Settings",
"useServerDefault": "Use Server Default",
"language": "Language",
"languageDescription": "Choose your interface language.",
"theme": "Theme",
"themeDescription": "Dark/Light mode for interface and DrawIO canvas.",
"drawioStyle": "DrawIO Style",
@@ -150,7 +147,6 @@
"tokenLimit": "Daily Token Limit Reached",
"tpmLimit": "Rate Limit",
"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.",
"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.",

View File

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

View File

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

View File

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

View File

@@ -4,16 +4,9 @@ import packageJson from "./package.json"
const nextConfig: NextConfig = {
/* config options here */
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: {
APP_VERSION: packageJson.version,
},
// Include instrumentation.ts in standalone build for Langfuse telemetry
outputFileTracingIncludes: {
"*": ["./instrumentation.ts"],
},
}
export default nextConfig