mirror of
https://github.com/DayuanJiang/next-ai-draw-io.git
synced 2026-01-02 22:32:27 +08:00
Compare commits
12 Commits
v0.4.6
...
feat/multi
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6086c4177a | ||
|
|
33fd2a16e6 | ||
|
|
41c450516c | ||
|
|
0e8783ccfb | ||
|
|
7cf6d7e7bd | ||
|
|
7ed7b29274 | ||
|
|
1be0cfa06c | ||
|
|
1f6ef7ac90 | ||
|
|
56ca9d3f48 | ||
|
|
e089702949 | ||
|
|
89b0a96b95 | ||
|
|
1e916aa86e |
2
.github/workflows/docker-build.yml
vendored
2
.github/workflows/docker-build.yml
vendored
@@ -63,8 +63,6 @@ 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
|
||||||
|
|||||||
@@ -26,10 +26,6 @@ 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
|
||||||
|
|
||||||
|
|||||||
@@ -11,66 +11,6 @@ import { createOllama } from "ollama-ai-provider-v2"
|
|||||||
|
|
||||||
export const runtime = "nodejs"
|
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 {
|
interface ValidateRequest {
|
||||||
provider: string
|
provider: string
|
||||||
apiKey: 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
|
// Validate credentials based on provider
|
||||||
if (provider === "bedrock") {
|
if (provider === "bedrock") {
|
||||||
if (!awsAccessKeyId || !awsSecretAccessKey || !awsRegion) {
|
if (!awsAccessKeyId || !awsSecretAccessKey || !awsRegion) {
|
||||||
|
|||||||
@@ -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: getAssetUrl("/"),
|
start_url: "/",
|
||||||
display: "standalone",
|
display: "standalone",
|
||||||
background_color: "#f9fafb",
|
background_color: "#f9fafb",
|
||||||
theme_color: "#171d26",
|
theme_color: "#171d26",
|
||||||
icons: [
|
icons: [
|
||||||
{
|
{
|
||||||
src: getAssetUrl("/favicon-192x192.png"),
|
src: "/favicon-192x192.png",
|
||||||
sizes: "192x192",
|
sizes: "192x192",
|
||||||
type: "image/png",
|
type: "image/png",
|
||||||
purpose: "any",
|
purpose: "any",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
src: getAssetUrl("/favicon-512x512.png"),
|
src: "/favicon-512x512.png",
|
||||||
sizes: "512x512",
|
sizes: "512x512",
|
||||||
type: "image/png",
|
type: "image/png",
|
||||||
purpose: "any",
|
purpose: "any",
|
||||||
|
|||||||
@@ -9,7 +9,6 @@ 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
|
||||||
@@ -80,7 +79,7 @@ export default function ExamplePanel({
|
|||||||
setInput("Replicate this flowchart.")
|
setInput("Replicate this flowchart.")
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await fetch(getAssetUrl("/example.png"))
|
const response = await fetch("/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])
|
||||||
@@ -93,7 +92,7 @@ export default function ExamplePanel({
|
|||||||
setInput("Replicate this in aws style")
|
setInput("Replicate this in aws style")
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await fetch(getAssetUrl("/architecture.png"))
|
const response = await fetch("/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",
|
||||||
@@ -108,7 +107,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(getAssetUrl("/chain-of-thought.txt"))
|
const response = await fetch("/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",
|
||||||
|
|||||||
@@ -27,7 +27,6 @@ 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,
|
||||||
@@ -292,7 +291,7 @@ export function ChatMessageDisplay({
|
|||||||
setFeedback((prev) => ({ ...prev, [messageId]: value }))
|
setFeedback((prev) => ({ ...prev, [messageId]: value }))
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await fetch(getApiEndpoint("/api/log-feedback"), {
|
await fetch("/api/log-feedback", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: { "Content-Type": "application/json" },
|
headers: { "Content-Type": "application/json" },
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
|
|||||||
@@ -21,21 +21,16 @@ import { ChatInput } from "@/components/chat-input"
|
|||||||
import { ModelConfigDialog } from "@/components/model-config-dialog"
|
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 { getSelectedAIConfig, useModelConfig } from "@/hooks/use-model-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"
|
||||||
@@ -173,7 +168,7 @@ export default function ChatPanel({
|
|||||||
|
|
||||||
// Check config on mount
|
// Check config on mount
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
fetch(getApiEndpoint("/api/config"))
|
fetch("/api/config")
|
||||||
.then((res) => res.json())
|
.then((res) => res.json())
|
||||||
.then((data) => {
|
.then((data) => {
|
||||||
setDailyRequestLimit(data.dailyRequestLimit || 0)
|
setDailyRequestLimit(data.dailyRequestLimit || 0)
|
||||||
@@ -244,7 +239,7 @@ export default function ChatPanel({
|
|||||||
setMessages,
|
setMessages,
|
||||||
} = useChat({
|
} = useChat({
|
||||||
transport: new DefaultChatTransport({
|
transport: new DefaultChatTransport({
|
||||||
api: getApiEndpoint("/api/chat"),
|
api: "/api/chat",
|
||||||
}),
|
}),
|
||||||
async onToolCall({ toolCall }) {
|
async onToolCall({ toolCall }) {
|
||||||
if (DEBUG) {
|
if (DEBUG) {
|
||||||
@@ -1269,36 +1264,32 @@ Continue from EXACTLY where you stopped.`,
|
|||||||
Next AI Drawio
|
Next AI Drawio
|
||||||
</h1>
|
</h1>
|
||||||
</div>
|
</div>
|
||||||
{!isMobile &&
|
{!isMobile && (
|
||||||
process.env.NEXT_PUBLIC_SHOW_ABOUT_AND_NOTICE ===
|
<Link
|
||||||
"true" && (
|
href="/about"
|
||||||
<Link
|
target="_blank"
|
||||||
href="/about"
|
rel="noopener noreferrer"
|
||||||
target="_blank"
|
className="text-sm text-muted-foreground hover:text-foreground transition-colors ml-2"
|
||||||
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
|
<AlertTriangle className="h-4 w-4" />
|
||||||
</Link>
|
</ButtonWithTooltip>
|
||||||
)}
|
</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
|
||||||
@@ -1313,23 +1304,16 @@ 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
|
||||||
<Tooltip>
|
href="https://github.com/DayuanJiang/next-ai-draw-io"
|
||||||
<TooltipTrigger asChild>
|
target="_blank"
|
||||||
<a
|
rel="noopener noreferrer"
|
||||||
href="https://github.com/DayuanJiang/next-ai-draw-io"
|
className="p-2 rounded-lg text-muted-foreground hover:text-foreground hover:bg-accent transition-colors"
|
||||||
target="_blank"
|
>
|
||||||
rel="noopener noreferrer"
|
<FaGithub
|
||||||
className="inline-flex items-center justify-center h-9 w-9 rounded-md text-muted-foreground hover:text-foreground hover:bg-accent transition-colors"
|
className={`${isMobile ? "w-4 h-4" : "w-5 h-5"}`}
|
||||||
>
|
/>
|
||||||
<FaGithub
|
</a>
|
||||||
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"
|
||||||
@@ -1342,6 +1326,7 @@ 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}
|
||||||
|
|||||||
108
components/language-toggle.tsx
Normal file
108
components/language-toggle.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,8 +1,7 @@
|
|||||||
"use client"
|
"use client"
|
||||||
|
|
||||||
import { Moon, Sun } from "lucide-react"
|
import { Moon, Sun } from "lucide-react"
|
||||||
import { usePathname, useRouter, useSearchParams } from "next/navigation"
|
import { useEffect, useState } from "react"
|
||||||
import { Suspense, useEffect, useState } from "react"
|
|
||||||
import { Button } from "@/components/ui/button"
|
import { Button } from "@/components/ui/button"
|
||||||
import {
|
import {
|
||||||
Dialog,
|
Dialog,
|
||||||
@@ -13,23 +12,8 @@ import {
|
|||||||
} from "@/components/ui/dialog"
|
} from "@/components/ui/dialog"
|
||||||
import { Input } from "@/components/ui/input"
|
import { Input } from "@/components/ui/input"
|
||||||
import { Label } from "@/components/ui/label"
|
import { Label } from "@/components/ui/label"
|
||||||
import {
|
|
||||||
Select,
|
|
||||||
SelectContent,
|
|
||||||
SelectItem,
|
|
||||||
SelectTrigger,
|
|
||||||
SelectValue,
|
|
||||||
} from "@/components/ui/select"
|
|
||||||
import { Switch } from "@/components/ui/switch"
|
import { Switch } from "@/components/ui/switch"
|
||||||
import { useDictionary } from "@/hooks/use-dictionary"
|
import { useDictionary } from "@/hooks/use-dictionary"
|
||||||
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
|
||||||
@@ -52,7 +36,7 @@ function getStoredAccessCodeRequired(): boolean | null {
|
|||||||
return stored === "true"
|
return stored === "true"
|
||||||
}
|
}
|
||||||
|
|
||||||
function SettingsContent({
|
export function SettingsDialog({
|
||||||
open,
|
open,
|
||||||
onOpenChange,
|
onOpenChange,
|
||||||
onCloseProtectionChange,
|
onCloseProtectionChange,
|
||||||
@@ -62,9 +46,6 @@ function SettingsContent({
|
|||||||
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)
|
||||||
@@ -72,13 +53,12 @@ function SettingsContent({
|
|||||||
const [accessCodeRequired, setAccessCodeRequired] = useState(
|
const [accessCodeRequired, setAccessCodeRequired] = useState(
|
||||||
() => getStoredAccessCodeRequired() ?? false,
|
() => getStoredAccessCodeRequired() ?? false,
|
||||||
)
|
)
|
||||||
const [currentLang, setCurrentLang] = useState("en")
|
|
||||||
|
|
||||||
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(getApiEndpoint("/api/config"))
|
fetch("/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()
|
||||||
@@ -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(() => {
|
useEffect(() => {
|
||||||
if (open) {
|
if (open) {
|
||||||
const storedCode =
|
const storedCode =
|
||||||
@@ -124,18 +93,6 @@ function SettingsContent({
|
|||||||
}
|
}
|
||||||
}, [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
|
||||||
|
|
||||||
@@ -143,15 +100,12 @@ function SettingsContent({
|
|||||||
setIsVerifying(true)
|
setIsVerifying(true)
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await fetch(
|
const response = await fetch("/api/verify-access-code", {
|
||||||
getApiEndpoint("/api/verify-access-code"),
|
method: "POST",
|
||||||
{
|
headers: {
|
||||||
method: "POST",
|
"x-access-code": accessCode.trim(),
|
||||||
headers: {
|
|
||||||
"x-access-code": accessCode.trim(),
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
)
|
})
|
||||||
|
|
||||||
const data = await response.json()
|
const data = await response.json()
|
||||||
|
|
||||||
@@ -177,166 +131,128 @@ function SettingsContent({
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<DialogContent className="sm:max-w-md">
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||||
<DialogHeader>
|
<DialogContent className="sm:max-w-md">
|
||||||
<DialogTitle>{dict.settings.title}</DialogTitle>
|
<DialogHeader>
|
||||||
<DialogDescription>
|
<DialogTitle>{dict.settings.title}</DialogTitle>
|
||||||
{dict.settings.description}
|
<DialogDescription>
|
||||||
</DialogDescription>
|
{dict.settings.description}
|
||||||
</DialogHeader>
|
</DialogDescription>
|
||||||
<div className="space-y-4 py-2">
|
</DialogHeader>
|
||||||
{accessCodeRequired && (
|
<div className="space-y-4 py-2">
|
||||||
<div className="space-y-2">
|
{accessCodeRequired && (
|
||||||
<Label htmlFor="access-code">
|
<div className="space-y-2">
|
||||||
{dict.settings.accessCode}
|
<Label htmlFor="access-code">
|
||||||
</Label>
|
{dict.settings.accessCode}
|
||||||
<div className="flex gap-2">
|
</Label>
|
||||||
<Input
|
<div className="flex gap-2">
|
||||||
id="access-code"
|
<Input
|
||||||
type="password"
|
id="access-code"
|
||||||
value={accessCode}
|
type="password"
|
||||||
onChange={(e) => setAccessCode(e.target.value)}
|
value={accessCode}
|
||||||
onKeyDown={handleKeyDown}
|
onChange={(e) =>
|
||||||
placeholder={
|
setAccessCode(e.target.value)
|
||||||
dict.settings.accessCodePlaceholder
|
}
|
||||||
}
|
onKeyDown={handleKeyDown}
|
||||||
autoComplete="off"
|
placeholder={
|
||||||
/>
|
dict.settings.accessCodePlaceholder
|
||||||
<Button
|
}
|
||||||
onClick={handleSave}
|
autoComplete="off"
|
||||||
disabled={isVerifying || !accessCode.trim()}
|
/>
|
||||||
>
|
<Button
|
||||||
{isVerifying ? "..." : dict.common.save}
|
onClick={handleSave}
|
||||||
</Button>
|
disabled={isVerifying || !accessCode.trim()}
|
||||||
</div>
|
>
|
||||||
<p className="text-[0.8rem] text-muted-foreground">
|
{isVerifying ? "..." : dict.common.save}
|
||||||
{dict.settings.accessCodeDescription}
|
</Button>
|
||||||
</p>
|
</div>
|
||||||
{error && (
|
<p className="text-[0.8rem] text-muted-foreground">
|
||||||
<p className="text-[0.8rem] text-destructive">
|
{dict.settings.accessCodeDescription}
|
||||||
{error}
|
|
||||||
</p>
|
</p>
|
||||||
)}
|
{error && (
|
||||||
</div>
|
<p className="text-[0.8rem] text-destructive">
|
||||||
)}
|
{error}
|
||||||
|
</p>
|
||||||
<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" />
|
|
||||||
</div>
|
</div>
|
||||||
</DialogContent>
|
)}
|
||||||
}
|
<div className="flex items-center justify-between">
|
||||||
>
|
<div className="space-y-0.5">
|
||||||
<SettingsContent {...props} />
|
<Label htmlFor="theme-toggle">
|
||||||
</Suspense>
|
{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>
|
</Dialog>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,7 +5,6 @@ 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 {
|
||||||
@@ -330,7 +329,7 @@ export function DiagramProvider({ children }: { children: React.ReactNode }) {
|
|||||||
sessionId?: string,
|
sessionId?: string,
|
||||||
) => {
|
) => {
|
||||||
try {
|
try {
|
||||||
await fetch(getApiEndpoint("/api/log-save"), {
|
await fetch("/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 }),
|
||||||
|
|||||||
@@ -7,11 +7,6 @@ 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]
|
||||||
|
|||||||
10
env.example
10
env.example
@@ -68,10 +68,6 @@ 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"
|
||||||
@@ -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
|
# 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.
|
||||||
|
|||||||
@@ -19,7 +19,6 @@ export type ProviderName =
|
|||||||
| "openrouter"
|
| "openrouter"
|
||||||
| "deepseek"
|
| "deepseek"
|
||||||
| "siliconflow"
|
| "siliconflow"
|
||||||
| "sglang"
|
|
||||||
| "gateway"
|
| "gateway"
|
||||||
|
|
||||||
interface ModelConfig {
|
interface ModelConfig {
|
||||||
@@ -51,7 +50,6 @@ const ALLOWED_CLIENT_PROVIDERS: ProviderName[] = [
|
|||||||
"openrouter",
|
"openrouter",
|
||||||
"deepseek",
|
"deepseek",
|
||||||
"siliconflow",
|
"siliconflow",
|
||||||
"sglang",
|
|
||||||
"gateway",
|
"gateway",
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -345,7 +343,6 @@ 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
|
||||||
@@ -370,7 +367,6 @@ 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",
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -436,7 +432,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, 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
|
* - AI_MODEL: The model ID/name for the selected provider
|
||||||
*
|
*
|
||||||
* Provider-specific env vars:
|
* Provider-specific env vars:
|
||||||
@@ -452,8 +448,6 @@ 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)
|
||||||
@@ -522,7 +516,6 @@ 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 {
|
||||||
@@ -705,112 +698,6 @@ 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"
|
||||||
@@ -834,7 +721,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, sglang, gateway`,
|
`Unknown AI provider: ${provider}. Supported providers: bedrock, openai, anthropic, google, azure, ollama, openrouter, deepseek, siliconflow, gateway`,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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}`
|
|
||||||
}
|
|
||||||
@@ -14,7 +14,6 @@
|
|||||||
"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)",
|
||||||
@@ -88,8 +87,6 @@
|
|||||||
"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",
|
||||||
@@ -150,7 +147,6 @@
|
|||||||
"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.",
|
||||||
|
|||||||
@@ -14,7 +14,6 @@
|
|||||||
"about": "概要",
|
"about": "概要",
|
||||||
"editor": "エディタ",
|
"editor": "エディタ",
|
||||||
"newChat": "新しいチャットを開始",
|
"newChat": "新しいチャットを開始",
|
||||||
"github": "GitHub",
|
|
||||||
"settings": "設定",
|
"settings": "設定",
|
||||||
"hidePanel": "チャットパネルを非表示 (Ctrl+B)",
|
"hidePanel": "チャットパネルを非表示 (Ctrl+B)",
|
||||||
"showPanel": "チャットパネルを表示 (Ctrl+B)",
|
"showPanel": "チャットパネルを表示 (Ctrl+B)",
|
||||||
@@ -88,8 +87,6 @@
|
|||||||
"overrides": "上書き",
|
"overrides": "上書き",
|
||||||
"clearSettings": "設定をクリア",
|
"clearSettings": "設定をクリア",
|
||||||
"useServerDefault": "サーバーデフォルトを使用",
|
"useServerDefault": "サーバーデフォルトを使用",
|
||||||
"language": "言語",
|
|
||||||
"languageDescription": "インターフェース言語を選択します。",
|
|
||||||
"theme": "テーマ",
|
"theme": "テーマ",
|
||||||
"themeDescription": "インターフェースと DrawIO キャンバスのダーク/ライトモード。",
|
"themeDescription": "インターフェースと DrawIO キャンバスのダーク/ライトモード。",
|
||||||
"drawioStyle": "DrawIO スタイル",
|
"drawioStyle": "DrawIO スタイル",
|
||||||
@@ -150,7 +147,6 @@
|
|||||||
"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 キーを使用する(設定アイコンをクリック)か、プロジェクトをセルフホストしてこれらの制限を回避できます。",
|
||||||
|
|||||||
@@ -14,7 +14,6 @@
|
|||||||
"about": "关于",
|
"about": "关于",
|
||||||
"editor": "编辑器",
|
"editor": "编辑器",
|
||||||
"newChat": "开始新对话",
|
"newChat": "开始新对话",
|
||||||
"github": "GitHub",
|
|
||||||
"settings": "设置",
|
"settings": "设置",
|
||||||
"hidePanel": "隐藏聊天面板 (Ctrl+B)",
|
"hidePanel": "隐藏聊天面板 (Ctrl+B)",
|
||||||
"showPanel": "显示聊天面板 (Ctrl+B)",
|
"showPanel": "显示聊天面板 (Ctrl+B)",
|
||||||
@@ -88,8 +87,6 @@
|
|||||||
"overrides": "覆盖",
|
"overrides": "覆盖",
|
||||||
"clearSettings": "清除设置",
|
"clearSettings": "清除设置",
|
||||||
"useServerDefault": "使用服务器默认值",
|
"useServerDefault": "使用服务器默认值",
|
||||||
"language": "语言",
|
|
||||||
"languageDescription": "选择界面语言。",
|
|
||||||
"theme": "主题",
|
"theme": "主题",
|
||||||
"themeDescription": "界面和 DrawIO 画布的深色/浅色模式。",
|
"themeDescription": "界面和 DrawIO 画布的深色/浅色模式。",
|
||||||
"drawioStyle": "DrawIO 样式",
|
"drawioStyle": "DrawIO 样式",
|
||||||
@@ -150,7 +147,6 @@
|
|||||||
"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 密钥(点击设置图标)或自托管项目来绕过这些限制。",
|
||||||
|
|||||||
@@ -3,8 +3,6 @@
|
|||||||
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 {
|
||||||
@@ -42,8 +40,6 @@ 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)
|
||||||
@@ -225,12 +221,11 @@ 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)
|
||||||
const message = formatMessage(dict.quota.tpmMessageDetailed, {
|
toast.error(
|
||||||
limit: limitDisplay,
|
`Rate limit reached (${limitDisplay} tokens/min). Please wait 60 seconds before sending another request.`,
|
||||||
seconds: 60,
|
{ duration: 8000 },
|
||||||
})
|
)
|
||||||
toast.error(message, { duration: 8000 })
|
}, [tpmLimit])
|
||||||
}, [tpmLimit, dict])
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
// Check functions
|
// Check functions
|
||||||
|
|||||||
@@ -4,16 +4,9 @@ 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
|
||||||
|
|||||||
4
package-lock.json
generated
4
package-lock.json
generated
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "next-ai-draw-io",
|
"name": "next-ai-draw-io",
|
||||||
"version": "0.4.6",
|
"version": "0.4.5",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "next-ai-draw-io",
|
"name": "next-ai-draw-io",
|
||||||
"version": "0.4.6",
|
"version": "0.4.5",
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@ai-sdk/amazon-bedrock": "^3.0.70",
|
"@ai-sdk/amazon-bedrock": "^3.0.70",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "next-ai-draw-io",
|
"name": "next-ai-draw-io",
|
||||||
"version": "0.4.6",
|
"version": "0.4.5",
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"main": "dist-electron/main/index.js",
|
"main": "dist-electron/main/index.js",
|
||||||
|
|||||||
Reference in New Issue
Block a user