Compare commits

..

5 Commits

Author SHA1 Message Date
dayuan.jiang
5df0050334 docs: sync CN/JA READMEs with EN structure and fix all paths 2025-12-30 23:40:00 +09:00
dayuan.jiang
f63ed75465 docs: fix broken import statements in cloudflare deploy guides 2025-12-30 23:35:38 +09:00
dayuan.jiang
dcd1ee9a2e docs: update README and provider docs 2025-12-30 23:12:02 +09:00
dayuan.jiang
7b18bcaf68 docs: fix links to new docs folder structure 2025-12-30 22:52:40 +09:00
dayuan.jiang
454b86d898 docs: reorganize docs into en/cn/ja folders
- Move documentation files into language-specific folders (en, cn, ja)
- Add Chinese and Japanese translations for all docs
- Extract Docker section from README to separate doc file
- Update README to link to new doc locations
2025-12-30 22:50:51 +09:00
36 changed files with 1987 additions and 6441 deletions

View File

@@ -38,7 +38,7 @@ jobs:
cache: "npm"
- name: Install dependencies
run: npm install
run: npm ci
- name: Build and publish Electron app
run: npm run dist:${{ matrix.platform }}

View File

@@ -72,6 +72,28 @@ export default function AboutCN() {
<p className="text-xl text-gray-600 font-medium">
AI驱动的图表创建工具 -
</p>
<div className="flex justify-center gap-4 mt-4 text-sm">
<Link
href="/about"
className="text-gray-600 hover:text-blue-600"
>
English
</Link>
<span className="text-gray-400">|</span>
<Link
href="/about/cn"
className="text-blue-600 font-semibold"
>
</Link>
<span className="text-gray-400">|</span>
<Link
href="/about/ja"
className="text-gray-600 hover:text-blue-600"
>
</Link>
</div>
</div>
<div className="relative mb-8 rounded-2xl bg-gradient-to-br from-amber-50 via-orange-50 to-yellow-50 p-[1px] shadow-lg">

View File

@@ -80,6 +80,28 @@ export default function AboutJA() {
AI搭載のダイアグラム作成ツール -
</p>
<div className="flex justify-center gap-4 mt-4 text-sm">
<Link
href="/about"
className="text-gray-600 hover:text-blue-600"
>
English
</Link>
<span className="text-gray-400">|</span>
<Link
href="/about/cn"
className="text-gray-600 hover:text-blue-600"
>
</Link>
<span className="text-gray-400">|</span>
<Link
href="/about/ja"
className="text-blue-600 font-semibold"
>
</Link>
</div>
</div>
<div className="relative mb-8 rounded-2xl bg-gradient-to-br from-amber-50 via-orange-50 to-yellow-50 p-[1px] shadow-lg">

View File

@@ -80,6 +80,28 @@ export default function About() {
AI-Powered Diagram Creation Tool - Chat, Draw,
Visualize
</p>
<div className="flex justify-center gap-4 mt-4 text-sm">
<Link
href="/about"
className="text-blue-600 font-semibold"
>
English
</Link>
<span className="text-gray-400">|</span>
<Link
href="/about/cn"
className="text-gray-600 hover:text-blue-600"
>
</Link>
<span className="text-gray-400">|</span>
<Link
href="/about/ja"
className="text-gray-600 hover:text-blue-600"
>
</Link>
</div>
</div>
<div className="relative mb-8 rounded-2xl bg-gradient-to-br from-amber-50 via-orange-50 to-yellow-50 p-[1px] shadow-lg">

View File

@@ -1,6 +1,6 @@
"use client"
import { usePathname, useRouter } from "next/navigation"
import { Suspense, useCallback, useEffect, useRef, useState } from "react"
import { useCallback, useEffect, useRef, useState } from "react"
import { DrawIoEmbed } from "react-drawio"
import type { ImperativePanelHandle } from "react-resizable-panels"
import ChatPanel from "@/components/chat-panel"
@@ -17,23 +17,49 @@ const drawioBaseUrl =
process.env.NEXT_PUBLIC_DRAWIO_BASE_URL || "https://embed.diagrams.net"
export default function Home() {
const { drawioRef, handleDiagramExport, onDrawioLoad, resetDrawioReady } =
useDiagram()
const {
drawioRef,
handleDiagramExport,
onDrawioLoad,
resetDrawioReady,
saveDiagramToStorage,
showSaveDialog,
setShowSaveDialog,
} = useDiagram()
const router = useRouter()
const pathname = usePathname()
// Extract current language from pathname (e.g., "/zh/about" → "zh")
const currentLang = (pathname.split("/")[1] || i18n.defaultLocale) as Locale
const [isMobile, setIsMobile] = useState(false)
const [isChatVisible, setIsChatVisible] = useState(true)
const [drawioUi, setDrawioUi] = useState<"min" | "sketch">("min")
const [darkMode, setDarkMode] = useState(false)
const [isLoaded, setIsLoaded] = useState(false)
const [isDrawioReady, setIsDrawioReady] = useState(false)
const [closeProtection, setCloseProtection] = useState(false)
const chatPanelRef = useRef<ImperativePanelHandle>(null)
const isSavingRef = useRef(false)
const mouseOverDrawioRef = useRef(false)
const isMobileRef = useRef(false)
// Reset saving flag when dialog closes (with delay to ignore lingering save events from draw.io)
useEffect(() => {
if (!showSaveDialog) {
const timeout = setTimeout(() => {
isSavingRef.current = false
}, 1000)
return () => clearTimeout(timeout)
}
}, [showSaveDialog])
// Handle save from draw.io's built-in save button
// Note: draw.io sends save events for various reasons (focus changes, etc.)
// We use mouse position to determine if the user is interacting with draw.io
const handleDrawioSave = useCallback(() => {
if (!mouseOverDrawioRef.current) return
if (isSavingRef.current) return
isSavingRef.current = true
setShowSaveDialog(true)
}, [setShowSaveDialog])
// Load preferences from localStorage after mount
useEffect(() => {
// Restore saved locale and redirect if needed
@@ -76,29 +102,24 @@ export default function Home() {
setIsLoaded(true)
}, [pathname, router])
const handleDrawioLoad = useCallback(() => {
setIsDrawioReady(true)
onDrawioLoad()
}, [onDrawioLoad])
const handleDarkModeChange = () => {
const handleDarkModeChange = async () => {
await saveDiagramToStorage()
const newValue = !darkMode
setDarkMode(newValue)
localStorage.setItem("next-ai-draw-io-dark-mode", String(newValue))
document.documentElement.classList.toggle("dark", newValue)
setIsDrawioReady(false)
resetDrawioReady()
}
const handleDrawioUiChange = () => {
const handleDrawioUiChange = async () => {
await saveDiagramToStorage()
const newUi = drawioUi === "min" ? "sketch" : "min"
localStorage.setItem("drawio-theme", newUi)
setDrawioUi(newUi)
setIsDrawioReady(false)
resetDrawioReady()
}
// Check mobile - reset draw.io before crossing breakpoint
// Check mobile - save diagram and reset draw.io before crossing breakpoint
const isInitialRenderRef = useRef(true)
useEffect(() => {
const checkMobile = () => {
@@ -107,7 +128,7 @@ export default function Home() {
!isInitialRenderRef.current &&
newIsMobile !== isMobileRef.current
) {
setIsDrawioReady(false)
saveDiagramToStorage().catch(() => {})
resetDrawioReady()
}
isMobileRef.current = newIsMobile
@@ -118,7 +139,7 @@ export default function Home() {
checkMobile()
window.addEventListener("resize", checkMobile)
return () => window.removeEventListener("resize", checkMobile)
}, [resetDrawioReady])
}, [saveDiagramToStorage, resetDrawioReady])
const toggleChatPanel = () => {
const panel = chatPanelRef.current
@@ -176,36 +197,34 @@ export default function Home() {
className={`h-full relative ${
isMobile ? "p-1" : "p-2"
}`}
onMouseEnter={() => {
mouseOverDrawioRef.current = true
}}
onMouseLeave={() => {
mouseOverDrawioRef.current = false
}}
>
<div className="h-full rounded-xl overflow-hidden shadow-soft-lg border border-border/30 relative">
{isLoaded && (
<div
className={`h-full w-full ${isDrawioReady ? "" : "invisible absolute inset-0"}`}
>
<DrawIoEmbed
key={`${drawioUi}-${darkMode}-${currentLang}`}
ref={drawioRef}
onExport={handleDiagramExport}
onLoad={handleDrawioLoad}
baseUrl={drawioBaseUrl}
urlParameters={{
ui: drawioUi,
spin: false,
libraries: false,
saveAndExit: false,
noSaveBtn: true,
noExitBtn: true,
dark: darkMode,
lang: currentLang,
}}
/>
</div>
)}
{(!isLoaded || !isDrawioReady) && (
<div className="h-full w-full bg-background flex items-center justify-center">
<span className="text-muted-foreground">
Draw.io panel is loading...
</span>
<div className="h-full rounded-xl overflow-hidden shadow-soft-lg border border-border/30">
{isLoaded ? (
<DrawIoEmbed
key={`${drawioUi}-${darkMode}`}
ref={drawioRef}
onExport={handleDiagramExport}
onLoad={onDrawioLoad}
onSave={handleDrawioSave}
baseUrl={drawioBaseUrl}
urlParameters={{
ui: drawioUi,
spin: true,
libraries: false,
saveAndExit: false,
noExitBtn: true,
dark: darkMode,
}}
/>
) : (
<div className="h-full w-full flex items-center justify-center bg-background">
<div className="animate-spin h-8 w-8 border-4 border-primary border-t-transparent rounded-full" />
</div>
)}
</div>
@@ -228,24 +247,16 @@ export default function Home() {
onExpand={() => setIsChatVisible(true)}
>
<div className={`h-full ${isMobile ? "p-1" : "py-2 pr-2"}`}>
<Suspense
fallback={
<div className="h-full bg-card rounded-xl border border-border/30 flex items-center justify-center text-muted-foreground">
Loading chat...
</div>
}
>
<ChatPanel
isVisible={isChatVisible}
onToggleVisibility={toggleChatPanel}
drawioUi={drawioUi}
onToggleDrawioUi={handleDrawioUiChange}
darkMode={darkMode}
onToggleDarkMode={handleDarkModeChange}
isMobile={isMobile}
onCloseProtectionChange={setCloseProtection}
/>
</Suspense>
<ChatPanel
isVisible={isChatVisible}
onToggleVisibility={toggleChatPanel}
drawioUi={drawioUi}
onToggleDrawioUi={handleDrawioUiChange}
darkMode={darkMode}
onToggleDarkMode={handleDarkModeChange}
isMobile={isMobile}
onCloseProtectionChange={setCloseProtection}
/>
</div>
</ResizablePanel>
</ResizablePanelGroup>

View File

@@ -12,11 +12,7 @@ import fs from "fs/promises"
import { jsonrepair } from "jsonrepair"
import path from "path"
import { z } from "zod"
import {
getAIModel,
supportsImageInput,
supportsPromptCaching,
} from "@/lib/ai-providers"
import { getAIModel, supportsPromptCaching } from "@/lib/ai-providers"
import { findCachedResponse } from "@/lib/cached-responses"
import {
checkAndIncrementRequest,
@@ -299,17 +295,6 @@ async function handleChatRequest(req: Request): Promise<Response> {
lastUserMessage?.parts?.filter((part: any) => part.type === "file") ||
[]
// Check if user is sending images to a model that doesn't support them
// AI SDK silently drops unsupported parts, so we need to catch this early
if (fileParts.length > 0 && !supportsImageInput(modelId)) {
return Response.json(
{
error: `The model "${modelId}" does not support image input. Please use a vision-capable model (e.g., GPT-4o, Claude, Gemini) or remove the image.`,
},
{ status: 400 },
)
}
// User input only - XML is now in a separate cached system message
const formattedUserInput = `User input:
"""md

View File

@@ -74,8 +74,8 @@
--accent: oklch(0.94 0.03 280);
--accent-foreground: oklch(0.35 0.08 270);
/* Muted rose destructive */
--destructive: oklch(0.45 0.12 10);
/* Coral destructive */
--destructive: oklch(0.6 0.2 25);
/* Subtle borders */
--border: oklch(0.92 0.01 260);
@@ -122,7 +122,7 @@
--accent: oklch(0.3 0.04 280);
--accent-foreground: oklch(0.9 0.03 270);
--destructive: oklch(0.55 0.12 10);
--destructive: oklch(0.65 0.22 25);
--border: oklch(0.28 0.015 260);
--input: oklch(0.25 0.015 260);

View File

@@ -1,5 +1,5 @@
{
"$schema": "https://biomejs.dev/schemas/2.3.10/schema.json",
"$schema": "https://biomejs.dev/schemas/2.3.8/schema.json",
"vcs": {
"enabled": true,
"clientKind": "git",

View File

@@ -70,11 +70,9 @@ function ExampleCard({
export default function ExamplePanel({
setInput,
setFiles,
minimal = false,
}: {
setInput: (input: string) => void
setFiles: (files: File[]) => void
minimal?: boolean
}) {
const dict = useDictionary()
@@ -122,55 +120,49 @@ export default function ExamplePanel({
}
return (
<div className={minimal ? "" : "py-6 px-2 animate-fade-in"}>
{!minimal && (
<>
{/* MCP Server Notice */}
<a
href="https://github.com/DayuanJiang/next-ai-draw-io/tree/main/packages/mcp-server"
target="_blank"
rel="noopener noreferrer"
className="block mb-4 p-3 rounded-xl bg-gradient-to-r from-purple-500/10 to-blue-500/10 border border-purple-500/20 hover:border-purple-500/40 transition-colors group"
>
<div className="flex items-center gap-3">
<div className="w-8 h-8 rounded-lg bg-purple-500/20 flex items-center justify-center shrink-0">
<Terminal className="w-4 h-4 text-purple-500" />
</div>
<div className="min-w-0">
<div className="flex items-center gap-2">
<span className="text-sm font-medium text-foreground group-hover:text-purple-500 transition-colors">
{dict.examples.mcpServer}
</span>
<span className="px-1.5 py-0.5 text-[10px] font-semibold bg-purple-500 text-white rounded">
{dict.examples.preview}
</span>
</div>
<p className="text-xs text-muted-foreground">
{dict.examples.mcpDescription}
</p>
</div>
<div className="py-6 px-2 animate-fade-in">
{/* MCP Server Notice */}
<a
href="https://github.com/DayuanJiang/next-ai-draw-io/tree/main/packages/mcp-server"
target="_blank"
rel="noopener noreferrer"
className="block mb-4 p-3 rounded-xl bg-gradient-to-r from-purple-500/10 to-blue-500/10 border border-purple-500/20 hover:border-purple-500/40 transition-colors group"
>
<div className="flex items-center gap-3">
<div className="w-8 h-8 rounded-lg bg-purple-500/20 flex items-center justify-center shrink-0">
<Terminal className="w-4 h-4 text-purple-500" />
</div>
<div className="min-w-0">
<div className="flex items-center gap-2">
<span className="text-sm font-medium text-foreground group-hover:text-purple-500 transition-colors">
{dict.examples.mcpServer}
</span>
<span className="px-1.5 py-0.5 text-[10px] font-semibold bg-purple-500 text-white rounded">
{dict.examples.preview}
</span>
</div>
</a>
{/* Welcome section */}
<div className="text-center mb-6">
<h2 className="text-lg font-semibold text-foreground mb-2">
{dict.examples.title}
</h2>
<p className="text-sm text-muted-foreground max-w-xs mx-auto">
{dict.examples.subtitle}
<p className="text-xs text-muted-foreground">
{dict.examples.mcpDescription}
</p>
</div>
</>
)}
</div>
</a>
{/* Welcome section */}
<div className="text-center mb-6">
<h2 className="text-lg font-semibold text-foreground mb-2">
{dict.examples.title}
</h2>
<p className="text-sm text-muted-foreground max-w-xs mx-auto">
{dict.examples.subtitle}
</p>
</div>
{/* Examples grid */}
<div className="space-y-3">
{!minimal && (
<p className="text-xs font-medium text-muted-foreground uppercase tracking-wider px-1">
{dict.examples.quickExamples}
</p>
)}
<p className="text-xs font-medium text-muted-foreground uppercase tracking-wider px-1">
{dict.examples.quickExamples}
</p>
<div className="grid gap-2">
<ExampleCard

View File

@@ -6,6 +6,7 @@ import {
Image as ImageIcon,
Loader2,
Send,
Trash2,
} from "lucide-react"
import type React from "react"
import { useCallback, useEffect, useRef, useState } from "react"
@@ -14,6 +15,7 @@ import { ButtonWithTooltip } from "@/components/button-with-tooltip"
import { ErrorToast } from "@/components/error-toast"
import { HistoryDialog } from "@/components/history-dialog"
import { ModelSelector } from "@/components/model-selector"
import { ResetWarningModal } from "@/components/reset-warning-modal"
import { SaveDialog } from "@/components/save-dialog"
import { Button } from "@/components/ui/button"
@@ -138,6 +140,7 @@ interface ChatInputProps {
status: "submitted" | "streaming" | "ready" | "error"
onSubmit: (e: React.FormEvent<HTMLFormElement>) => void
onChange: (e: React.ChangeEvent<HTMLTextAreaElement>) => void
onClearChat: () => void
files?: File[]
onFileChange?: (files: File[]) => void
pdfData?: Map<
@@ -160,6 +163,7 @@ export function ChatInput({
status,
onSubmit,
onChange,
onClearChat,
files = [],
onFileChange = () => {},
pdfData = new Map(),
@@ -172,17 +176,14 @@ export function ChatInput({
onConfigureModels = () => {},
}: ChatInputProps) {
const dict = useDictionary()
const {
diagramHistory,
saveDiagramToFile,
showSaveDialog,
setShowSaveDialog,
} = useDiagram()
const { diagramHistory, saveDiagramToFile } = useDiagram()
const textareaRef = useRef<HTMLTextAreaElement>(null)
const fileInputRef = useRef<HTMLInputElement>(null)
const [isDragging, setIsDragging] = useState(false)
const [showClearDialog, setShowClearDialog] = useState(false)
const [showHistory, setShowHistory] = useState(false)
const [showSaveDialog, setShowSaveDialog] = useState(false)
// Allow retry when there's an error (even if status is still "streaming" or "submitted")
const isDisabled =
(status === "streaming" || status === "submitted") && !error
@@ -312,6 +313,11 @@ export function ChatInput({
}
}
const handleClear = () => {
onClearChat()
setShowClearDialog(false)
}
return (
<form
onSubmit={onSubmit}
@@ -347,81 +353,104 @@ export function ChatInput({
className="min-h-[60px] max-h-[200px] resize-none border-0 bg-transparent px-4 py-3 text-sm focus-visible:ring-0 focus-visible:ring-offset-0 placeholder:text-muted-foreground/60"
/>
<div className="flex items-center justify-end gap-1 px-3 py-2 border-t border-border/50">
<div className="flex items-center justify-between px-3 py-2 border-t border-border/50">
<div className="flex items-center gap-1 overflow-x-hidden">
<ButtonWithTooltip
type="button"
variant="ghost"
size="sm"
onClick={() => setShowHistory(true)}
disabled={isDisabled || diagramHistory.length === 0}
tooltipContent={dict.chat.diagramHistory}
className="h-8 w-8 p-0 text-muted-foreground hover:text-foreground"
onClick={() => setShowClearDialog(true)}
tooltipContent={dict.chat.clearConversation}
className="h-8 w-8 p-0 text-muted-foreground hover:text-destructive hover:bg-destructive/10"
>
<History className="h-4 w-4" />
<Trash2 className="h-4 w-4" />
</ButtonWithTooltip>
<ButtonWithTooltip
type="button"
variant="ghost"
size="sm"
onClick={() => setShowSaveDialog(true)}
disabled={isDisabled}
tooltipContent={dict.chat.saveDiagram}
className="h-8 w-8 p-0 text-muted-foreground hover:text-foreground"
>
<Download className="h-4 w-4" />
</ButtonWithTooltip>
<ButtonWithTooltip
type="button"
variant="ghost"
size="sm"
onClick={triggerFileInput}
disabled={isDisabled}
tooltipContent={dict.chat.uploadFile}
className="h-8 w-8 p-0 text-muted-foreground hover:text-foreground"
>
<ImageIcon className="h-4 w-4" />
</ButtonWithTooltip>
<input
type="file"
ref={fileInputRef}
className="hidden"
onChange={handleFileChange}
accept="image/*,.pdf,application/pdf,text/*,.md,.markdown,.json,.csv,.xml,.yaml,.yml,.toml"
multiple
disabled={isDisabled}
<ResetWarningModal
open={showClearDialog}
onOpenChange={setShowClearDialog}
onClear={handleClear}
/>
</div>
<ModelSelector
models={models}
selectedModelId={selectedModelId}
onSelect={onModelSelect}
onConfigure={onConfigureModels}
disabled={isDisabled}
showUnvalidatedModels={showUnvalidatedModels}
/>
<div className="w-px h-5 bg-border mx-1" />
<Button
type="submit"
disabled={isDisabled || !input.trim()}
size="sm"
className="h-8 px-4 rounded-xl font-medium shadow-sm"
aria-label={
isDisabled ? dict.chat.sending : dict.chat.send
}
>
{isDisabled ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<>
<Send className="h-4 w-4 mr-1.5" />
{dict.chat.send}
</>
)}
</Button>
<div className="flex items-center gap-1 overflow-hidden justify-end">
<div className="flex items-center gap-1 overflow-x-hidden">
<ButtonWithTooltip
type="button"
variant="ghost"
size="sm"
onClick={() => setShowHistory(true)}
disabled={
isDisabled || diagramHistory.length === 0
}
tooltipContent={dict.chat.diagramHistory}
className="h-8 w-8 p-0 text-muted-foreground hover:text-foreground"
>
<History className="h-4 w-4" />
</ButtonWithTooltip>
<ButtonWithTooltip
type="button"
variant="ghost"
size="sm"
onClick={() => setShowSaveDialog(true)}
disabled={isDisabled}
tooltipContent={dict.chat.saveDiagram}
className="h-8 w-8 p-0 text-muted-foreground hover:text-foreground"
>
<Download className="h-4 w-4" />
</ButtonWithTooltip>
<ButtonWithTooltip
type="button"
variant="ghost"
size="sm"
onClick={triggerFileInput}
disabled={isDisabled}
tooltipContent={dict.chat.uploadFile}
className="h-8 w-8 p-0 text-muted-foreground hover:text-foreground"
>
<ImageIcon className="h-4 w-4" />
</ButtonWithTooltip>
<input
type="file"
ref={fileInputRef}
className="hidden"
onChange={handleFileChange}
accept="image/*,.pdf,application/pdf,text/*,.md,.markdown,.json,.csv,.xml,.yaml,.yml,.toml"
multiple
disabled={isDisabled}
/>
</div>
<ModelSelector
models={models}
selectedModelId={selectedModelId}
onSelect={onModelSelect}
onConfigure={onConfigureModels}
disabled={isDisabled}
showUnvalidatedModels={showUnvalidatedModels}
/>
<div className="w-px h-5 bg-border mx-1" />
<Button
type="submit"
disabled={isDisabled || !input.trim()}
size="sm"
className="h-8 px-4 rounded-xl font-medium shadow-sm"
aria-label={
isDisabled ? dict.chat.sending : dict.chat.send
}
>
{isDisabled ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<>
<Send className="h-4 w-4 mr-1.5" />
{dict.chat.send}
</>
)}
</Button>
</div>
</div>
</div>
<HistoryDialog
@@ -432,12 +461,7 @@ export function ChatInput({
open={showSaveDialog}
onOpenChange={setShowSaveDialog}
onSave={(filename, format) =>
saveDiagramToFile(
filename,
format,
sessionId,
dict.save.savedSuccessfully,
)
saveDiagramToFile(filename, format, sessionId)
}
defaultFilename={`diagram-${new Date()
.toISOString()

View File

@@ -7,6 +7,7 @@ import {
ChevronDown,
ChevronUp,
Copy,
Cpu,
FileCode,
FileText,
Pencil,
@@ -25,9 +26,6 @@ import {
ReasoningContent,
ReasoningTrigger,
} from "@/components/ai-elements/reasoning"
import { ChatLobby } from "@/components/chat/ChatLobby"
import { ToolCallCard } from "@/components/chat/ToolCallCard"
import type { DiagramOperation, ToolPartLike } from "@/components/chat/types"
import { ScrollArea } from "@/components/ui/scroll-area"
import { useDictionary } from "@/hooks/use-dictionary"
import { getApiEndpoint } from "@/lib/base-path"
@@ -35,10 +33,18 @@ import {
applyDiagramOperations,
convertToLegalXml,
extractCompleteMxCells,
isMxCellXmlComplete,
replaceNodes,
validateAndFixXml,
} from "@/lib/utils"
import ExamplePanel from "./chat-example-panel"
import { CodeBlock } from "./code-block"
interface DiagramOperation {
operation: "update" | "add" | "delete"
cell_id: string
new_xml?: string
}
// Helper to extract complete operations from streaming input
function getCompleteOperations(
@@ -52,10 +58,60 @@ function getCompleteOperations(
["update", "add", "delete"].includes(op.operation) &&
typeof op.cell_id === "string" &&
op.cell_id.length > 0 &&
// delete doesn't need new_xml, update/add do
(op.operation === "delete" || typeof op.new_xml === "string"),
)
}
// Tool part interface for type safety
interface ToolPartLike {
type: string
toolCallId: string
state?: string
input?: {
xml?: string
operations?: DiagramOperation[]
} & Record<string, unknown>
output?: string
}
function OperationsDisplay({ operations }: { operations: DiagramOperation[] }) {
return (
<div className="space-y-3">
{operations.map((op, index) => (
<div
key={`${op.operation}-${op.cell_id}-${index}`}
className="rounded-lg border border-border/50 overflow-hidden bg-background/50"
>
<div className="px-3 py-1.5 bg-muted/40 border-b border-border/30 flex items-center gap-2">
<span
className={`text-[10px] font-medium uppercase tracking-wide ${
op.operation === "delete"
? "text-red-600"
: op.operation === "add"
? "text-green-600"
: "text-blue-600"
}`}
>
{op.operation}
</span>
<span className="text-xs text-muted-foreground">
cell_id: {op.cell_id}
</span>
</div>
{op.new_xml && (
<div className="px-3 py-2">
<pre className="text-[11px] font-mono text-foreground/80 bg-muted/30 rounded px-2 py-1.5 overflow-x-auto whitespace-pre-wrap break-all">
{op.new_xml}
</pre>
</div>
)}
</div>
))}
</div>
)
}
import { useDiagram } from "@/contexts/diagram-context"
// Helper to split text content into regular text and file sections (PDF or text files)
@@ -127,13 +183,6 @@ const getUserOriginalText = (message: UIMessage): string => {
return fullText.replace(filePattern, "").trim()
}
interface SessionMetadata {
id: string
title: string
updatedAt: number
thumbnailDataUrl?: string
}
interface ChatMessageDisplayProps {
messages: UIMessage[]
setInput: (input: string) => void
@@ -144,11 +193,6 @@ interface ChatMessageDisplayProps {
onRegenerate?: (messageIndex: number) => void
onEditMessage?: (messageIndex: number, newText: string) => void
status?: "streaming" | "submitted" | "idle" | "error" | "ready"
isRestored?: boolean
sessions?: SessionMetadata[]
onSelectSession?: (id: string) => void
onDeleteSession?: (id: string) => void
loadedMessageIdsRef?: MutableRefObject<Set<string>>
}
export function ChatMessageDisplay({
@@ -161,33 +205,14 @@ export function ChatMessageDisplay({
onRegenerate,
onEditMessage,
status = "idle",
isRestored = false,
sessions = [],
onSelectSession,
onDeleteSession,
loadedMessageIdsRef,
}: ChatMessageDisplayProps) {
const dict = useDictionary()
const { chartXML, loadDiagram: onDisplayChart } = useDiagram()
const messagesEndRef = useRef<HTMLDivElement>(null)
const scrollTopRef = useRef<HTMLDivElement>(null)
const previousXML = useRef<string>("")
const processedToolCalls = processedToolCallsRef
// Track the last processed XML per toolCallId to skip redundant processing during streaming
const lastProcessedXmlRef = useRef<Map<string, string>>(new Map())
// Reset refs when messages become empty (new chat or session switch)
// This ensures cached examples work correctly after starting a new session
useEffect(() => {
if (messages.length === 0) {
previousXML.current = ""
lastProcessedXmlRef.current.clear()
// Note: processedToolCalls is passed from parent, so we clear it too
processedToolCalls.current.clear()
// Scroll to top to show newest history items
scrollTopRef.current?.scrollIntoView({ behavior: "instant" })
}
}, [messages.length, processedToolCalls])
// Debounce streaming diagram updates - store pending XML and timeout
const pendingXmlRef = useRef<string | null>(null)
const debounceTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(
@@ -205,12 +230,6 @@ export function ChatMessageDisplay({
const [expandedTools, setExpandedTools] = useState<Record<string, boolean>>(
{},
)
const [copiedToolCallId, setCopiedToolCallId] = useState<string | null>(
null,
)
const [copyFailedToolCallId, setCopyFailedToolCallId] = useState<
string | null
>(null)
const [copiedMessageId, setCopiedMessageId] = useState<string | null>(null)
const [copyFailedMessageId, setCopyFailedMessageId] = useState<
string | null
@@ -226,39 +245,13 @@ export function ChatMessageDisplay({
Record<string, boolean>
>({})
const setCopyState = (
messageId: string,
isToolCall: boolean,
isSuccess: boolean,
) => {
if (isSuccess) {
if (isToolCall) {
setCopiedToolCallId(messageId)
setTimeout(() => setCopiedToolCallId(null), 2000)
} else {
setCopiedMessageId(messageId)
setTimeout(() => setCopiedMessageId(null), 2000)
}
} else {
if (isToolCall) {
setCopyFailedToolCallId(messageId)
setTimeout(() => setCopyFailedToolCallId(null), 2000)
} else {
setCopyFailedMessageId(messageId)
setTimeout(() => setCopyFailedMessageId(null), 2000)
}
}
}
const copyMessageToClipboard = async (
messageId: string,
text: string,
isToolCall = false,
) => {
const copyMessageToClipboard = async (messageId: string, text: string) => {
try {
await navigator.clipboard.writeText(text)
setCopyState(messageId, isToolCall, true)
} catch (_err) {
setCopiedMessageId(messageId)
setTimeout(() => setCopiedMessageId(null), 2000)
} catch (err) {
// Fallback for non-secure contexts (HTTP) or permission denied
const textarea = document.createElement("textarea")
textarea.value = text
@@ -273,11 +266,13 @@ export function ChatMessageDisplay({
if (!success) {
throw new Error("Copy command failed")
}
setCopyState(messageId, isToolCall, true)
setCopiedMessageId(messageId)
setTimeout(() => setCopiedMessageId(null), 2000)
} catch (fallbackErr) {
console.error("Failed to copy message:", fallbackErr)
toast.error(dict.chat.failedToCopyDetail)
setCopyState(messageId, isToolCall, false)
setCopyFailedMessageId(messageId)
setTimeout(() => setCopyFailedMessageId(null), 2000)
} finally {
document.body.removeChild(textarea)
}
@@ -322,6 +317,7 @@ export function ChatMessageDisplay({
const handleDisplayChart = useCallback(
(xml: string, showToast = false) => {
let currentXml = xml || ""
const startTime = performance.now()
// During streaming (showToast=false), extract only complete mxCell elements
// This allows progressive rendering even with partial/incomplete trailing XML
@@ -345,8 +341,14 @@ export function ChatMessageDisplay({
const parseError = testDoc.querySelector("parsererror")
if (parseError) {
// Only show toast if this is the final XML (not during streaming)
// Use console.warn instead of console.error to avoid triggering
// Next.js dev mode error overlay for expected streaming states
// (partial XML during streaming is normal and will be fixed by subsequent updates)
if (showToast) {
// Only log as error and show toast if this is the final XML
console.error(
"[ChatMessageDisplay] Malformed XML detected in final output",
)
toast.error(dict.errors.malformedXml)
}
return // Skip this update
@@ -360,12 +362,18 @@ export function ChatMessageDisplay({
`<mxfile><diagram name="Page-1" id="page-1"><mxGraphModel><root><mxCell id="0"/><mxCell id="1" parent="0"/></root></mxGraphModel></diagram></mxfile>`
const replacedXML = replaceNodes(baseXML, convertedXml)
const xmlProcessTime = performance.now() - startTime
// During streaming (showToast=false), skip heavy validation for lower latency
// The quick DOM parse check above catches malformed XML
// Full validation runs on final output (showToast=true)
if (!showToast) {
previousXML.current = convertedXml
const loadStartTime = performance.now()
onDisplayChart(replacedXML, true)
console.log(
`[Streaming] XML processing: ${xmlProcessTime.toFixed(1)}ms, drawio load: ${(performance.now() - loadStartTime).toFixed(1)}ms`,
)
return
}
@@ -375,12 +383,30 @@ export function ChatMessageDisplay({
previousXML.current = convertedXml
// Use fixed XML if available, otherwise use original
const xmlToLoad = validation.fixed || replacedXML
if (validation.fixes.length > 0) {
console.log(
"[ChatMessageDisplay] Auto-fixed XML issues:",
validation.fixes,
)
}
// Skip validation in loadDiagram since we already validated above
const loadStartTime = performance.now()
onDisplayChart(xmlToLoad, true)
console.log(
`[Final] XML processing: ${xmlProcessTime.toFixed(1)}ms, validation+load: ${(performance.now() - loadStartTime).toFixed(1)}ms`,
)
} else {
console.error(
"[ChatMessageDisplay] XML validation failed:",
validation.error,
)
toast.error(dict.errors.validationFailed)
}
} catch (error) {
console.error("Error processing XML:", error)
console.error(
"[ChatMessageDisplay] Error processing XML:",
error,
)
// Only show toast if this is the final XML (not during streaming)
if (showToast) {
toast.error(dict.errors.failedToProcess)
@@ -391,22 +417,8 @@ export function ChatMessageDisplay({
[chartXML, onDisplayChart],
)
// Track previous message count to detect bulk loads vs streaming
const prevMessageCountRef = useRef(0)
useEffect(() => {
if (messagesEndRef.current && messages.length > 0) {
const prevCount = prevMessageCountRef.current
const currentCount = messages.length
prevMessageCountRef.current = currentCount
// Bulk load (session restore) - instant scroll, no animation
if (prevCount === 0 || currentCount - prevCount > 1) {
messagesEndRef.current.scrollIntoView({ behavior: "instant" })
return
}
// Single message added - smooth scroll
if (messagesEndRef.current) {
messagesEndRef.current.scrollIntoView({ behavior: "smooth" })
}
}, [messages])
@@ -624,19 +636,149 @@ export function ChatMessageDisplay({
// Let the timeouts complete naturally - they're harmless if component unmounts.
}, [messages, handleDisplayChart, chartXML])
const renderToolPart = (part: ToolPartLike) => {
const callId = part.toolCallId
const { state, input, output } = part
const isExpanded = expandedTools[callId] ?? true
const toolName = part.type?.replace("tool-", "")
const toggleExpanded = () => {
setExpandedTools((prev) => ({
...prev,
[callId]: !isExpanded,
}))
}
const getToolDisplayName = (name: string) => {
switch (name) {
case "display_diagram":
return "Generate Diagram"
case "edit_diagram":
return "Edit Diagram"
case "get_shape_library":
return "Get Shape Library"
default:
return name
}
}
return (
<div
key={callId}
className="my-3 rounded-xl border border-border/60 bg-muted/30 overflow-hidden"
>
<div className="flex items-center justify-between px-4 py-3 bg-muted/50">
<div className="flex items-center gap-2">
<div className="w-6 h-6 rounded-md bg-primary/10 flex items-center justify-center">
<Cpu className="w-3.5 h-3.5 text-primary" />
</div>
<span className="text-sm font-medium text-foreground/80">
{getToolDisplayName(toolName)}
</span>
</div>
<div className="flex items-center gap-2">
{state === "input-streaming" && (
<div className="h-4 w-4 border-2 border-primary border-t-transparent rounded-full animate-spin" />
)}
{state === "output-available" && (
<span className="text-xs font-medium text-green-600 bg-green-50 px-2 py-0.5 rounded-full">
Complete
</span>
)}
{state === "output-error" &&
(() => {
// Check if this is a truncation (incomplete XML) vs real error
const isTruncated =
(toolName === "display_diagram" ||
toolName === "append_diagram") &&
!isMxCellXmlComplete(input?.xml)
return isTruncated ? (
<span className="text-xs font-medium text-yellow-600 bg-yellow-50 px-2 py-0.5 rounded-full">
Truncated
</span>
) : (
<span className="text-xs font-medium text-red-600 bg-red-50 px-2 py-0.5 rounded-full">
Error
</span>
)
})()}
{input && Object.keys(input).length > 0 && (
<button
type="button"
onClick={toggleExpanded}
className="p-1 rounded hover:bg-muted transition-colors"
>
{isExpanded ? (
<ChevronUp className="w-4 h-4 text-muted-foreground" />
) : (
<ChevronDown className="w-4 h-4 text-muted-foreground" />
)}
</button>
)}
</div>
</div>
{input && isExpanded && (
<div className="px-4 py-3 border-t border-border/40 bg-muted/20">
{typeof input === "object" && input.xml ? (
<CodeBlock code={input.xml} language="xml" />
) : typeof input === "object" &&
input.operations &&
Array.isArray(input.operations) ? (
<OperationsDisplay operations={input.operations} />
) : typeof input === "object" &&
Object.keys(input).length > 0 ? (
<CodeBlock
code={JSON.stringify(input, null, 2)}
language="json"
/>
) : null}
</div>
)}
{output &&
state === "output-error" &&
(() => {
const isTruncated =
(toolName === "display_diagram" ||
toolName === "append_diagram") &&
!isMxCellXmlComplete(input?.xml)
return (
<div
className={`px-4 py-3 border-t border-border/40 text-sm ${isTruncated ? "text-yellow-600" : "text-red-600"}`}
>
{isTruncated
? "Output truncated due to length limits. Try a simpler request or increase the maxOutputLength."
: output}
</div>
)
})()}
{/* Show get_shape_library output on success */}
{output &&
toolName === "get_shape_library" &&
state === "output-available" &&
isExpanded && (
<div className="px-4 py-3 border-t border-border/40">
<div className="text-xs text-muted-foreground mb-2">
Library loaded (
{typeof output === "string" ? output.length : 0}{" "}
chars)
</div>
<pre className="text-xs bg-muted/50 p-2 rounded-md overflow-auto max-h-32 whitespace-pre-wrap">
{typeof output === "string"
? output.substring(0, 800) +
(output.length > 800 ? "\n..." : "")
: String(output)}
</pre>
</div>
)}
</div>
)
}
return (
<ScrollArea className="h-full w-full scrollbar-thin">
<div ref={scrollTopRef} />
{messages.length === 0 && isRestored ? (
<ChatLobby
sessions={sessions}
onSelectSession={onSelectSession || (() => {})}
onDeleteSession={onDeleteSession}
setInput={setInput}
setFiles={setFiles}
dict={dict}
/>
) : messages.length === 0 ? null : (
{messages.length === 0 ? (
<ExamplePanel setInput={setInput} setFiles={setFiles} />
) : (
<div className="py-4 px-4 space-y-4">
{messages.map((message, messageIndex) => {
const userMessageText =
@@ -656,21 +798,13 @@ export function ChatMessageDisplay({
.slice(messageIndex + 1)
.every((m) => m.role !== "user"))
const isEditing = editingMessageId === message.id
// Skip animation for loaded messages (from session restore)
const isRestoredMessage =
loadedMessageIdsRef?.current.has(message.id) ??
false
return (
<div
key={message.id}
className={`flex w-full ${message.role === "user" ? "justify-end" : "justify-start"} ${isRestoredMessage ? "" : "animate-message-in"}`}
style={
isRestoredMessage
? undefined
: {
animationDelay: `${messageIndex * 50}ms`,
}
}
className={`flex w-full ${message.role === "user" ? "justify-end" : "justify-start"} animate-message-in`}
style={{
animationDelay: `${messageIndex * 50}ms`,
}}
>
{message.role === "user" &&
userMessageText &&
@@ -767,9 +901,6 @@ export function ChatMessageDisplay({
isStreaming={
isStreamingReasoning
}
defaultOpen={
!isRestoredMessage
}
>
<ReasoningTrigger />
<ReasoningContent>
@@ -912,30 +1043,9 @@ export function ChatMessageDisplay({
return groups.map(
(group, groupIndex) => {
if (group.type === "tool") {
return (
<ToolCallCard
key={`${message.id}-tool-${group.startIndex}`}
part={
group
.parts[0] as ToolPartLike
}
expandedTools={
expandedTools
}
setExpandedTools={
setExpandedTools
}
onCopy={
copyMessageToClipboard
}
copiedToolCallId={
copiedToolCallId
}
copyFailedToolCallId={
copyFailedToolCallId
}
dict={dict}
/>
return renderToolPart(
group
.parts[0] as ToolPartLike,
)
}

View File

@@ -3,45 +3,42 @@
import { useChat } from "@ai-sdk/react"
import { DefaultChatTransport } from "ai"
import {
AlertTriangle,
MessageSquarePlus,
PanelRightClose,
PanelRightOpen,
Settings,
} from "lucide-react"
import Image from "next/image"
import { useRouter, useSearchParams } from "next/navigation"
import Link from "next/link"
import type React from "react"
import {
useCallback,
useEffect,
useLayoutEffect,
useRef,
useState,
} from "react"
import { useCallback, useEffect, useRef, useState } from "react"
import { flushSync } from "react-dom"
import { Toaster, toast } from "sonner"
import { ButtonWithTooltip } from "@/components/button-with-tooltip"
import { ChatInput } from "@/components/chat-input"
import { ModelConfigDialog } from "@/components/model-config-dialog"
import { ResetWarningModal } from "@/components/reset-warning-modal"
import { SettingsDialog } from "@/components/settings-dialog"
import { useDiagram } from "@/contexts/diagram-context"
import { useDiagramToolHandlers } from "@/hooks/use-diagram-tool-handlers"
import { useDictionary } from "@/hooks/use-dictionary"
import { getSelectedAIConfig, useModelConfig } from "@/hooks/use-model-config"
import { useSessionManager } from "@/hooks/use-session-manager"
import { getApiEndpoint } from "@/lib/base-path"
import { findCachedResponse } from "@/lib/cached-responses"
import { formatMessage } from "@/lib/i18n/utils"
import { isPdfFile, isTextFile } from "@/lib/pdf-utils"
import { sanitizeMessages } from "@/lib/session-storage"
import { type FileData, useFileProcessor } from "@/lib/use-file-processor"
import { useQuotaManager } from "@/lib/use-quota-manager"
import { cn, formatXML, isRealDiagram } from "@/lib/utils"
import { formatXML } from "@/lib/utils"
import { ChatMessageDisplay } from "./chat-message-display"
import { DevXmlSimulator } from "./dev-xml-simulator"
// localStorage keys for persistence
const STORAGE_MESSAGES_KEY = "next-ai-draw-io-messages"
const STORAGE_XML_SNAPSHOTS_KEY = "next-ai-draw-io-xml-snapshots"
const STORAGE_SESSION_ID_KEY = "next-ai-draw-io-session-id"
export const STORAGE_DIAGRAM_XML_KEY = "next-ai-draw-io-diagram-xml"
// sessionStorage keys
const SESSION_STORAGE_INPUT_KEY = "next-ai-draw-io-input"
@@ -118,17 +115,10 @@ export default function ChatPanel({
handleExportWithoutHistory,
resolverRef,
chartXML,
latestSvg,
clearDiagram,
getThumbnailSvg,
diagramHistory,
setDiagramHistory,
} = useDiagram()
const dict = useDictionary()
const router = useRouter()
const searchParams = useSearchParams()
const urlSessionId = searchParams.get("session")
const onFetchChart = (saveToHistory = true) => {
return Promise.race([
@@ -164,14 +154,11 @@ export default function ChatPanel({
// Model configuration hook
const modelConfig = useModelConfig()
// Session manager for chat history (pass URL session ID for restoration)
const sessionManager = useSessionManager({ initialSessionId: urlSessionId })
const [input, setInput] = useState("")
const [dailyRequestLimit, setDailyRequestLimit] = useState(0)
const [dailyTokenLimit, setDailyTokenLimit] = useState(0)
const [tpmLimit, setTpmLimit] = useState(0)
const [showNewChatDialog, setShowNewChatDialog] = useState(false)
const [minimalStyle, setMinimalStyle] = useState(false)
// Restore input from sessionStorage on mount (when ChatPanel remounts due to key change)
@@ -216,37 +203,13 @@ export default function ChatPanel({
// Flag to track if we've restored from localStorage
const hasRestoredRef = useRef(false)
const [isRestored, setIsRestored] = useState(false)
// Track previous isVisible to only animate when toggling (not on page load)
const prevIsVisibleRef = useRef(isVisible)
const [shouldAnimatePanel, setShouldAnimatePanel] = useState(false)
useEffect(() => {
// Only animate when visibility changes from false to true (not on initial load)
if (!prevIsVisibleRef.current && isVisible) {
setShouldAnimatePanel(true)
}
prevIsVisibleRef.current = isVisible
}, [isVisible])
// Ref to track latest chartXML for use in callbacks (avoids stale closure)
const chartXMLRef = useRef(chartXML)
// Track session ID that was loaded without a diagram (to prevent thumbnail contamination)
const justLoadedSessionIdRef = useRef<string | null>(null)
useEffect(() => {
chartXMLRef.current = chartXML
// Clear the no-diagram flag when a diagram is generated
if (chartXML) {
justLoadedSessionIdRef.current = null
}
}, [chartXML])
// Ref to track latest SVG for thumbnail generation
const latestSvgRef = useRef(latestSvg)
useEffect(() => {
latestSvgRef.current = latestSvg
}, [latestSvg])
// Ref to track consecutive auto-retry count (reset on user action)
const autoRetryCountRef = useRef(0)
// Ref to track continuation retry count (for truncation handling)
@@ -328,6 +291,32 @@ export default function ChatPanel({
// Silence access code error in console since it's handled by UI
if (!error.message.includes("Invalid or missing access code")) {
console.error("Chat error:", error)
// Debug: Log messages structure when error occurs
console.log("[onError] messages count:", messages.length)
messages.forEach((msg, idx) => {
console.log(`[onError] Message ${idx}:`, {
role: msg.role,
partsCount: msg.parts?.length,
})
if (msg.parts) {
msg.parts.forEach((part: any, partIdx: number) => {
console.log(
`[onError] Part ${partIdx}:`,
JSON.stringify({
type: part.type,
toolName: part.toolName,
hasInput: !!part.input,
inputType: typeof part.input,
inputKeys:
part.input &&
typeof part.input === "object"
? Object.keys(part.input)
: null,
}),
)
})
}
})
}
// Translate technical errors into user-friendly messages
@@ -348,10 +337,7 @@ export default function ChatPanel({
}
// Translate image not supported error
if (
friendlyMessage.includes("image content block") ||
friendlyMessage.toLowerCase().includes("image_url")
) {
if (friendlyMessage.includes("image content block")) {
friendlyMessage = "This model doesn't support image input."
}
@@ -373,7 +359,15 @@ export default function ChatPanel({
setShowSettingsDialog(true)
}
},
onFinish: () => {},
onFinish: ({ message }) => {
// Track actual token usage from server metadata
const metadata = message?.metadata as
| Record<string, unknown>
| undefined
// DEBUG: Log finish reason to diagnose truncation
console.log("[onFinish] finishReason:", metadata?.finishReason)
},
sendAutomaticallyWhen: ({ messages }) => {
const isInContinuationMode = partialXmlRef.current.length > 0
@@ -431,199 +425,58 @@ export default function ChatPanel({
messagesRef.current = messages
}, [messages])
// Track last synced session ID to detect external changes (e.g., URL back/forward)
const lastSyncedSessionIdRef = useRef<string | null>(null)
const messagesEndRef = useRef<HTMLDivElement>(null)
// Helper: Sync UI state with session data (eliminates duplication)
// Track message IDs that are being loaded from session (to skip animations/scroll)
const loadedMessageIdsRef = useRef<Set<string>>(new Set())
// Track when session was just loaded (to skip auto-save on load)
const justLoadedSessionRef = useRef(false)
const syncUIWithSession = useCallback(
(
data: {
messages: unknown[]
xmlSnapshots: [number, string][]
diagramXml: string
diagramHistory?: { svg: string; xml: string }[]
} | null,
) => {
const hasRealDiagram = isRealDiagram(data?.diagramXml)
if (data) {
// Mark all message IDs as loaded from session
const messageIds = (data.messages as any[]).map(
(m: any) => m.id,
)
loadedMessageIdsRef.current = new Set(messageIds)
setMessages(data.messages as any)
xmlSnapshotsRef.current = new Map(data.xmlSnapshots)
if (hasRealDiagram) {
onDisplayChart(data.diagramXml, true)
chartXMLRef.current = data.diagramXml
} else {
clearDiagram()
// Clear refs to prevent stale data from being saved
chartXMLRef.current = ""
latestSvgRef.current = ""
}
setDiagramHistory(data.diagramHistory || [])
} else {
loadedMessageIdsRef.current = new Set()
setMessages([])
xmlSnapshotsRef.current.clear()
clearDiagram()
// Clear refs to prevent stale data from being saved
chartXMLRef.current = ""
latestSvgRef.current = ""
setDiagramHistory([])
}
},
[setMessages, onDisplayChart, clearDiagram, setDiagramHistory],
)
// Helper: Build session data object for saving (eliminates duplication)
const buildSessionData = useCallback(
async (options: { withThumbnail?: boolean } = {}) => {
const currentDiagramXml = chartXMLRef.current || ""
// Only capture thumbnail if there's a meaningful diagram (not just empty template)
const hasRealDiagram = isRealDiagram(currentDiagramXml)
let thumbnailDataUrl: string | undefined
if (hasRealDiagram && options.withThumbnail) {
const freshThumb = await getThumbnailSvg()
if (freshThumb) {
latestSvgRef.current = freshThumb
thumbnailDataUrl = freshThumb
} else if (latestSvgRef.current) {
// Use cached thumbnail only if we have a real diagram
thumbnailDataUrl = latestSvgRef.current
}
}
return {
messages: sanitizeMessages(messagesRef.current),
xmlSnapshots: Array.from(xmlSnapshotsRef.current.entries()),
diagramXml: currentDiagramXml,
thumbnailDataUrl,
diagramHistory,
}
},
[diagramHistory, getThumbnailSvg],
)
// Restore messages and XML snapshots from session manager on mount
// This effect syncs with the session manager's loaded session
useLayoutEffect(() => {
// Restore messages and XML snapshots from localStorage on mount
useEffect(() => {
if (hasRestoredRef.current) return
if (sessionManager.isLoading) return // Wait for session manager to load
hasRestoredRef.current = true
try {
const currentSession = sessionManager.currentSession
if (currentSession && currentSession.messages.length > 0) {
// Restore from session manager (IndexedDB)
justLoadedSessionRef.current = true
syncUIWithSession(currentSession)
// Restore messages
const savedMessages = localStorage.getItem(STORAGE_MESSAGES_KEY)
if (savedMessages) {
const parsed = JSON.parse(savedMessages)
if (Array.isArray(parsed) && parsed.length > 0) {
setMessages(parsed)
}
}
// Restore XML snapshots
const savedSnapshots = localStorage.getItem(
STORAGE_XML_SNAPSHOTS_KEY,
)
if (savedSnapshots) {
const parsed = JSON.parse(savedSnapshots)
xmlSnapshotsRef.current = new Map(parsed)
}
// Initialize lastSyncedSessionIdRef to prevent sync effect from firing immediately
lastSyncedSessionIdRef.current = sessionManager.currentSessionId
// Note: Migration from old localStorage format is handled by session-storage.ts
} catch (error) {
console.error("Failed to restore session:", error)
console.error("Failed to restore from localStorage:", error)
// On complete failure, clear storage to allow recovery
localStorage.removeItem(STORAGE_MESSAGES_KEY)
localStorage.removeItem(STORAGE_XML_SNAPSHOTS_KEY)
toast.error(dict.errors.sessionCorrupted)
} finally {
setIsRestored(true)
}
}, [
sessionManager.isLoading,
sessionManager.currentSession,
syncUIWithSession,
dict.errors.sessionCorrupted,
])
// Sync UI when session changes externally (e.g., URL navigation via back/forward)
// This handles changes AFTER initial restore
useEffect(() => {
if (!isRestored) return // Wait for initial restore to complete
if (!sessionManager.isAvailable) return
const newSessionId = sessionManager.currentSessionId
const newSession = sessionManager.currentSession
// Skip if session ID hasn't changed (our own saves don't change the ID)
if (newSessionId === lastSyncedSessionIdRef.current) return
// Update last synced ID
lastSyncedSessionIdRef.current = newSessionId
// Sync UI with new session
if (newSession && newSession.messages.length > 0) {
justLoadedSessionRef.current = true
syncUIWithSession(newSession)
} else if (!newSession) {
syncUIWithSession(null)
}
}, [
isRestored,
sessionManager.isAvailable,
sessionManager.currentSessionId,
sessionManager.currentSession,
syncUIWithSession,
])
// Save messages to session manager (debounced, only when not streaming)
// Destructure stable values to avoid effect re-running on every render
const {
isAvailable: sessionIsAvailable,
currentSessionId,
saveCurrentSession,
} = sessionManager
// Use ref for saveCurrentSession to avoid infinite loop
// (saveCurrentSession changes after each save, which would re-trigger the effect)
const saveCurrentSessionRef = useRef(saveCurrentSession)
saveCurrentSessionRef.current = saveCurrentSession
}, [setMessages])
// Save messages to localStorage whenever they change (debounced to prevent blocking during streaming)
useEffect(() => {
if (!hasRestoredRef.current) return
if (!sessionIsAvailable) return
// Only save when not actively streaming to avoid write storms
if (status === "streaming" || status === "submitted") return
// Skip auto-save if session was just loaded (to prevent re-ordering)
if (justLoadedSessionRef.current) {
justLoadedSessionRef.current = false
return
}
// Clear any pending save
if (localStorageDebounceRef.current) {
clearTimeout(localStorageDebounceRef.current)
}
// Capture current session ID at schedule time to verify at save time
const scheduledForSessionId = currentSessionId
// Capture whether there's a REAL diagram NOW (not just empty template)
const hasDiagramNow = isRealDiagram(chartXMLRef.current)
// Check if this session was just loaded without a diagram
const isNodiagramSession =
justLoadedSessionIdRef.current === scheduledForSessionId
// Debounce: save after 1 second of no changes
localStorageDebounceRef.current = setTimeout(async () => {
localStorageDebounceRef.current = setTimeout(() => {
try {
if (messages.length > 0) {
const sessionData = await buildSessionData({
// Only capture thumbnail if there was a diagram AND this isn't a no-diagram session
withThumbnail: hasDiagramNow && !isNodiagramSession,
})
await saveCurrentSessionRef.current(
sessionData,
scheduledForSessionId,
)
}
localStorage.setItem(
STORAGE_MESSAGES_KEY,
JSON.stringify(messages),
)
} catch (error) {
console.error("Failed to save session:", error)
console.error("Failed to save messages to localStorage:", error)
}
}, LOCAL_STORAGE_DEBOUNCE_MS)
@@ -633,62 +486,63 @@ export default function ChatPanel({
clearTimeout(localStorageDebounceRef.current)
}
}
}, [
messages,
status,
sessionIsAvailable,
currentSessionId,
buildSessionData,
])
}, [messages])
// Update URL when a new session is created (first message sent)
useEffect(() => {
if (sessionManager.currentSessionId && !urlSessionId) {
// A session was created but URL doesn't have the session param yet
router.replace(`?session=${sessionManager.currentSessionId}`, {
scroll: false,
})
// Save XML snapshots to localStorage whenever they change
const saveXmlSnapshots = useCallback(() => {
try {
const snapshotsArray = Array.from(xmlSnapshotsRef.current.entries())
localStorage.setItem(
STORAGE_XML_SNAPSHOTS_KEY,
JSON.stringify(snapshotsArray),
)
} catch (error) {
console.error(
"Failed to save XML snapshots to localStorage:",
error,
)
}
}, [sessionManager.currentSessionId, urlSessionId, router])
}, [])
// Save session ID to localStorage
useEffect(() => {
localStorage.setItem(STORAGE_SESSION_ID_KEY, sessionId)
}, [sessionId])
// Save session when page becomes hidden (tab switch, close, navigate away)
// This is more reliable than beforeunload for async IndexedDB operations
useEffect(() => {
if (!sessionManager.isAvailable) return
if (messagesEndRef.current) {
messagesEndRef.current.scrollIntoView({ behavior: "smooth" })
}
}, [messages])
const handleVisibilityChange = async () => {
if (
document.visibilityState === "hidden" &&
messagesRef.current.length > 0
) {
try {
// Attempt to save session - browser may not wait for completion
// Skip thumbnail capture as it may not complete in time
const sessionData = await buildSessionData({
withThumbnail: false,
})
await sessionManager.saveCurrentSession(sessionData)
} catch (error) {
console.error(
"Failed to save session on visibility change:",
error,
)
// Save state right before page unload (refresh/close)
useEffect(() => {
const handleBeforeUnload = () => {
try {
localStorage.setItem(
STORAGE_MESSAGES_KEY,
JSON.stringify(messagesRef.current),
)
localStorage.setItem(
STORAGE_XML_SNAPSHOTS_KEY,
JSON.stringify(
Array.from(xmlSnapshotsRef.current.entries()),
),
)
const xml = chartXMLRef.current
if (xml && xml.length > 300) {
localStorage.setItem(STORAGE_DIAGRAM_XML_KEY, xml)
}
localStorage.setItem(STORAGE_SESSION_ID_KEY, sessionId)
} catch (error) {
console.error("Failed to persist state before unload:", error)
}
}
document.addEventListener("visibilitychange", handleVisibilityChange)
window.addEventListener("beforeunload", handleBeforeUnload)
return () =>
document.removeEventListener(
"visibilitychange",
handleVisibilityChange,
)
}, [sessionManager, buildSessionData])
window.removeEventListener("beforeunload", handleBeforeUnload)
}, [sessionId])
const onFormSubmit = async (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault()
@@ -772,6 +626,7 @@ export default function ChatPanel({
// Save XML snapshot for this message (will be at index = current messages.length)
const messageIndex = messages.length
xmlSnapshotsRef.current.set(messageIndex, chartXml)
saveXmlSnapshots()
sendChatMessage(parts, chartXml, previousXml, sessionId)
@@ -785,97 +640,30 @@ export default function ChatPanel({
}
}
// Handle session switching from history dropdown
const handleSelectSession = useCallback(
async (sessionId: string) => {
if (!sessionManager.isAvailable) return
// Save current session before switching
if (messages.length > 0) {
const sessionData = await buildSessionData({
withThumbnail: true,
})
await sessionManager.saveCurrentSession(sessionData)
}
// Switch to selected session
const sessionData = await sessionManager.switchSession(sessionId)
if (sessionData) {
const hasRealDiagram = isRealDiagram(sessionData.diagramXml)
justLoadedSessionRef.current = true
// CRITICAL: Update latestSvgRef with the NEW session's thumbnail
// This prevents stale thumbnail from previous session being used by auto-save
latestSvgRef.current = sessionData.thumbnailDataUrl || ""
// Track if this session has no real diagram - to prevent thumbnail contamination
if (!hasRealDiagram) {
justLoadedSessionIdRef.current = sessionId
} else {
justLoadedSessionIdRef.current = null
}
syncUIWithSession(sessionData)
router.replace(`?session=${sessionId}`, { scroll: false })
}
},
[sessionManager, messages, buildSessionData, syncUIWithSession, router],
)
// Handle session deletion from history dropdown
const handleDeleteSession = useCallback(
async (sessionId: string) => {
if (!sessionManager.isAvailable) return
const result = await sessionManager.deleteSession(sessionId)
if (result.wasCurrentSession) {
// Deleted current session - clear UI and URL
syncUIWithSession(null)
router.replace(window.location.pathname, { scroll: false })
}
},
[sessionManager, syncUIWithSession, router],
)
const handleNewChat = useCallback(async () => {
// Save current session before creating new one
if (sessionManager.isAvailable && messages.length > 0) {
const sessionData = await buildSessionData({ withThumbnail: true })
await sessionManager.saveCurrentSession(sessionData)
// Refresh sessions list to ensure dropdown shows the saved session
await sessionManager.refreshSessions()
}
// Clear session manager state BEFORE clearing URL to prevent race condition
// (otherwise the URL update effect would restore the old session URL)
sessionManager.clearCurrentSession()
// Clear UI state (can't use syncUIWithSession here because we also need to clear files)
const handleNewChat = useCallback(() => {
setMessages([])
clearDiagram()
setDiagramHistory([])
handleFileChange([]) // Use handleFileChange to also clear pdfData
const newSessionId = `session-${Date.now()}-${Math.random()
.toString(36)
.slice(2, 9)}`
setSessionId(newSessionId)
xmlSnapshotsRef.current.clear()
sessionStorage.removeItem(SESSION_STORAGE_INPUT_KEY)
toast.success(dict.dialogs.clearSuccess)
// Clear localStorage with error handling
try {
localStorage.removeItem(STORAGE_MESSAGES_KEY)
localStorage.removeItem(STORAGE_XML_SNAPSHOTS_KEY)
localStorage.removeItem(STORAGE_DIAGRAM_XML_KEY)
localStorage.setItem(STORAGE_SESSION_ID_KEY, newSessionId)
sessionStorage.removeItem(SESSION_STORAGE_INPUT_KEY)
toast.success(dict.dialogs.clearSuccess)
} catch (error) {
console.error("Failed to clear localStorage:", error)
toast.warning(dict.errors.storageUpdateFailed)
}
// Clear URL param to show blank state
router.replace(window.location.pathname, { scroll: false })
}, [
clearDiagram,
handleFileChange,
setMessages,
setSessionId,
sessionManager,
messages,
router,
dict.dialogs.clearSuccess,
buildSessionData,
setDiagramHistory,
])
setShowNewChatDialog(false)
}, [clearDiagram, handleFileChange, setMessages, setSessionId])
const handleInputChange = (
e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>,
@@ -912,6 +700,7 @@ export default function ChatPanel({
xmlSnapshotsRef.current.delete(key)
}
}
saveXmlSnapshots()
}
// Send chat message with headers
@@ -1115,6 +904,7 @@ export default function ChatPanel({
className="text-sm font-medium text-muted-foreground mt-8 tracking-wide"
style={{
writingMode: "vertical-rl",
transform: "rotate(180deg)",
}}
>
{dict.nav.aiChat}
@@ -1125,16 +915,12 @@ export default function ChatPanel({
// Full view
return (
<div
className={cn(
"h-full flex flex-col bg-card shadow-soft rounded-xl border border-border/30 relative",
shouldAnimatePanel && "animate-slide-in-right",
)}
>
<div className="h-full flex flex-col bg-card shadow-soft animate-slide-in-right rounded-xl border border-border/30 relative">
<Toaster
position="bottom-left"
position="bottom-center"
richColors
expand
style={{ position: "absolute" }}
toastOptions={{
style: {
maxWidth: "480px",
@@ -1147,15 +933,7 @@ export default function ChatPanel({
className={`${isMobile ? "px-3 py-2" : "px-5 py-4"} border-b border-border/50`}
>
<div className="flex items-center justify-between">
<button
type="button"
onClick={handleNewChat}
disabled={
status === "streaming" || status === "submitted"
}
className="flex items-center gap-2 overflow-x-hidden hover:opacity-80 transition-opacity cursor-pointer disabled:opacity-50 disabled:cursor-not-allowed"
title={dict.nav.newChat}
>
<div className="flex items-center gap-2 overflow-x-hidden">
<div className="flex items-center gap-2">
<Image
src={
@@ -1174,17 +952,40 @@ export default function ChatPanel({
Next AI Drawio
</h1>
</div>
</button>
{!isMobile && (
<Link
href="/about"
target="_blank"
rel="noopener noreferrer"
className="text-sm text-muted-foreground hover:text-foreground transition-colors ml-2"
>
{dict.nav.about}
</Link>
)}
{!isMobile && (
<Link
href="/about"
target="_blank"
rel="noopener noreferrer"
>
<ButtonWithTooltip
tooltipContent={dict.nav.sponsorTooltip}
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 className="flex items-center gap-1 justify-end overflow-visible">
<ButtonWithTooltip
tooltipContent={dict.nav.newChat}
variant="ghost"
size="icon"
onClick={handleNewChat}
disabled={
status === "streaming" || status === "submitted"
}
className="hover:bg-accent disabled:opacity-50 disabled:cursor-not-allowed"
onClick={() => setShowNewChatDialog(true)}
className="hover:bg-accent"
>
<MessageSquarePlus
className={`${isMobile ? "h-4 w-4" : "h-5 w-5"} text-muted-foreground`}
@@ -1231,11 +1032,6 @@ export default function ChatPanel({
onRegenerate={handleRegenerate}
status={status}
onEditMessage={handleEditMessage}
isRestored={isRestored}
sessions={sessionManager.sessions}
onSelectSession={handleSelectSession}
onDeleteSession={handleDeleteSession}
loadedMessageIdsRef={loadedMessageIdsRef}
/>
</main>
@@ -1259,6 +1055,7 @@ export default function ChatPanel({
status={status}
onSubmit={onFormSubmit}
onChange={handleInputChange}
onClearChat={handleNewChat}
files={files}
onFileChange={handleFileChange}
pdfData={pdfData}
@@ -1289,6 +1086,12 @@ export default function ChatPanel({
onOpenChange={setShowModelConfigDialog}
modelConfig={modelConfig}
/>
<ResetWarningModal
open={showNewChatDialog}
onOpenChange={setShowNewChatDialog}
onClear={handleNewChat}
/>
</div>
)
}

View File

@@ -1,274 +0,0 @@
"use client"
import {
ChevronDown,
ChevronUp,
MessageSquare,
Search,
Trash2,
X,
} from "lucide-react"
import Image from "next/image"
import { useState } from "react"
import ExamplePanel from "@/components/chat-example-panel"
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from "@/components/ui/alert-dialog"
interface SessionMetadata {
id: string
title: string
updatedAt: number
thumbnailDataUrl?: string
}
interface ChatLobbyProps {
sessions: SessionMetadata[]
onSelectSession: (id: string) => void
onDeleteSession?: (id: string) => void
setInput: (input: string) => void
setFiles: (files: File[]) => void
dict: {
sessionHistory?: {
recentChats?: string
searchPlaceholder?: string
noResults?: string
justNow?: string
deleteTitle?: string
deleteDescription?: string
}
examples?: {
quickExamples?: string
}
common: {
delete: string
cancel: string
}
}
}
// Helper to format session date
function formatSessionDate(
timestamp: number,
dict?: { justNow?: string },
): string {
const date = new Date(timestamp)
const now = new Date()
const diffMs = now.getTime() - date.getTime()
const diffMins = Math.floor(diffMs / (1000 * 60))
const diffHours = Math.floor(diffMs / (1000 * 60 * 60))
if (diffMins < 1) return dict?.justNow || "Just now"
if (diffMins < 60) return `${diffMins}m ago`
if (diffHours < 24) return `${diffHours}h ago`
return date.toLocaleDateString(undefined, {
month: "short",
day: "numeric",
})
}
export function ChatLobby({
sessions,
onSelectSession,
onDeleteSession,
setInput,
setFiles,
dict,
}: ChatLobbyProps) {
// Track whether examples section is expanded (collapsed by default when there's history)
const [examplesExpanded, setExamplesExpanded] = useState(false)
// Delete confirmation dialog state
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false)
const [sessionToDelete, setSessionToDelete] = useState<string | null>(null)
// Search filter for history
const [searchQuery, setSearchQuery] = useState("")
const hasHistory = sessions.length > 0
if (!hasHistory) {
// Show full examples when no history
return <ExamplePanel setInput={setInput} setFiles={setFiles} />
}
// Show history + collapsible examples when there are sessions
return (
<div className="py-6 px-2 animate-fade-in">
{/* Recent Chats Section */}
<div className="mb-6">
<p className="text-xs font-medium text-muted-foreground uppercase tracking-wider px-1 mb-3">
{dict.sessionHistory?.recentChats || "Recent Chats"}
</p>
{/* Search Bar */}
<div className="relative mb-3">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-muted-foreground" />
<input
type="text"
placeholder={
dict.sessionHistory?.searchPlaceholder ||
"Search chats..."
}
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="w-full pl-9 pr-3 py-2 text-sm rounded-lg border border-border/60 bg-background focus:outline-none focus:ring-2 focus:ring-primary/30 focus:border-primary/50 transition-all"
/>
{searchQuery && (
<button
type="button"
onClick={() => setSearchQuery("")}
className="absolute right-2 top-1/2 -translate-y-1/2 p-1 rounded hover:bg-muted transition-colors"
>
<X className="w-3 h-3 text-muted-foreground" />
</button>
)}
</div>
<div className="space-y-2">
{sessions
.filter((session) =>
session.title
.toLowerCase()
.includes(searchQuery.toLowerCase()),
)
.map((session) => (
// biome-ignore lint/a11y/useSemanticElements: Cannot use button - has nested delete button which causes hydration error
<div
key={session.id}
role="button"
tabIndex={0}
className="group w-full flex items-center gap-3 p-3 rounded-xl border border-border/60 bg-card hover:bg-accent/50 hover:border-primary/30 transition-all duration-200 cursor-pointer text-left"
onClick={() => onSelectSession(session.id)}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault()
onSelectSession(session.id)
}
}}
>
{session.thumbnailDataUrl ? (
<div className="w-12 h-12 shrink-0 rounded-lg border bg-white overflow-hidden">
<Image
src={session.thumbnailDataUrl}
alt=""
width={48}
height={48}
className="object-contain w-full h-full"
/>
</div>
) : (
<div className="w-12 h-12 shrink-0 rounded-lg bg-primary/10 flex items-center justify-center">
<MessageSquare className="w-5 h-5 text-primary" />
</div>
)}
<div className="min-w-0 flex-1">
<div className="text-sm font-medium truncate">
{session.title}
</div>
<div className="text-xs text-muted-foreground">
{formatSessionDate(
session.updatedAt,
dict.sessionHistory,
)}
</div>
</div>
{onDeleteSession && (
<button
type="button"
onClick={(e) => {
e.stopPropagation()
setSessionToDelete(session.id)
setDeleteDialogOpen(true)
}}
className="p-1.5 rounded-lg opacity-0 group-hover:opacity-100 text-muted-foreground hover:text-destructive hover:bg-destructive/10 transition-all"
title={dict.common.delete}
>
<Trash2 className="w-4 h-4" />
</button>
)}
</div>
))}
{sessions.filter((s) =>
s.title
.toLowerCase()
.includes(searchQuery.toLowerCase()),
).length === 0 &&
searchQuery && (
<p className="text-sm text-muted-foreground text-center py-4">
{dict.sessionHistory?.noResults ||
"No chats found"}
</p>
)}
</div>
</div>
{/* Collapsible Examples Section */}
<div className="border-t border-border/50 pt-4">
<button
type="button"
onClick={() => setExamplesExpanded(!examplesExpanded)}
className="w-full flex items-center justify-between px-1 py-2 text-xs font-medium text-muted-foreground uppercase tracking-wider hover:text-foreground transition-colors"
>
<span>
{dict.examples?.quickExamples || "Quick Examples"}
</span>
{examplesExpanded ? (
<ChevronUp className="w-4 h-4" />
) : (
<ChevronDown className="w-4 h-4" />
)}
</button>
{examplesExpanded && (
<div className="mt-2">
<ExamplePanel
setInput={setInput}
setFiles={setFiles}
minimal
/>
</div>
)}
</div>
{/* Delete Confirmation Dialog */}
<AlertDialog
open={deleteDialogOpen}
onOpenChange={setDeleteDialogOpen}
>
<AlertDialogContent className="max-w-sm">
<AlertDialogHeader>
<AlertDialogTitle>
{dict.sessionHistory?.deleteTitle ||
"Delete this chat?"}
</AlertDialogTitle>
<AlertDialogDescription>
{dict.sessionHistory?.deleteDescription ||
"This will permanently delete this chat session and its diagram. This action cannot be undone."}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>
{dict.common.cancel}
</AlertDialogCancel>
<AlertDialogAction
onClick={() => {
if (sessionToDelete && onDeleteSession) {
onDeleteSession(sessionToDelete)
}
setDeleteDialogOpen(false)
setSessionToDelete(null)
}}
className="border border-red-300 bg-red-50 text-red-700 hover:bg-red-100 hover:border-red-400"
>
{dict.common.delete}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
)
}

View File

@@ -1,250 +0,0 @@
"use client"
import { Check, ChevronDown, ChevronUp, Copy, Cpu } from "lucide-react"
import type { Dispatch, SetStateAction } from "react"
import { CodeBlock } from "@/components/code-block"
import { isMxCellXmlComplete } from "@/lib/utils"
import type { DiagramOperation, ToolPartLike } from "./types"
interface ToolCallCardProps {
part: ToolPartLike
expandedTools: Record<string, boolean>
setExpandedTools: Dispatch<SetStateAction<Record<string, boolean>>>
onCopy: (callId: string, text: string, isToolCall: boolean) => void
copiedToolCallId: string | null
copyFailedToolCallId: string | null
dict: {
tools: { complete: string }
chat: { copied: string; failedToCopy: string; copyResponse: string }
}
}
function OperationsDisplay({ operations }: { operations: DiagramOperation[] }) {
return (
<div className="space-y-3">
{operations.map((op, index) => (
<div
key={`${op.operation}-${op.cell_id}-${index}`}
className="rounded-lg border border-border/50 overflow-hidden bg-background/50"
>
<div className="px-3 py-1.5 bg-muted/40 border-b border-border/30 flex items-center gap-2">
<span
className={`text-[10px] font-medium uppercase tracking-wide ${
op.operation === "delete"
? "text-red-600"
: op.operation === "add"
? "text-green-600"
: "text-blue-600"
}`}
>
{op.operation}
</span>
<span className="text-xs text-muted-foreground">
cell_id: {op.cell_id}
</span>
</div>
{op.new_xml && (
<div className="px-3 py-2">
<pre className="text-[11px] font-mono text-foreground/80 bg-muted/30 rounded px-2 py-1.5 overflow-x-auto whitespace-pre-wrap break-all">
{op.new_xml}
</pre>
</div>
)}
</div>
))}
</div>
)
}
export function ToolCallCard({
part,
expandedTools,
setExpandedTools,
onCopy,
copiedToolCallId,
copyFailedToolCallId,
dict,
}: ToolCallCardProps) {
const callId = part.toolCallId
const { state, input, output } = part
// Default to collapsed if tool is complete, expanded if still streaming
const isExpanded = expandedTools[callId] ?? state !== "output-available"
const toolName = part.type?.replace("tool-", "")
const isCopied = copiedToolCallId === callId
const toggleExpanded = () => {
setExpandedTools((prev) => ({
...prev,
[callId]: !isExpanded,
}))
}
const getToolDisplayName = (name: string) => {
switch (name) {
case "display_diagram":
return "Generate Diagram"
case "edit_diagram":
return "Edit Diagram"
case "get_shape_library":
return "Get Shape Library"
default:
return name
}
}
const handleCopy = () => {
let textToCopy = ""
if (input && typeof input === "object") {
if (input.xml) {
textToCopy = input.xml
} else if (input.operations && Array.isArray(input.operations)) {
textToCopy = JSON.stringify(input.operations, null, 2)
} else if (Object.keys(input).length > 0) {
textToCopy = JSON.stringify(input, null, 2)
}
}
if (
output &&
toolName === "get_shape_library" &&
typeof output === "string"
) {
textToCopy = output
}
if (textToCopy) {
onCopy(callId, textToCopy, true)
}
}
return (
<div className="my-3 rounded-xl border border-border/60 bg-muted/30 overflow-hidden">
<div className="flex items-center justify-between px-4 py-3 bg-muted/50">
<div className="flex items-center gap-2">
<div className="w-6 h-6 rounded-md bg-primary/10 flex items-center justify-center">
<Cpu className="w-3.5 h-3.5 text-primary" />
</div>
<span className="text-sm font-medium text-foreground/80">
{getToolDisplayName(toolName)}
</span>
</div>
<div className="flex items-center gap-2">
{state === "input-streaming" && (
<div className="h-4 w-4 border-2 border-primary border-t-transparent rounded-full animate-spin" />
)}
{state === "output-available" && (
<>
<span className="text-xs font-medium text-green-600 bg-green-50 px-2 py-0.5 rounded-full">
{dict.tools.complete}
</span>
{isExpanded && (
<button
type="button"
onClick={handleCopy}
className="p-1 rounded hover:bg-muted transition-colors"
title={
copiedToolCallId === callId
? dict.chat.copied
: copyFailedToolCallId === callId
? dict.chat.failedToCopy
: dict.chat.copyResponse
}
>
{isCopied ? (
<Check className="w-4 h-4 text-green-600" />
) : (
<Copy className="w-4 h-4 text-muted-foreground" />
)}
</button>
)}
</>
)}
{state === "output-error" &&
(() => {
// Check if this is a truncation (incomplete XML) vs real error
const isTruncated =
(toolName === "display_diagram" ||
toolName === "append_diagram") &&
!isMxCellXmlComplete(input?.xml)
return isTruncated ? (
<span className="text-xs font-medium text-yellow-600 bg-yellow-50 px-2 py-0.5 rounded-full">
Truncated
</span>
) : (
<span className="text-xs font-medium text-red-600 bg-red-50 px-2 py-0.5 rounded-full">
Error
</span>
)
})()}
{input && Object.keys(input).length > 0 && (
<button
type="button"
onClick={toggleExpanded}
className="p-1 rounded hover:bg-muted transition-colors"
>
{isExpanded ? (
<ChevronUp className="w-4 h-4 text-muted-foreground" />
) : (
<ChevronDown className="w-4 h-4 text-muted-foreground" />
)}
</button>
)}
</div>
</div>
{input && isExpanded && (
<div className="px-4 py-3 border-t border-border/40 bg-muted/20">
{typeof input === "object" && input.xml ? (
<CodeBlock code={input.xml} language="xml" />
) : typeof input === "object" &&
input.operations &&
Array.isArray(input.operations) ? (
<OperationsDisplay operations={input.operations} />
) : typeof input === "object" &&
Object.keys(input).length > 0 ? (
<CodeBlock
code={JSON.stringify(input, null, 2)}
language="json"
/>
) : null}
</div>
)}
{output &&
state === "output-error" &&
(() => {
const isTruncated =
(toolName === "display_diagram" ||
toolName === "append_diagram") &&
!isMxCellXmlComplete(input?.xml)
return (
<div
className={`px-4 py-3 border-t border-border/40 text-sm ${isTruncated ? "text-yellow-600" : "text-red-600"}`}
>
{isTruncated
? "Output truncated due to length limits. Try a simpler request or increase the maxOutputLength."
: output}
</div>
)
})()}
{/* Show get_shape_library output on success */}
{output &&
toolName === "get_shape_library" &&
state === "output-available" &&
isExpanded && (
<div className="px-4 py-3 border-t border-border/40">
<div className="text-xs text-muted-foreground mb-2">
Library loaded (
{typeof output === "string" ? output.length : 0}{" "}
chars)
</div>
<pre className="text-xs bg-muted/50 p-2 rounded-md overflow-auto max-h-32 whitespace-pre-wrap">
{typeof output === "string"
? output.substring(0, 800) +
(output.length > 800 ? "\n..." : "")
: String(output)}
</pre>
</div>
)}
</div>
)
}

View File

@@ -1,16 +0,0 @@
export interface DiagramOperation {
operation: "update" | "add" | "delete"
cell_id: string
new_xml?: string
}
export interface ToolPartLike {
type: string
toolCallId: string
state?: string
input?: {
xml?: string
operations?: DiagramOperation[]
} & Record<string, unknown>
output?: string
}

File diff suppressed because it is too large Load Diff

View File

@@ -395,13 +395,13 @@ function SettingsContent({
<>
<span className="text-muted-foreground">·</span>
<a
href={`/${currentLang}/about${currentLang === "zh" ? "/cn" : currentLang === "ja" ? "/ja" : ""}`}
href="/about"
target="_blank"
rel="noopener noreferrer"
className="text-xs text-muted-foreground hover:text-foreground transition-colors flex items-center gap-1"
>
<Info className="h-3 w-3" />
{dict.nav.about}
About
</a>
</>
)}

View File

@@ -3,20 +3,15 @@
import type React from "react"
import { createContext, useContext, useEffect, useRef, useState } from "react"
import type { DrawIoEmbedRef } from "react-drawio"
import { toast } from "sonner"
import { STORAGE_DIAGRAM_XML_KEY } from "@/components/chat-panel"
import type { ExportFormat } from "@/components/save-dialog"
import { getApiEndpoint } from "@/lib/base-path"
import {
extractDiagramXML,
isRealDiagram,
validateAndFixXml,
} from "../lib/utils"
import { extractDiagramXML, validateAndFixXml } from "../lib/utils"
interface DiagramContextType {
chartXML: string
latestSvg: string
diagramHistory: { svg: string; xml: string }[]
setDiagramHistory: (history: { svg: string; xml: string }[]) => void
loadDiagram: (chart: string, skipValidation?: boolean) => string | null
handleExport: () => void
handleExportWithoutHistory: () => void
@@ -28,9 +23,8 @@ interface DiagramContextType {
filename: string,
format: ExportFormat,
sessionId?: string,
successMessage?: string,
) => void
getThumbnailSvg: () => Promise<string | null>
saveDiagramToStorage: () => Promise<void>
isDrawioReady: boolean
onDrawioLoad: () => void
resetDrawioReady: () => void
@@ -47,52 +41,72 @@ export function DiagramProvider({ children }: { children: React.ReactNode }) {
{ svg: string; xml: string }[]
>([])
const [isDrawioReady, setIsDrawioReady] = useState(false)
const [canSaveDiagram, setCanSaveDiagram] = useState(false)
const [showSaveDialog, setShowSaveDialog] = useState(false)
const hasCalledOnLoadRef = useRef(false)
const drawioRef = useRef<DrawIoEmbedRef | null>(null)
const resolverRef = useRef<((value: string) => void) | null>(null)
// Track if we're expecting an export for history (user-initiated)
const expectHistoryExportRef = useRef<boolean>(false)
// Track if diagram has been restored after DrawIO remount (e.g., theme change)
// Track if diagram has been restored from localStorage
const hasDiagramRestoredRef = useRef<boolean>(false)
// Track latest chartXML for restoration after remount
const chartXMLRef = useRef<string>("")
const onDrawioLoad = () => {
// Only set ready state once to prevent infinite loops
if (hasCalledOnLoadRef.current) return
hasCalledOnLoadRef.current = true
// console.log("[DiagramContext] DrawIO loaded, setting ready state")
setIsDrawioReady(true)
}
const resetDrawioReady = () => {
// console.log("[DiagramContext] Resetting DrawIO ready state")
hasCalledOnLoadRef.current = false
setIsDrawioReady(false)
}
// Keep chartXMLRef in sync with state for restoration after remount
// Restore diagram XML when DrawIO becomes ready
// eslint-disable-next-line react-hooks/exhaustive-deps -- loadDiagram uses refs internally and is stable
useEffect(() => {
chartXMLRef.current = chartXML
}, [chartXML])
// Restore diagram when DrawIO becomes ready after remount (e.g., theme/UI change)
useEffect(() => {
// Reset restore flag when DrawIO is not ready (preparing for next restore cycle)
// Reset restore flag when DrawIO is not ready (e.g., theme/UI change remounts it)
if (!isDrawioReady) {
hasDiagramRestoredRef.current = false
setCanSaveDiagram(false)
return
}
// Only restore once per ready cycle
if (hasDiagramRestoredRef.current) return
hasDiagramRestoredRef.current = true
// Restore diagram from ref if we have one
const xmlToRestore = chartXMLRef.current
if (isRealDiagram(xmlToRestore) && drawioRef.current) {
drawioRef.current.load({ xml: xmlToRestore })
try {
const savedDiagramXml = localStorage.getItem(
STORAGE_DIAGRAM_XML_KEY,
)
if (savedDiagramXml) {
// Skip validation for trusted saved diagrams
loadDiagram(savedDiagramXml, true)
}
} catch (error) {
console.error("Failed to restore diagram from localStorage:", error)
}
// Allow saving after restore is complete
setTimeout(() => {
setCanSaveDiagram(true)
}, 500)
}, [isDrawioReady])
// Save diagram XML to localStorage whenever it changes (debounced)
useEffect(() => {
if (!canSaveDiagram) return
if (!chartXML || chartXML.length <= 300) return
const timeoutId = setTimeout(() => {
localStorage.setItem(STORAGE_DIAGRAM_XML_KEY, chartXML)
}, 1000)
return () => clearTimeout(timeoutId)
}, [chartXML, canSaveDiagram])
// Track if we're expecting an export for file save (stores raw export data)
const saveResolverRef = useRef<{
resolver: ((data: string) => void) | null
@@ -118,32 +132,27 @@ export function DiagramProvider({ children }: { children: React.ReactNode }) {
}
}
// Get current diagram as SVG for thumbnail (used by session storage)
const getThumbnailSvg = async (): Promise<string | null> => {
if (!drawioRef.current) return null
// Don't export if diagram is empty
if (!isRealDiagram(chartXML)) return null
// Save current diagram to localStorage (used before theme/UI changes)
const saveDiagramToStorage = async (): Promise<void> => {
if (!drawioRef.current) return
try {
const svgData = await Promise.race([
const currentXml = await Promise.race([
new Promise<string>((resolve) => {
resolverRef.current = resolve
drawioRef.current?.exportDiagram({ format: "xmlsvg" })
}),
new Promise<string>((_, reject) =>
setTimeout(() => reject(new Error("Export timeout")), 3000),
setTimeout(() => reject(new Error("Export timeout")), 2000),
),
])
// Update latestSvg so it's available for future saves
if (svgData?.includes("<svg")) {
setLatestSvg(svgData)
return svgData
// Only save if diagram has meaningful content (not empty template)
if (currentXml && currentXml.length > 300) {
localStorage.setItem(STORAGE_DIAGRAM_XML_KEY, currentXml)
}
return null
} catch {
// Timeout is expected occasionally - don't log as error
return null
} catch (error) {
console.error("Failed to save diagram to storage:", error)
}
}
@@ -238,7 +247,6 @@ export function DiagramProvider({ children }: { children: React.ReactNode }) {
filename: string,
format: ExportFormat,
sessionId?: string,
successMessage?: string,
) => {
if (!drawioRef.current) {
console.warn("Draw.io editor not ready")
@@ -265,6 +273,9 @@ export function DiagramProvider({ children }: { children: React.ReactNode }) {
fileContent = xmlContent
mimeType = "application/xml"
extension = ".drawio"
// Save to localStorage when user manually saves
localStorage.setItem(STORAGE_DIAGRAM_XML_KEY, xmlContent)
} else if (format === "png") {
// PNG data comes as base64 data URL
fileContent = exportData
@@ -300,14 +311,6 @@ export function DiagramProvider({ children }: { children: React.ReactNode }) {
a.click()
document.body.removeChild(a)
// Show success toast after download is initiated
if (successMessage) {
toast.success(successMessage, {
position: "bottom-left",
duration: 2500,
})
}
// Delay URL revocation to ensure download completes
if (!url.startsWith("data:")) {
setTimeout(() => URL.revokeObjectURL(url), 100)
@@ -343,7 +346,6 @@ export function DiagramProvider({ children }: { children: React.ReactNode }) {
chartXML,
latestSvg,
diagramHistory,
setDiagramHistory,
loadDiagram,
handleExport,
handleExportWithoutHistory,
@@ -352,7 +354,7 @@ export function DiagramProvider({ children }: { children: React.ReactNode }) {
handleDiagramExport,
clearDiagram,
saveDiagramToFile,
getThumbnailSvg,
saveDiagramToStorage,
isDrawioReady,
onDrawioLoad,
resetDrawioReady,

View File

@@ -11,7 +11,7 @@ services:
# - 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
environment:
# For subdirectory deployment, uncomment and set your path:
# NEXT_PUBLIC_BASE_PATH: /nextaidrawio
depends_on: [drawio]

View File

@@ -11,6 +11,7 @@ import {
flattenModels,
type ModelConfig,
type MultiModelConfig,
PROVIDER_INFO,
type ProviderConfig,
type ProviderName,
} from "@/lib/types/model-config"

View File

@@ -1,322 +0,0 @@
"use client"
import { useCallback, useEffect, useRef, useState } from "react"
import {
type ChatSession,
createEmptySession,
deleteSession as deleteSessionFromDB,
enforceSessionLimit,
extractTitle,
getAllSessionMetadata,
getSession,
isIndexedDBAvailable,
migrateFromLocalStorage,
type SessionMetadata,
type StoredMessage,
saveSession,
} from "@/lib/session-storage"
export interface SessionData {
messages: StoredMessage[]
xmlSnapshots: [number, string][]
diagramXml: string
thumbnailDataUrl?: string
diagramHistory?: { svg: string; xml: string }[]
}
export interface UseSessionManagerReturn {
// State
sessions: SessionMetadata[]
currentSessionId: string | null
currentSession: ChatSession | null
isLoading: boolean
isAvailable: boolean
// Actions
switchSession: (id: string) => Promise<SessionData | null>
deleteSession: (id: string) => Promise<{ wasCurrentSession: boolean }>
// forSessionId: optional session ID to verify save targets correct session (prevents stale debounce writes)
saveCurrentSession: (
data: SessionData,
forSessionId?: string | null,
) => Promise<void>
refreshSessions: () => Promise<void>
clearCurrentSession: () => void
}
interface UseSessionManagerOptions {
/** Session ID from URL param - if provided, load this session; if null, start blank */
initialSessionId?: string | null
}
export function useSessionManager(
options: UseSessionManagerOptions = {},
): UseSessionManagerReturn {
const { initialSessionId } = options
const [sessions, setSessions] = useState<SessionMetadata[]>([])
const [currentSessionId, setCurrentSessionId] = useState<string | null>(
null,
)
const [currentSession, setCurrentSession] = useState<ChatSession | null>(
null,
)
const [isLoading, setIsLoading] = useState(true)
const [isAvailable, setIsAvailable] = useState(false)
const isInitializedRef = useRef(false)
// Sequence guard for URL changes - prevents out-of-order async resolution
const urlChangeSequenceRef = useRef(0)
// Load sessions list
const refreshSessions = useCallback(async () => {
if (!isIndexedDBAvailable()) return
try {
const metadata = await getAllSessionMetadata()
setSessions(metadata)
} catch (error) {
console.error("Failed to refresh sessions:", error)
}
}, [])
// Initialize on mount
useEffect(() => {
if (isInitializedRef.current) return
isInitializedRef.current = true
async function init() {
setIsLoading(true)
if (!isIndexedDBAvailable()) {
setIsAvailable(false)
setIsLoading(false)
return
}
setIsAvailable(true)
try {
// Run migration first (one-time conversion from localStorage)
await migrateFromLocalStorage()
// Load sessions list
const metadata = await getAllSessionMetadata()
setSessions(metadata)
// Only load a session if initialSessionId is provided (from URL param)
if (initialSessionId) {
const session = await getSession(initialSessionId)
if (session) {
setCurrentSession(session)
setCurrentSessionId(session.id)
}
// If session not found, stay in blank state (URL has invalid session ID)
}
// If no initialSessionId, start with blank state (no auto-restore)
} catch (error) {
console.error("Failed to initialize session manager:", error)
} finally {
setIsLoading(false)
}
}
init()
}, [initialSessionId])
// Handle URL session ID changes after initialization
// Note: intentionally NOT including currentSessionId in deps to avoid race conditions
// when clearCurrentSession() is called before URL updates
useEffect(() => {
if (!isInitializedRef.current) return // Wait for initial load
if (!isAvailable) return
// Increment sequence to invalidate any pending async operations
urlChangeSequenceRef.current++
const currentSequence = urlChangeSequenceRef.current
async function handleSessionIdChange() {
if (initialSessionId) {
// URL has session ID - load it
const session = await getSession(initialSessionId)
// Check if this request is still the latest (sequence guard)
// If not, a newer URL change happened while we were loading
if (currentSequence !== urlChangeSequenceRef.current) {
return
}
if (session) {
// Only update if the session is different from current
setCurrentSessionId((current) => {
if (current !== session.id) {
setCurrentSession(session)
return session.id
}
return current
})
}
}
// Removed: else clause that clears session
// Clearing is now handled explicitly by clearCurrentSession()
// This prevents race conditions when URL update is async
}
handleSessionIdChange()
}, [initialSessionId, isAvailable])
// Refresh sessions on window focus (multi-tab sync)
useEffect(() => {
const handleFocus = () => {
refreshSessions()
}
window.addEventListener("focus", handleFocus)
return () => window.removeEventListener("focus", handleFocus)
}, [refreshSessions])
// Switch to a different session
const switchSession = useCallback(
async (id: string): Promise<SessionData | null> => {
if (id === currentSessionId) return null
// Save current session first if it has messages
if (currentSession && currentSession.messages.length > 0) {
await saveSession(currentSession)
}
// Load the target session
const session = await getSession(id)
if (!session) {
console.error("Session not found:", id)
return null
}
// Update state
setCurrentSession(session)
setCurrentSessionId(session.id)
return {
messages: session.messages,
xmlSnapshots: session.xmlSnapshots,
diagramXml: session.diagramXml,
thumbnailDataUrl: session.thumbnailDataUrl,
diagramHistory: session.diagramHistory,
}
},
[currentSessionId, currentSession],
)
// Delete a session
const deleteSession = useCallback(
async (id: string): Promise<{ wasCurrentSession: boolean }> => {
const wasCurrentSession = id === currentSessionId
await deleteSessionFromDB(id)
// If deleting current session, clear state (caller will show new empty session)
if (wasCurrentSession) {
setCurrentSession(null)
setCurrentSessionId(null)
}
await refreshSessions()
return { wasCurrentSession }
},
[currentSessionId, refreshSessions],
)
// Save current session data (debounced externally by caller)
// forSessionId: if provided, verify save targets correct session (prevents stale debounce writes)
const saveCurrentSession = useCallback(
async (
data: SessionData,
forSessionId?: string | null,
): Promise<void> => {
// If forSessionId is provided, verify it matches current session
// This prevents stale debounced saves from overwriting a newly switched session
if (
forSessionId !== undefined &&
forSessionId !== currentSessionId
) {
return
}
if (!currentSession) {
// Create a new session if none exists
const newSession: ChatSession = {
...createEmptySession(),
messages: data.messages,
xmlSnapshots: data.xmlSnapshots,
diagramXml: data.diagramXml,
thumbnailDataUrl: data.thumbnailDataUrl,
diagramHistory: data.diagramHistory,
title: extractTitle(data.messages),
}
await saveSession(newSession)
await enforceSessionLimit()
setCurrentSession(newSession)
setCurrentSessionId(newSession.id)
await refreshSessions()
return
}
// Update existing session
const updatedSession: ChatSession = {
...currentSession,
messages: data.messages,
xmlSnapshots: data.xmlSnapshots,
diagramXml: data.diagramXml,
thumbnailDataUrl:
data.thumbnailDataUrl ?? currentSession.thumbnailDataUrl,
diagramHistory:
data.diagramHistory ?? currentSession.diagramHistory,
updatedAt: Date.now(),
// Update title if it's still default and we have messages
title:
currentSession.title === "New Chat" &&
data.messages.length > 0
? extractTitle(data.messages)
: currentSession.title,
}
await saveSession(updatedSession)
setCurrentSession(updatedSession)
// Update sessions list metadata
setSessions((prev) =>
prev.map((s) =>
s.id === updatedSession.id
? {
...s,
title: updatedSession.title,
updatedAt: updatedSession.updatedAt,
messageCount: updatedSession.messages.length,
hasDiagram:
!!updatedSession.diagramXml &&
updatedSession.diagramXml.trim().length > 0,
thumbnailDataUrl: updatedSession.thumbnailDataUrl,
}
: s,
),
)
},
[currentSession, currentSessionId, refreshSessions],
)
// Clear current session state (for starting fresh without loading another session)
const clearCurrentSession = useCallback(() => {
setCurrentSession(null)
setCurrentSessionId(null)
}, [])
return {
sessions,
currentSessionId,
currentSession,
isLoading,
isAvailable,
switchSession,
deleteSession,
saveCurrentSession,
refreshSessions,
clearCurrentSession,
}
}

View File

@@ -786,7 +786,7 @@ export function getAIModel(overrides?: ClientOverrides): ModelConfig {
`data: ${JSON.stringify(data)}\n\n`,
),
)
} catch (_e) {
} catch (e) {
// If parsing fails, forward the original message to avoid breaking the stream.
controller.enqueue(
new TextEncoder().encode(
@@ -906,34 +906,3 @@ export function supportsPromptCaching(modelId: string): boolean {
modelId.startsWith("eu.anthropic")
)
}
/**
* Check if a model supports image/vision input.
* Some models silently drop image parts without error (AI SDK warning only).
*/
export function supportsImageInput(modelId: string): boolean {
const lowerModelId = modelId.toLowerCase()
// Helper to check if model has vision capability indicator
const hasVisionIndicator =
lowerModelId.includes("vision") || lowerModelId.includes("vl")
// Models that DON'T support image/vision input (unless vision variant)
// Kimi K2 models don't support images
if (lowerModelId.includes("kimi") && !hasVisionIndicator) {
return false
}
// DeepSeek text models (not vision variants)
if (lowerModelId.includes("deepseek") && !hasVisionIndicator) {
return false
}
// Qwen text models (not vision variants like qwen-vl)
if (lowerModelId.includes("qwen") && !hasVisionIndicator) {
return false
}
// Default: assume model supports images
return true
}

View File

@@ -18,7 +18,8 @@
"settings": "Settings",
"hidePanel": "Hide chat panel (Ctrl+B)",
"showPanel": "Show chat panel (Ctrl+B)",
"aiChat": "AI Chat"
"aiChat": "AI Chat",
"sponsorTooltip": "Sponsored by ByteDance Doubao K2-thinking. See About page for details."
},
"providers": {
"useServerDefault": "Use Server Default",
@@ -117,8 +118,7 @@
"drawio": "Draw.io XML",
"png": "PNG Image",
"svg": "SVG Image"
},
"savedSuccessfully": "Saved successfully!"
}
},
"history": {
"title": "Diagram History",
@@ -213,22 +213,6 @@
"contactMe": "Contact Me",
"usageNotice": "Due to high usage, I have changed the model from Claude to minimax-m2 and added some usage limits. See About page for details."
},
"sessionHistory": {
"tooltip": "Chat History",
"newChat": "New Chat",
"empty": "No chat history yet",
"emptyHint": "Start a conversation to begin",
"today": "Today",
"yesterday": "Yesterday",
"thisWeek": "This Week",
"earlier": "Earlier",
"deleteTitle": "Delete this chat?",
"deleteDescription": "This will permanently delete this chat session and its diagram. This action cannot be undone.",
"recentChats": "Recent Chats",
"justNow": "Just now",
"searchPlaceholder": "Search chats...",
"noResults": "No chats found"
},
"modelConfig": {
"title": "AI Model Configuration",
"description": "Configure multiple AI providers and models",

View File

@@ -18,7 +18,8 @@
"settings": "設定",
"hidePanel": "チャットパネルを非表示 (Ctrl+B)",
"showPanel": "チャットパネルを表示 (Ctrl+B)",
"aiChat": "AI チャット"
"aiChat": "AI チャット",
"sponsorTooltip": "ByteDance Doubao K2-thinking によるスポンサー。詳細は概要ページをご覧ください。"
},
"providers": {
"useServerDefault": "サーバーデフォルトを使用",
@@ -117,8 +118,7 @@
"drawio": "Draw.io XML",
"png": "PNG 画像",
"svg": "SVG 画像"
},
"savedSuccessfully": "保存完了!"
}
},
"history": {
"title": "ダイアグラム履歴",
@@ -213,22 +213,6 @@
"contactMe": "お問い合わせ",
"usageNotice": "利用量の増加に伴い、コスト削減のためモデルを Claude から minimax-m2 に変更し、いくつかの利用制限を設けました。詳細は概要ページをご覧ください。"
},
"sessionHistory": {
"tooltip": "チャット履歴",
"newChat": "新しいチャット",
"empty": "チャット履歴はまだありません",
"emptyHint": "会話を始めてください",
"today": "今日",
"yesterday": "昨日",
"thisWeek": "今週",
"earlier": "それ以前",
"deleteTitle": "このチャットを削除しますか?",
"deleteDescription": "このチャットセッションとダイアグラムは完全に削除されます。この操作は取り消せません。",
"recentChats": "最近のチャット",
"justNow": "たった今",
"searchPlaceholder": "チャットを検索...",
"noResults": "チャットが見つかりません"
},
"modelConfig": {
"title": "AIモデル設定",
"description": "複数のAIプロバイダーとモデルを設定",

View File

@@ -18,7 +18,8 @@
"settings": "设置",
"hidePanel": "隐藏聊天面板 (Ctrl+B)",
"showPanel": "显示聊天面板 (Ctrl+B)",
"aiChat": "AI 聊天"
"aiChat": "AI 聊天",
"sponsorTooltip": "由字节跳动豆包 K2-thinking 赞助。详情请参阅关于页面。"
},
"providers": {
"useServerDefault": "使用服务器默认值",
@@ -117,8 +118,7 @@
"drawio": "Draw.io XML",
"png": "PNG 图片",
"svg": "SVG 图片"
},
"savedSuccessfully": "保存成功!"
}
},
"history": {
"title": "图表历史",
@@ -213,22 +213,6 @@
"contactMe": "联系我",
"usageNotice": "由于使用量过高,我已将模型从 Claude 更换为 minimax-m2并设置了一些用量限制。详情请查看关于页面。"
},
"sessionHistory": {
"tooltip": "聊天历史",
"newChat": "新对话",
"empty": "暂无聊天记录",
"emptyHint": "开始对话吧",
"today": "今天",
"yesterday": "昨天",
"thisWeek": "本周",
"earlier": "更早",
"deleteTitle": "删除此对话?",
"deleteDescription": "这将永久删除此聊天会话及其图表。此操作无法撤消。",
"recentChats": "最近对话",
"justNow": "刚刚",
"searchPlaceholder": "搜索对话...",
"noResults": "未找到对话"
},
"modelConfig": {
"title": "AI 模型配置",
"description": "配置多个 AI 提供商和模型",

View File

@@ -1,338 +0,0 @@
import { type DBSchema, type IDBPDatabase, openDB } from "idb"
import { nanoid } from "nanoid"
// Constants
const DB_NAME = "next-ai-drawio"
const DB_VERSION = 1
const STORE_NAME = "sessions"
const MIGRATION_FLAG = "next-ai-drawio-migrated-to-idb"
const MAX_SESSIONS = 50
// Types
export interface ChatSession {
id: string
title: string
createdAt: number
updatedAt: number
messages: StoredMessage[]
xmlSnapshots: [number, string][]
diagramXml: string
thumbnailDataUrl?: string // Small PNG preview of the diagram
diagramHistory?: { svg: string; xml: string }[] // Version history of diagram edits
}
export interface StoredMessage {
id: string
role: "user" | "assistant" | "system"
parts: Array<{ type: string; [key: string]: unknown }>
}
export interface SessionMetadata {
id: string
title: string
createdAt: number
updatedAt: number
messageCount: number
hasDiagram: boolean
thumbnailDataUrl?: string
}
interface ChatSessionDB extends DBSchema {
sessions: {
key: string
value: ChatSession
indexes: { "by-updated": number }
}
}
// Database singleton
let dbPromise: Promise<IDBPDatabase<ChatSessionDB>> | null = null
async function getDB(): Promise<IDBPDatabase<ChatSessionDB>> {
if (!dbPromise) {
dbPromise = openDB<ChatSessionDB>(DB_NAME, DB_VERSION, {
upgrade(db, oldVersion) {
if (oldVersion < 1) {
const store = db.createObjectStore(STORE_NAME, {
keyPath: "id",
})
store.createIndex("by-updated", "updatedAt")
}
// Future migrations: if (oldVersion < 2) { ... }
},
})
}
return dbPromise
}
// Check if IndexedDB is available
export function isIndexedDBAvailable(): boolean {
if (typeof window === "undefined") return false
try {
return "indexedDB" in window && window.indexedDB !== null
} catch {
return false
}
}
// CRUD Operations
export async function getAllSessionMetadata(): Promise<SessionMetadata[]> {
if (!isIndexedDBAvailable()) return []
try {
const db = await getDB()
const tx = db.transaction(STORE_NAME, "readonly")
const index = tx.store.index("by-updated")
const metadata: SessionMetadata[] = []
// Use cursor to read only metadata fields (avoids loading full messages/XML)
let cursor = await index.openCursor(null, "prev") // newest first
while (cursor) {
const s = cursor.value
metadata.push({
id: s.id,
title: s.title,
createdAt: s.createdAt,
updatedAt: s.updatedAt,
messageCount: s.messages.length,
hasDiagram: !!s.diagramXml && s.diagramXml.trim().length > 0,
thumbnailDataUrl: s.thumbnailDataUrl,
})
cursor = await cursor.continue()
}
return metadata
} catch (error) {
console.error("Failed to get session metadata:", error)
return []
}
}
export async function getSession(id: string): Promise<ChatSession | null> {
if (!isIndexedDBAvailable()) return null
try {
const db = await getDB()
return (await db.get(STORE_NAME, id)) || null
} catch (error) {
console.error("Failed to get session:", error)
return null
}
}
export async function saveSession(session: ChatSession): Promise<boolean> {
if (!isIndexedDBAvailable()) return false
try {
const db = await getDB()
await db.put(STORE_NAME, session)
return true
} catch (error) {
// Handle quota exceeded
if (
error instanceof DOMException &&
error.name === "QuotaExceededError"
) {
console.warn("Storage quota exceeded, deleting oldest session...")
await deleteOldestSession()
// Retry once
try {
const db = await getDB()
await db.put(STORE_NAME, session)
return true
} catch (retryError) {
console.error(
"Failed to save session after cleanup:",
retryError,
)
return false
}
} else {
console.error("Failed to save session:", error)
return false
}
}
}
export async function deleteSession(id: string): Promise<void> {
if (!isIndexedDBAvailable()) return
try {
const db = await getDB()
await db.delete(STORE_NAME, id)
} catch (error) {
console.error("Failed to delete session:", error)
}
}
export async function getSessionCount(): Promise<number> {
if (!isIndexedDBAvailable()) return 0
try {
const db = await getDB()
return await db.count(STORE_NAME)
} catch (error) {
console.error("Failed to get session count:", error)
return 0
}
}
export async function deleteOldestSession(): Promise<void> {
if (!isIndexedDBAvailable()) return
try {
const db = await getDB()
const tx = db.transaction(STORE_NAME, "readwrite")
const index = tx.store.index("by-updated")
const cursor = await index.openCursor()
if (cursor) {
await cursor.delete()
}
await tx.done
} catch (error) {
console.error("Failed to delete oldest session:", error)
}
}
// Enforce max sessions limit
export async function enforceSessionLimit(): Promise<void> {
const count = await getSessionCount()
if (count > MAX_SESSIONS) {
const toDelete = count - MAX_SESSIONS
for (let i = 0; i < toDelete; i++) {
await deleteOldestSession()
}
}
}
// Helper: Create a new empty session
export function createEmptySession(): ChatSession {
return {
id: nanoid(),
title: "New Chat",
createdAt: Date.now(),
updatedAt: Date.now(),
messages: [],
xmlSnapshots: [],
diagramXml: "",
}
}
// Helper: Extract title from first user message (truncated to reasonable length)
const MAX_TITLE_LENGTH = 100
export function extractTitle(messages: StoredMessage[]): string {
const firstUserMessage = messages.find((m) => m.role === "user")
if (!firstUserMessage) return "New Chat"
const textPart = firstUserMessage.parts.find((p) => p.type === "text")
if (!textPart || typeof textPart.text !== "string") return "New Chat"
const text = textPart.text.trim()
if (!text) return "New Chat"
// Truncate long titles
if (text.length > MAX_TITLE_LENGTH) {
return text.slice(0, MAX_TITLE_LENGTH).trim() + "..."
}
return text
}
// Helper: Sanitize UIMessage to StoredMessage
export function sanitizeMessage(message: unknown): StoredMessage | null {
if (!message || typeof message !== "object") return null
const msg = message as Record<string, unknown>
if (!msg.id || !msg.role) return null
const role = msg.role as string
if (!["user", "assistant", "system"].includes(role)) return null
// Extract parts, removing streaming state artifacts
let parts: Array<{ type: string; [key: string]: unknown }> = []
if (Array.isArray(msg.parts)) {
parts = msg.parts.map((part: unknown) => {
if (!part || typeof part !== "object") return { type: "unknown" }
const p = part as Record<string, unknown>
// Remove streaming-related fields
const { isStreaming, streamingState, ...cleanPart } = p
return cleanPart as { type: string; [key: string]: unknown }
})
}
return {
id: msg.id as string,
role: role as "user" | "assistant" | "system",
parts,
}
}
export function sanitizeMessages(messages: unknown[]): StoredMessage[] {
return messages
.map(sanitizeMessage)
.filter((m): m is StoredMessage => m !== null)
}
// Migration from localStorage
export async function migrateFromLocalStorage(): Promise<string | null> {
if (typeof window === "undefined") return null
if (!isIndexedDBAvailable()) return null
// Check if already migrated
if (localStorage.getItem(MIGRATION_FLAG)) return null
try {
const savedMessages = localStorage.getItem("next-ai-draw-io-messages")
const savedSnapshots = localStorage.getItem(
"next-ai-draw-io-xml-snapshots",
)
const savedXml = localStorage.getItem("next-ai-draw-io-diagram-xml")
let newSessionId: string | null = null
let migrationSucceeded = false
if (savedMessages) {
const messages = JSON.parse(savedMessages)
if (Array.isArray(messages) && messages.length > 0) {
const sanitized = sanitizeMessages(messages)
const session: ChatSession = {
id: nanoid(),
title: extractTitle(sanitized),
createdAt: Date.now(),
updatedAt: Date.now(),
messages: sanitized,
xmlSnapshots: savedSnapshots
? JSON.parse(savedSnapshots)
: [],
diagramXml: savedXml || "",
}
const saved = await saveSession(session)
if (saved) {
// Verify the session was actually written
const verified = await getSession(session.id)
if (verified) {
newSessionId = session.id
migrationSucceeded = true
}
}
} else {
// Empty array or invalid data - nothing to migrate, mark as success
migrationSucceeded = true
}
} else {
// No data to migrate - mark as success
migrationSucceeded = true
}
// Only clean up old data if migration succeeded
if (migrationSucceeded) {
localStorage.setItem(MIGRATION_FLAG, "true")
localStorage.removeItem("next-ai-draw-io-messages")
localStorage.removeItem("next-ai-draw-io-xml-snapshots")
localStorage.removeItem("next-ai-draw-io-diagram-xml")
} else {
console.warn(
"Migration to IndexedDB failed - keeping localStorage data for retry",
)
}
return newSessionId
} catch (error) {
console.error("Migration failed:", error)
// Don't mark as migrated - allow retry on next load
return null
}
}

View File

@@ -1,7 +1,13 @@
// Centralized localStorage keys for quota tracking and settings
// Chat data is now stored in IndexedDB via session-storage.ts
// Centralized localStorage keys
// Consolidates all storage keys from chat-panel.tsx and settings-dialog.tsx
export const STORAGE_KEYS = {
// Chat data
messages: "next-ai-draw-io-messages",
xmlSnapshots: "next-ai-draw-io-xml-snapshots",
diagramXml: "next-ai-draw-io-diagram-xml",
sessionId: "next-ai-draw-io-session-id",
// Quota tracking
requestCount: "next-ai-draw-io-request-count",
requestDate: "next-ai-draw-io-request-date",

View File

@@ -6,25 +6,6 @@ export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs))
}
// ============================================================================
// Diagram Constants
// ============================================================================
/**
* Minimum length for a "real" diagram XML (not just empty template).
* Empty mxfile templates are ~147-300 chars; real diagrams are larger.
*/
export const MIN_REAL_DIAGRAM_LENGTH = 300
/**
* Check if diagram XML represents a real diagram (not just empty template).
* @param xml - The diagram XML string to check
* @returns true if the XML is a real diagram with content
*/
export function isRealDiagram(xml: string | undefined | null): boolean {
return !!xml && xml.length > MIN_REAL_DIAGRAM_LENGTH
}
// ============================================================================
// XML Validation/Fix Constants
// ============================================================================

3774
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,6 +1,6 @@
{
"name": "next-ai-draw-io",
"version": "0.4.9",
"version": "0.4.7",
"license": "Apache-2.0",
"private": true,
"main": "dist-electron/main/index.js",
@@ -64,7 +64,6 @@
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"cmdk": "^1.1.1",
"idb": "^8.0.3",
"js-tiktoken": "^1.0.21",
"jsdom": "^27.0.0",
"jsonrepair": "^3.13.1",
@@ -72,7 +71,7 @@
"motion": "^12.23.25",
"negotiator": "^1.0.0",
"next": "^16.0.7",
"ollama-ai-provider-v2": "^2.0.0",
"ollama-ai-provider-v2": "^1.5.4",
"pako": "^2.1.0",
"prism-react-renderer": "^2.4.1",
"react": "^19.1.2",
@@ -90,9 +89,9 @@
"zod": "^4.1.12"
},
"optionalDependencies": {
"@tailwindcss/oxide-linux-x64-gnu": "^4.1.18",
"lightningcss": "^1.30.2",
"lightningcss-linux-x64-gnu": "^1.30.2"
"lightningcss-linux-x64-gnu": "^1.30.2",
"@tailwindcss/oxide-linux-x64-gnu": "^4.1.18"
},
"lint-staged": {
"*.{js,ts,jsx,tsx,json,css}": [

View File

@@ -64,24 +64,6 @@ Add to Cursor MCP config (`~/.cursor/mcp.json`):
}
```
### Cline (VS Code Extension)
1. Click the **MCP Servers** icon in Cline's top menu bar
2. Select the **Configure** tab
3. Click **Configure MCP Servers** to edit `cline_mcp_settings.json`
4. Add the drawio server:
```json
{
"mcpServers": {
"drawio": {
"command": "npx",
"args": ["@next-ai-drawio/mcp-server@latest"]
}
}
}
```
### Claude Code CLI
```bash
@@ -108,7 +90,7 @@ Use the standard MCP configuration with:
- **Natural Language**: Describe diagrams in plain text - flowcharts, architecture diagrams, etc.
- **Edit Support**: Modify existing diagrams with natural language instructions
- **Export**: Save diagrams as `.drawio` files
- **Self-contained**: Embedded server, works offline (except draw.io UI which loads from `embed.diagrams.net` by default, configurable via `DRAWIO_BASE_URL`)
- **Self-contained**: Embedded server, works offline (except draw.io UI which loads from embed.diagrams.net)
## Available Tools
@@ -148,33 +130,6 @@ Use the standard MCP configuration with:
| Variable | Default | Description |
|----------|---------|-------------|
| `PORT` | `6002` | Port for the embedded HTTP server |
| `DRAWIO_BASE_URL` | `https://embed.diagrams.net` | Base URL for the draw.io embed. Set this to use a self-hosted draw.io instance for private deployments. |
### Private Deployment (Self-hosted draw.io)
For security-sensitive environments that require private deployment of draw.io:
```json
{
"mcpServers": {
"drawio": {
"command": "npx",
"args": ["@next-ai-drawio/mcp-server@latest"],
"env": {
"DRAWIO_BASE_URL": "https://drawio.your-company.com"
}
}
}
}
```
You can deploy your own draw.io instance using the official Docker image:
```bash
docker run -d -p 8080:8080 jgraph/drawio
```
Then set `DRAWIO_BASE_URL=http://localhost:8080` (or your server's URL).
## Troubleshooting

View File

@@ -1,18 +1,18 @@
{
"name": "@next-ai-drawio/mcp-server",
"version": "0.1.11",
"version": "0.1.6",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "@next-ai-drawio/mcp-server",
"version": "0.1.11",
"version": "0.1.6",
"license": "Apache-2.0",
"dependencies": {
"@modelcontextprotocol/sdk": "^1.0.4",
"linkedom": "^0.18.0",
"open": "^11.0.0",
"zod": "^4.0.0"
"zod": "^3.24.0"
},
"bin": {
"next-ai-drawio-mcp": "dist/index.js"
@@ -2051,9 +2051,9 @@
}
},
"node_modules/zod": {
"version": "4.3.5",
"resolved": "https://registry.npmjs.org/zod/-/zod-4.3.5.tgz",
"integrity": "sha512-k7Nwx6vuWx1IJ9Bjuf4Zt1PEllcwe7cls3VNzm4CQ1/hgtFUK2bRNG3rvnpPUhFjmqJKAKtjV576KnUkHocg/g==",
"version": "3.25.76",
"resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz",
"integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==",
"license": "MIT",
"peer": true,
"funding": {

View File

@@ -1,6 +1,6 @@
{
"name": "@next-ai-drawio/mcp-server",
"version": "0.1.11",
"version": "0.1.10",
"description": "MCP server for Next AI Draw.io - AI-powered diagram generation with real-time browser preview",
"type": "module",
"main": "dist/index.js",
@@ -39,7 +39,7 @@
"@modelcontextprotocol/sdk": "^1.0.4",
"linkedom": "^0.18.0",
"open": "^11.0.0",
"zod": "^4.0.0"
"zod": "^3.24.0"
},
"devDependencies": {
"@types/node": "^24.0.0",

View File

@@ -13,45 +13,6 @@ import {
} from "./history.js"
import { log } from "./logger.js"
// Configurable draw.io embed URL for private deployments
const DRAWIO_BASE_URL =
process.env.DRAWIO_BASE_URL || "https://embed.diagrams.net"
// Extract origin (scheme + host + port) from URL for postMessage security check
function getOrigin(url: string): string {
try {
const parsed = new URL(url)
return `${parsed.protocol}//${parsed.host}`
} catch {
return url // Fallback if parsing fails
}
}
const DRAWIO_ORIGIN = getOrigin(DRAWIO_BASE_URL)
// Minimal blank diagram used to bootstrap new sessions.
// This avoids the draw.io embed spinner (spin=1) getting stuck when no `load(xml)` is ever sent.
const DEFAULT_DIAGRAM_XML = `<mxfile host="app.diagrams.net"><diagram id="blank" name="Page-1"><mxGraphModel><root><mxCell id="0"/><mxCell id="1" parent="0"/></root></mxGraphModel></diagram></mxfile>`
// Normalize URL for iframe src - ensure no double slashes
function normalizeUrl(url: string): string {
// Remove trailing slash to avoid double slashes
return url.replace(/\/$/, "")
}
function isLikelyMcpSessionId(sessionId: string): boolean {
// Keep this cheap and conservative to avoid creating state for arbitrary IDs.
return sessionId.startsWith("mcp-") && sessionId.length <= 128
}
function ensureSessionStateInitialized(sessionId: string): void {
if (!sessionId) return
if (!isLikelyMcpSessionId(sessionId)) return
if (stateStore.has(sessionId)) return
setState(sessionId, DEFAULT_DIAGRAM_XML)
}
interface SessionState {
xml: string
version: number
@@ -166,12 +127,7 @@ function cleanupExpiredSessions(): void {
}
}
const cleanupIntervalId = setInterval(cleanupExpiredSessions, 5 * 60 * 1000)
export function shutdown(): void {
clearInterval(cleanupIntervalId)
stopHttpServer()
}
setInterval(cleanupExpiredSessions, 5 * 60 * 1000)
export function getServerPort(): number {
return serverPort
@@ -194,11 +150,8 @@ function handleRequest(
}
if (url.pathname === "/" || url.pathname === "/index.html") {
const sessionId = url.searchParams.get("mcp") || ""
ensureSessionStateInitialized(sessionId)
res.writeHead(200, { "Content-Type": "text/html" })
res.end(getHtmlPage(sessionId))
res.end(getHtmlPage(url.searchParams.get("mcp") || ""))
} else if (url.pathname === "/api/state") {
handleStateApi(req, res, url)
} else if (url.pathname === "/api/history") {
@@ -225,7 +178,6 @@ function handleStateApi(
res.end(JSON.stringify({ error: "sessionId required" }))
return
}
ensureSessionStateInitialized(sessionId)
const state = stateStore.get(sessionId)
res.writeHead(200, { "Content-Type": "application/json" })
res.end(
@@ -446,7 +398,7 @@ function getHtmlPage(sessionId: string): string {
</div>
<div id="status" class="status disconnected">Connecting...</div>
</div>
<iframe id="drawio" src="${normalizeUrl(DRAWIO_BASE_URL)}/?embed=1&proto=json&spin=1&libraries=1"></iframe>
<iframe id="drawio" src="https://embed.diagrams.net/?embed=1&proto=json&spin=1&libraries=1"></iframe>
</div>
<button id="history-btn" title="History" ${sessionId ? "" : "disabled"}>
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
@@ -476,7 +428,7 @@ function getHtmlPage(sessionId: string): string {
let pendingAiSvg = false;
window.addEventListener('message', (e) => {
if (e.origin !== '${DRAWIO_ORIGIN}') return;
if (e.origin !== 'https://embed.diagrams.net') return;
try {
const msg = JSON.parse(e.data);
if (msg.event === 'init') {

View File

@@ -39,7 +39,6 @@ import {
getState,
requestSync,
setState,
shutdown,
startHttpServer,
waitForSync,
} from "./http-server.js"
@@ -48,7 +47,7 @@ import { validateAndFixXml } from "./xml-validation.js"
// Server configuration
const config = {
port: parseInt(process.env.PORT || "6002", 10),
port: parseInt(process.env.PORT || "6002"),
}
// Session state (single session for simplicity)
@@ -619,31 +618,6 @@ server.registerTool(
},
)
// Graceful shutdown handler
let isShuttingDown = false
function gracefulShutdown(reason: string) {
if (isShuttingDown) return
isShuttingDown = true
log.info(`Shutting down: ${reason}`)
shutdown()
process.exit(0)
}
// Handle stdin close (primary method - works on all platforms including Windows)
process.stdin.on("close", () => gracefulShutdown("stdin closed"))
process.stdin.on("end", () => gracefulShutdown("stdin ended"))
// Handle signals (may not work reliably on Windows)
process.on("SIGINT", () => gracefulShutdown("SIGINT"))
process.on("SIGTERM", () => gracefulShutdown("SIGTERM"))
// Handle broken pipe (writing to closed stdout)
process.stdout.on("error", (err) => {
if (err.code === "EPIPE" || err.code === "ERR_STREAM_DESTROYED") {
gracefulShutdown("stdout error")
}
})
// Start the MCP server
async function main() {
log.info("Starting MCP server for Next AI Draw.io (embedded mode)...")

View File

@@ -253,7 +253,7 @@ async function main() {
},
)
console.log("👀 Watching for preset configuration changes...")
} catch (_err) {
} catch (err) {
// File might not exist yet, that's ok
setTimeout(setupConfigWatcher, 5000)
}