Compare commits

..

30 Commits

Author SHA1 Message Date
renovate[bot]
a2bdbd4997 fix(deps): update dependency zod to v4 2026-01-04 10:08:03 +00:00
Dayuan Jiang
3ce047f794 chore: revert version to 0.4.9 (#509) 2026-01-04 15:32:42 +09:00
Dayuan Jiang
2c2d35940b chore: bump version to 0.4.10 (#508) 2026-01-04 15:29:17 +09:00
Dayuan Jiang
02366cabfb fix: remove draw.io native save button to prevent duplicate save dialogs (#507) 2026-01-04 15:22:46 +09:00
Dayuan Jiang
3e0c3bcb36 chore: bump version to 0.4.9 (#505) 2026-01-04 14:45:09 +09:00
Rohit Chavan
ce2237f92e Show success toast after saving diagram (#484)
* Add success toast after saving diagram

* fix: correct save toast placement

* Changes made:
1. Added i18n support
2. Fixed the issue where the save toast was running only once

* fix: show toast after download completes, not when dialog opens

Move toast from handleDrawioSave (dialog open) to saveDiagramToFile
(after download). Also restore the duplicate-save guard that was removed.

---------

Co-authored-by: Biki Kalita <86558912+Biki-dev@users.noreply.github.com>
Co-authored-by: dayuan.jiang <jdy.toh@gmail.com>
2026-01-04 13:11:32 +09:00
Dayuan Jiang
2637da3215 fix: restore draw.io native save button functionality (#503)
PR #442 accidentally changed showSaveDialog from using the diagram
context to local state, breaking draw.io's native save button (Ctrl+S)
that was fixed in PR #296.

This restores the original behavior by using the context's showSaveDialog
so both draw.io native save and the download button open the same dialog.
2026-01-04 12:49:57 +09:00
Dayuan Jiang
24325c178f refactor: extract ToolCallCard and ChatLobby components (#502)
* refactor: extract ToolCallCard and ChatLobby components

- Extract ToolCallCard.tsx (279 lines) for tool call UI rendering
- Extract ChatLobby.tsx (272 lines) for empty state with session history
- Reduce chat-message-display.tsx from 1760 to 1307 lines (-26%)

* fix: address PR review feedback

- Remove redundant key prop in ToolCallCard
- Make onDeleteSession optional and conditionally render delete button
- Extract shared types (DiagramOperation, ToolPartLike) to types.ts
2026-01-04 12:04:06 +09:00
Dayuan Jiang
814f448cb0 fix: disable new chat button during streaming (#501) 2026-01-04 11:28:24 +09:00
Dayuan Jiang
4dc774d03f feat: add chat session history with IndexedDB persistence (#500)
* feat(session): add chat session history with IndexedDB storage

- Add session-storage.ts with IndexedDB wrapper using idb library
- Add use-session-manager.ts hook for session state management
- Add session-history-dropdown.tsx for session selection UI
- Integrate session system into chat-panel.tsx
- Auto-generate session titles from first user message
- Auto-save sessions on message completion
- Support session switching, deletion, and creation
- Migrate existing localStorage data to IndexedDB
- Add i18n translations for session history UI

* feat(session): improve history dropdown and persist diagram history

- Add time-based grouping (Today, Yesterday, This Week, Earlier)
- Add thumbnail previews using Next.js Image component
- Add staggered entrance animations with fade-in effects
- Improve active session indicator with left border accent
- Fix scrolling by using native overflow instead of ScrollArea
- Persist diagram version history to IndexedDB sessions
- Remove redundant diagram XML from localStorage
- Add i18n strings for time group labels (en, ja, zh)

* fix(session): prevent data loss on theme change and tab close

- Add isDrawioReady effect to restore diagram after DrawIO remount
- Add visibilitychange handler to save session when page becomes hidden
- Fix missing currentSessionId in saveCurrentSession dependency array
- Remove unused sanitizeMessages import from use-session-manager

* fix(session): fix diagram save and migration data loss bugs

- Add diagramHistory to save effect dependency array so diagram-only
  edits trigger saves (previously only message changes did)
- Destructure stable sessionManager values to prevent unnecessary
  effect re-runs on every render
- Add try-catch wrapper around debounced async save operation
- Make saveSession() return boolean to indicate success/failure
- Verify IndexedDB write succeeded before deleting localStorage data
  during migration (prevents data loss if write silently fails)
- Keep localStorage data for retry if migration fails instead of
  marking as complete anyway

* refactor(session): extract helpers to reduce code duplication

- Add syncUIWithSession helper to consolidate 4 duplicate UI sync blocks
- Add buildSessionData helper to consolidate 4 duplicate save logic blocks
- Remove unused saveTimeoutRef and its cleanup effect
- Net reduction of ~80 lines of duplicate code

* style(ui): improve history dropdown and delete dialog styling

- Change destructive color from coral to muted rose for refined look
- Make session history panel taller (400px fixed height)
- Fix popover alignment to prevent truncation
- Style delete button with soft red outline instead of solid fill
- Make delete dialog more compact (max-w-sm)

* fix(session): reset refs on new chat and show recent sessions

- Fix cached example diagrams not displaying after creating new session
- Reset previousXML, lastProcessedXmlRef and processedToolCalls when
  messages become empty (new chat or session switch)
- Add recent chats section in empty chat state with collapsible examples
- Pass sessions and onSelectSession to ChatMessageDisplay
- Add loadedMessageIdsRef to skip animations on session restore
- Add debug console.log for diagram processing flow

* feat(session): add search bar and improve history UI

- Remove session history dropdown, use main panel instead
- Add search bar to filter history chats by title
- Show minutes (Xm ago) instead of "Just now" for recent sessions
- Scroll to top when switching to new/empty chat
- Remove title truncation limit for better searchability
- Remove debug console.log statements

* refactor: remove redundant code and fix nested button hydration error

- Remove unused 'sessions' from deleteSession dependency array
- Remove unused 'switchedTo' variable and simplify return type
- Remove unused 'restoredMessageIdsRef' (always empty)
- Fix nested button hydration error by using div with role=button
- Simplify handleDeleteSession callback

* fix(session): fix migration bug, improve metadata perf, truncate titles

- Fix migration retry loop when localStorage has empty array
- Use cursor-based iteration for getAllSessionMetadata
- Truncate session titles to 100 chars with ellipsis

* refactor: remove dead code and extract diagram length constant

- Remove unused exports: getAllSessions, createNewSession, updateSessionTitle
- Remove write-only CURRENT_SESSION_KEY and all localStorage calls
- Remove dead messagesEndRef and unused scroll effect
- Extract magic number 300 to MIN_REAL_DIAGRAM_LENGTH constant
- Add isRealDiagram() helper function for semantic clarity
2026-01-04 10:25:19 +09:00
Dayuan Jiang
bc22b7c315 fix(docker): fix invalid YAML syntax in docker-compose.yml (#498)
Empty environment mapping caused validation error:
'services.next-ai-draw-io.environment must be a mapping'
2026-01-03 14:20:43 +09:00
renovate[bot]
8c1cc19d94 fix(deps): update dependency ollama-ai-provider-v2 to v2 (#497)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-01-03 13:32:49 +09:00
renovate[bot]
03c3ae6d5b chore(deps): update core framework packages (#495)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-01-03 12:16:46 +09:00
renovate[bot]
ddde0654a6 chore(deps): update minor and patch dependencies (#496)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-01-03 12:16:02 +09:00
Yu Peng
bc5709267c fix(mcp): prevent stuck spinner by initializing blank session state (#494)
* fix(mcp): initialize blank state to avoid stuck spinner

* style: fix formatting

---------

Co-authored-by: dayuan.jiang <jdy.toh@gmail.com>
2026-01-03 12:05:38 +09:00
Dayuan Jiang
6fbc7b340f fix: move toast notifications to bottom-left (#489) 2026-01-01 22:08:05 +09:00
Dayuan Jiang
3c8f420c3c docs: add Cline MCP configuration instructions (#488) 2026-01-01 21:47:46 +09:00
Dayuan Jiang
f240c494ac fix: use npm install instead of npm ci in electron workflow (#487) 2026-01-01 17:42:39 +09:00
Dayuan Jiang
a22d7025a3 fix: sync package-lock.json (#486) 2026-01-01 17:32:25 +09:00
Dayuan Jiang
2159db5586 chore: bump version to 0.4.8 (#485) 2026-01-01 17:23:42 +09:00
Dayuan Jiang
ada06260db fix: faster message restore and skip panel animation on refresh (#483)
* fix: faster message restore and skip panel animation on refresh

- Use useLayoutEffect for localStorage restore (runs before paint)
- Track visibility changes to only animate panel when toggling, not on page load
- Use cn() utility for cleaner conditional className

* fix: reset animation state after completion for re-animation support

* revert: remove unnecessary animation reset timer
2026-01-01 16:25:39 +09:00
Dayuan Jiang
02527526ba fix: prevent flash of example panel and animations on page refresh (#482)
- Add isRestored state to track when localStorage restoration completes
- Show example panel only after confirming no saved messages exist
- Skip message animations for restored messages
- Default tool calls and reasoning blocks to collapsed for restored messages
2026-01-01 15:42:48 +09:00
Dayuan Jiang
77a2f6f6fa fix: hide Draw.io loading flash with placeholder (#481)
* fix: hide Draw.io loading flash with placeholder

* style: auto-format with Biome

---------

Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2026-01-01 15:20:00 +09:00
LiuJing
493ee168b1 feat(mcp-server): add DRAWIO_BASE_URL env for private deployments (#467)
* feat(mcp-server): add DRAWIO_BASE_URL env for private deployments

* Fix postMessage origin check and URL normalization

- Add getOrigin() function to extract scheme+host+port from DRAWIO_BASE_URL
- Use DRAWIO_ORIGIN for postMessage security check instead of full URL
- Add normalizeUrl() to remove trailing slash and avoid double slashes
- This fixes issues when users configure DRAWIO_BASE_URL with trailing slash or path
2026-01-01 14:47:39 +09:00
Dayuan Jiang
037f32973a fix: resolve biome lint errors blocking CI (#480)
- Update biome schema version from 2.3.8 to 2.3.10
- Add radix parameter to parseInt in mcp-server
- Remove unnecessary React fragment in model-config-dialog
- Fix unused variable errors (err -> _err)
- Auto-format code with biome
2026-01-01 14:45:46 +09:00
Dayuan Jiang
7bdc1fe612 fix(mcp-server): add graceful shutdown to prevent zombie processes (#477)
* fix(mcp-server): add graceful shutdown to prevent zombie processes

Add lifecycle handlers to properly exit the MCP server when the parent
application closes:

- Listen for stdin close/end events (primary method for all platforms)
- Handle SIGINT/SIGTERM signals
- Handle stdout broken pipe errors
- Export shutdown() function from http-server to clean up resources

* chore(mcp-server): bump version to 0.1.11
2025-12-31 18:38:20 +09:00
Dayuan Jiang
03ac9a79de fix: detect models that don't support image input and return clear error (#474)
Some models (Kimi K2, DeepSeek, Qwen text models) don't support image/vision
input. The AI SDK silently drops unsupported image parts, causing confusing
responses where the model acts as if no image was uploaded.

Added supportsImageInput() function to detect unsupported models by name,
and return a 400 error with clear guidance when users try to upload images
to these models.

Closes #469
2025-12-31 12:20:09 +09:00
E66Crisp
f97934d6e0 feat(i18n): sync Draw.io panel language with app locale (#473) 2025-12-31 11:48:02 +09:00
E66Crisp
73a36cf9de style(chat-panel): Improve aiChat label display in collapsed panel (#470)
* style(chat-panel): Improve aiChat label display in collapsed panel

* fix: update qs to fix high severity security vulnerability

---------

Co-authored-by: dayuan.jiang <jdy.toh@gmail.com>
2025-12-31 11:25:23 +09:00
Dayuan Jiang
69f9df1792 fix: improve image not supported error detection for DeepSeek (#468) 2025-12-31 00:12:19 +09:00
32 changed files with 6329 additions and 1864 deletions

View File

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

View File

@@ -1,6 +1,6 @@
"use client"
import { usePathname, useRouter } from "next/navigation"
import { useCallback, useEffect, useRef, useState } from "react"
import { Suspense, 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,49 +17,23 @@ const drawioBaseUrl =
process.env.NEXT_PUBLIC_DRAWIO_BASE_URL || "https://embed.diagrams.net"
export default function Home() {
const {
drawioRef,
handleDiagramExport,
onDrawioLoad,
resetDrawioReady,
saveDiagramToStorage,
showSaveDialog,
setShowSaveDialog,
} = useDiagram()
const { drawioRef, handleDiagramExport, onDrawioLoad, resetDrawioReady } =
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
@@ -102,24 +76,29 @@ export default function Home() {
setIsLoaded(true)
}, [pathname, router])
const handleDarkModeChange = async () => {
await saveDiagramToStorage()
const handleDrawioLoad = useCallback(() => {
setIsDrawioReady(true)
onDrawioLoad()
}, [onDrawioLoad])
const handleDarkModeChange = () => {
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 = async () => {
await saveDiagramToStorage()
const handleDrawioUiChange = () => {
const newUi = drawioUi === "min" ? "sketch" : "min"
localStorage.setItem("drawio-theme", newUi)
setDrawioUi(newUi)
setIsDrawioReady(false)
resetDrawioReady()
}
// Check mobile - save diagram and reset draw.io before crossing breakpoint
// Check mobile - reset draw.io before crossing breakpoint
const isInitialRenderRef = useRef(true)
useEffect(() => {
const checkMobile = () => {
@@ -128,7 +107,7 @@ export default function Home() {
!isInitialRenderRef.current &&
newIsMobile !== isMobileRef.current
) {
saveDiagramToStorage().catch(() => {})
setIsDrawioReady(false)
resetDrawioReady()
}
isMobileRef.current = newIsMobile
@@ -139,7 +118,7 @@ export default function Home() {
checkMobile()
window.addEventListener("resize", checkMobile)
return () => window.removeEventListener("resize", checkMobile)
}, [saveDiagramToStorage, resetDrawioReady])
}, [resetDrawioReady])
const toggleChatPanel = () => {
const panel = chatPanelRef.current
@@ -197,34 +176,36 @@ 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">
{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 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>
)}
</div>
@@ -247,16 +228,24 @@ export default function Home() {
onExpand={() => setIsChatVisible(true)}
>
<div className={`h-full ${isMobile ? "p-1" : "py-2 pr-2"}`}>
<ChatPanel
isVisible={isChatVisible}
onToggleVisibility={toggleChatPanel}
drawioUi={drawioUi}
onToggleDrawioUi={handleDrawioUiChange}
darkMode={darkMode}
onToggleDarkMode={handleDarkModeChange}
isMobile={isMobile}
onCloseProtectionChange={setCloseProtection}
/>
<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>
</div>
</ResizablePanel>
</ResizablePanelGroup>

View File

@@ -12,7 +12,11 @@ import fs from "fs/promises"
import { jsonrepair } from "jsonrepair"
import path from "path"
import { z } from "zod"
import { getAIModel, supportsPromptCaching } from "@/lib/ai-providers"
import {
getAIModel,
supportsImageInput,
supportsPromptCaching,
} from "@/lib/ai-providers"
import { findCachedResponse } from "@/lib/cached-responses"
import {
checkAndIncrementRequest,
@@ -295,6 +299,17 @@ 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);
/* Coral destructive */
--destructive: oklch(0.6 0.2 25);
/* Muted rose destructive */
--destructive: oklch(0.45 0.12 10);
/* 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.65 0.22 25);
--destructive: oklch(0.55 0.12 10);
--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.8/schema.json",
"$schema": "https://biomejs.dev/schemas/2.3.10/schema.json",
"vcs": {
"enabled": true,
"clientKind": "git",

View File

@@ -70,9 +70,11 @@ function ExampleCard({
export default function ExamplePanel({
setInput,
setFiles,
minimal = false,
}: {
setInput: (input: string) => void
setFiles: (files: File[]) => void
minimal?: boolean
}) {
const dict = useDictionary()
@@ -120,49 +122,55 @@ export default function ExamplePanel({
}
return (
<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 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>
<p className="text-xs text-muted-foreground">
{dict.examples.mcpDescription}
</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>
</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">
<p className="text-xs font-medium text-muted-foreground uppercase tracking-wider px-1">
{dict.examples.quickExamples}
</p>
{!minimal && (
<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,7 +6,6 @@ import {
Image as ImageIcon,
Loader2,
Send,
Trash2,
} from "lucide-react"
import type React from "react"
import { useCallback, useEffect, useRef, useState } from "react"
@@ -15,7 +14,6 @@ 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"
@@ -140,7 +138,6 @@ 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<
@@ -163,7 +160,6 @@ export function ChatInput({
status,
onSubmit,
onChange,
onClearChat,
files = [],
onFileChange = () => {},
pdfData = new Map(),
@@ -176,14 +172,17 @@ export function ChatInput({
onConfigureModels = () => {},
}: ChatInputProps) {
const dict = useDictionary()
const { diagramHistory, saveDiagramToFile } = useDiagram()
const {
diagramHistory,
saveDiagramToFile,
showSaveDialog,
setShowSaveDialog,
} = 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
@@ -313,11 +312,6 @@ export function ChatInput({
}
}
const handleClear = () => {
onClearChat()
setShowClearDialog(false)
}
return (
<form
onSubmit={onSubmit}
@@ -353,104 +347,81 @@ 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-between px-3 py-2 border-t border-border/50">
<div className="flex items-center justify-end gap-1 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={() => setShowClearDialog(true)}
tooltipContent={dict.chat.clearConversation}
className="h-8 w-8 p-0 text-muted-foreground hover:text-destructive hover:bg-destructive/10"
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"
>
<Trash2 className="h-4 w-4" />
<History className="h-4 w-4" />
</ButtonWithTooltip>
<ResetWarningModal
open={showClearDialog}
onOpenChange={setShowClearDialog}
onClear={handleClear}
/>
</div>
<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()}
<ButtonWithTooltip
type="button"
variant="ghost"
size="sm"
className="h-8 px-4 rounded-xl font-medium shadow-sm"
aria-label={
isDisabled ? dict.chat.sending : dict.chat.send
}
onClick={() => setShowSaveDialog(true)}
disabled={isDisabled}
tooltipContent={dict.chat.saveDiagram}
className="h-8 w-8 p-0 text-muted-foreground hover:text-foreground"
>
{isDisabled ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<>
<Send className="h-4 w-4 mr-1.5" />
{dict.chat.send}
</>
)}
</Button>
<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>
<HistoryDialog
@@ -461,7 +432,12 @@ export function ChatInput({
open={showSaveDialog}
onOpenChange={setShowSaveDialog}
onSave={(filename, format) =>
saveDiagramToFile(filename, format, sessionId)
saveDiagramToFile(
filename,
format,
sessionId,
dict.save.savedSuccessfully,
)
}
defaultFilename={`diagram-${new Date()
.toISOString()

View File

@@ -7,7 +7,6 @@ import {
ChevronDown,
ChevronUp,
Copy,
Cpu,
FileCode,
FileText,
Pencil,
@@ -26,6 +25,9 @@ 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"
@@ -33,18 +35,10 @@ 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(
@@ -58,60 +52,10 @@ 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)
@@ -183,6 +127,13 @@ 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
@@ -193,6 +144,11 @@ 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({
@@ -205,14 +161,33 @@ 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>(
@@ -283,7 +258,7 @@ export function ChatMessageDisplay({
try {
await navigator.clipboard.writeText(text)
setCopyState(messageId, isToolCall, true)
} catch (err) {
} catch (_err) {
// Fallback for non-secure contexts (HTTP) or permission denied
const textarea = document.createElement("textarea")
textarea.value = text
@@ -347,7 +322,6 @@ 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
@@ -371,14 +345,8 @@ export function ChatMessageDisplay({
const parseError = testDoc.querySelector("parsererror")
if (parseError) {
// 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)
// Only show toast if this is the final XML (not during streaming)
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
@@ -392,18 +360,12 @@ 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
}
@@ -413,30 +375,12 @@ 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(
"[ChatMessageDisplay] Error processing XML:",
error,
)
console.error("Error processing XML:", error)
// Only show toast if this is the final XML (not during streaming)
if (showToast) {
toast.error(dict.errors.failedToProcess)
@@ -447,8 +391,22 @@ export function ChatMessageDisplay({
[chartXML, onDisplayChart],
)
// Track previous message count to detect bulk loads vs streaming
const prevMessageCountRef = useRef(0)
useEffect(() => {
if (messagesEndRef.current) {
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
messagesEndRef.current.scrollIntoView({ behavior: "smooth" })
}
}, [messages])
@@ -666,202 +624,19 @@ 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 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) {
copyMessageToClipboard(callId, textToCopy, true)
}
}
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">
{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>
)
}
return (
<ScrollArea className="h-full w-full scrollbar-thin">
{messages.length === 0 ? (
<ExamplePanel setInput={setInput} setFiles={setFiles} />
) : (
<div ref={scrollTopRef} />
{messages.length === 0 && isRestored ? (
<ChatLobby
sessions={sessions}
onSelectSession={onSelectSession || (() => {})}
onDeleteSession={onDeleteSession}
setInput={setInput}
setFiles={setFiles}
dict={dict}
/>
) : messages.length === 0 ? null : (
<div className="py-4 px-4 space-y-4">
{messages.map((message, messageIndex) => {
const userMessageText =
@@ -881,13 +656,21 @@ 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"} animate-message-in`}
style={{
animationDelay: `${messageIndex * 50}ms`,
}}
className={`flex w-full ${message.role === "user" ? "justify-end" : "justify-start"} ${isRestoredMessage ? "" : "animate-message-in"}`}
style={
isRestoredMessage
? undefined
: {
animationDelay: `${messageIndex * 50}ms`,
}
}
>
{message.role === "user" &&
userMessageText &&
@@ -984,6 +767,9 @@ export function ChatMessageDisplay({
isStreaming={
isStreamingReasoning
}
defaultOpen={
!isRestoredMessage
}
>
<ReasoningTrigger />
<ReasoningContent>
@@ -1126,9 +912,30 @@ export function ChatMessageDisplay({
return groups.map(
(group, groupIndex) => {
if (group.type === "tool") {
return renderToolPart(
group
.parts[0] as ToolPartLike,
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}
/>
)
}

View File

@@ -9,34 +9,39 @@ import {
Settings,
} from "lucide-react"
import Image from "next/image"
import { useRouter, useSearchParams } from "next/navigation"
import type React from "react"
import { useCallback, useEffect, useRef, useState } from "react"
import {
useCallback,
useEffect,
useLayoutEffect,
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 { formatXML } from "@/lib/utils"
import { cn, formatXML, isRealDiagram } 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"
@@ -113,10 +118,17 @@ 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([
@@ -152,11 +164,14 @@ 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)
@@ -201,13 +216,37 @@ 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)
@@ -289,32 +328,6 @@ 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
@@ -360,15 +373,7 @@ export default function ChatPanel({
setShowSettingsDialog(true)
}
},
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)
},
onFinish: () => {},
sendAutomaticallyWhen: ({ messages }) => {
const isInContinuationMode = partialXmlRef.current.length > 0
@@ -426,58 +431,199 @@ export default function ChatPanel({
messagesRef.current = messages
}, [messages])
const messagesEndRef = useRef<HTMLDivElement>(null)
// Track last synced session ID to detect external changes (e.g., URL back/forward)
const lastSyncedSessionIdRef = useRef<string | null>(null)
// Restore messages and XML snapshots from localStorage on mount
useEffect(() => {
// 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(() => {
if (hasRestoredRef.current) return
if (sessionManager.isLoading) return // Wait for session manager to load
hasRestoredRef.current = true
try {
// 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)
const currentSession = sessionManager.currentSession
if (currentSession && currentSession.messages.length > 0) {
// Restore from session manager (IndexedDB)
justLoadedSessionRef.current = true
syncUIWithSession(currentSession)
}
// 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 from localStorage:", error)
// On complete failure, clear storage to allow recovery
localStorage.removeItem(STORAGE_MESSAGES_KEY)
localStorage.removeItem(STORAGE_XML_SNAPSHOTS_KEY)
console.error("Failed to restore session:", error)
toast.error(dict.errors.sessionCorrupted)
} finally {
setIsRestored(true)
}
}, [setMessages])
}, [
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
// 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(() => {
localStorageDebounceRef.current = setTimeout(async () => {
try {
localStorage.setItem(
STORAGE_MESSAGES_KEY,
JSON.stringify(messages),
)
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,
)
}
} catch (error) {
console.error("Failed to save messages to localStorage:", error)
console.error("Failed to save session:", error)
}
}, LOCAL_STORAGE_DEBOUNCE_MS)
@@ -487,63 +633,62 @@ export default function ChatPanel({
clearTimeout(localStorageDebounceRef.current)
}
}
}, [messages])
}, [
messages,
status,
sessionIsAvailable,
currentSessionId,
buildSessionData,
])
// 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,
)
// 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,
})
}
}, [])
}, [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 (messagesEndRef.current) {
messagesEndRef.current.scrollIntoView({ behavior: "smooth" })
}
}, [messages])
if (!sessionManager.isAvailable) return
// 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)
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,
)
}
localStorage.setItem(STORAGE_SESSION_ID_KEY, sessionId)
} catch (error) {
console.error("Failed to persist state before unload:", error)
}
}
window.addEventListener("beforeunload", handleBeforeUnload)
document.addEventListener("visibilitychange", handleVisibilityChange)
return () =>
window.removeEventListener("beforeunload", handleBeforeUnload)
}, [sessionId])
document.removeEventListener(
"visibilitychange",
handleVisibilityChange,
)
}, [sessionManager, buildSessionData])
const onFormSubmit = async (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault()
@@ -627,7 +772,6 @@ 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)
@@ -641,30 +785,97 @@ export default function ChatPanel({
}
}
const handleNewChat = useCallback(() => {
// 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)
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()
// 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)
}
sessionStorage.removeItem(SESSION_STORAGE_INPUT_KEY)
toast.success(dict.dialogs.clearSuccess)
setShowNewChatDialog(false)
}, [clearDiagram, handleFileChange, setMessages, setSessionId])
// 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,
])
const handleInputChange = (
e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>,
@@ -701,7 +912,6 @@ export default function ChatPanel({
xmlSnapshotsRef.current.delete(key)
}
}
saveXmlSnapshots()
}
// Send chat message with headers
@@ -905,7 +1115,6 @@ 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}
@@ -916,12 +1125,16 @@ export default function ChatPanel({
// Full view
return (
<div className="h-full flex flex-col bg-card shadow-soft animate-slide-in-right rounded-xl border border-border/30 relative">
<div
className={cn(
"h-full flex flex-col bg-card shadow-soft rounded-xl border border-border/30 relative",
shouldAnimatePanel && "animate-slide-in-right",
)}
>
<Toaster
position="bottom-center"
position="bottom-left"
richColors
expand
style={{ position: "absolute" }}
toastOptions={{
style: {
maxWidth: "480px",
@@ -934,7 +1147,15 @@ 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">
<div className="flex items-center gap-2 overflow-x-hidden">
<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">
<Image
src={
@@ -953,14 +1174,17 @@ export default function ChatPanel({
Next AI Drawio
</h1>
</div>
</div>
</button>
<div className="flex items-center gap-1 justify-end overflow-visible">
<ButtonWithTooltip
tooltipContent={dict.nav.newChat}
variant="ghost"
size="icon"
onClick={() => setShowNewChatDialog(true)}
className="hover:bg-accent"
onClick={handleNewChat}
disabled={
status === "streaming" || status === "submitted"
}
className="hover:bg-accent disabled:opacity-50 disabled:cursor-not-allowed"
>
<MessageSquarePlus
className={`${isMobile ? "h-4 w-4" : "h-5 w-5"} text-muted-foreground`}
@@ -1007,6 +1231,11 @@ export default function ChatPanel({
onRegenerate={handleRegenerate}
status={status}
onEditMessage={handleEditMessage}
isRestored={isRestored}
sessions={sessionManager.sessions}
onSelectSession={handleSelectSession}
onDeleteSession={handleDeleteSession}
loadedMessageIdsRef={loadedMessageIdsRef}
/>
</main>
@@ -1030,7 +1259,6 @@ export default function ChatPanel({
status={status}
onSubmit={onFormSubmit}
onChange={handleInputChange}
onClearChat={handleNewChat}
files={files}
onFileChange={handleFileChange}
pdfData={pdfData}
@@ -1061,12 +1289,6 @@ export default function ChatPanel({
onOpenChange={setShowModelConfigDialog}
modelConfig={modelConfig}
/>
<ResetWarningModal
open={showNewChatDialog}
onOpenChange={setShowNewChatDialog}
onClear={handleNewChat}
/>
</div>
)
}

View File

@@ -0,0 +1,274 @@
"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

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

16
components/chat/types.ts Normal file
View File

@@ -0,0 +1,16 @@
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

@@ -3,15 +3,20 @@
import type React from "react"
import { createContext, useContext, useEffect, useRef, useState } from "react"
import type { DrawIoEmbedRef } from "react-drawio"
import { STORAGE_DIAGRAM_XML_KEY } from "@/components/chat-panel"
import { toast } from "sonner"
import type { ExportFormat } from "@/components/save-dialog"
import { getApiEndpoint } from "@/lib/base-path"
import { extractDiagramXML, validateAndFixXml } from "../lib/utils"
import {
extractDiagramXML,
isRealDiagram,
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
@@ -23,8 +28,9 @@ interface DiagramContextType {
filename: string,
format: ExportFormat,
sessionId?: string,
successMessage?: string,
) => void
saveDiagramToStorage: () => Promise<void>
getThumbnailSvg: () => Promise<string | null>
isDrawioReady: boolean
onDrawioLoad: () => void
resetDrawioReady: () => void
@@ -41,72 +47,52 @@ 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 from localStorage
// Track if diagram has been restored after DrawIO remount (e.g., theme change)
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)
}
// Restore diagram XML when DrawIO becomes ready
// eslint-disable-next-line react-hooks/exhaustive-deps -- loadDiagram uses refs internally and is stable
// Keep chartXMLRef in sync with state for restoration after remount
useEffect(() => {
// Reset restore flag when DrawIO is not ready (e.g., theme/UI change remounts it)
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)
if (!isDrawioReady) {
hasDiagramRestoredRef.current = false
setCanSaveDiagram(false)
return
}
// Only restore once per ready cycle
if (hasDiagramRestoredRef.current) return
hasDiagramRestoredRef.current = true
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)
// Restore diagram from ref if we have one
const xmlToRestore = chartXMLRef.current
if (isRealDiagram(xmlToRestore) && drawioRef.current) {
drawioRef.current.load({ xml: xmlToRestore })
}
// 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
@@ -132,27 +118,32 @@ export function DiagramProvider({ children }: { children: React.ReactNode }) {
}
}
// Save current diagram to localStorage (used before theme/UI changes)
const saveDiagramToStorage = async (): Promise<void> => {
if (!drawioRef.current) return
// 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
try {
const currentXml = await Promise.race([
const svgData = 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")), 2000),
setTimeout(() => reject(new Error("Export timeout")), 3000),
),
])
// Only save if diagram has meaningful content (not empty template)
if (currentXml && currentXml.length > 300) {
localStorage.setItem(STORAGE_DIAGRAM_XML_KEY, currentXml)
// Update latestSvg so it's available for future saves
if (svgData?.includes("<svg")) {
setLatestSvg(svgData)
return svgData
}
} catch (error) {
console.error("Failed to save diagram to storage:", error)
return null
} catch {
// Timeout is expected occasionally - don't log as error
return null
}
}
@@ -247,6 +238,7 @@ 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")
@@ -273,9 +265,6 @@ 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
@@ -311,6 +300,14 @@ 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)
@@ -346,6 +343,7 @@ export function DiagramProvider({ children }: { children: React.ReactNode }) {
chartXML,
latestSvg,
diagramHistory,
setDiagramHistory,
loadDiagram,
handleExport,
handleExportWithoutHistory,
@@ -354,7 +352,7 @@ export function DiagramProvider({ children }: { children: React.ReactNode }) {
handleDiagramExport,
clearDiagram,
saveDiagramToFile,
saveDiagramToStorage,
getThumbnailSvg,
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,7 +11,6 @@ import {
flattenModels,
type ModelConfig,
type MultiModelConfig,
PROVIDER_INFO,
type ProviderConfig,
type ProviderName,
} from "@/lib/types/model-config"

View File

@@ -0,0 +1,322 @@
"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,3 +906,34 @@ 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

@@ -117,7 +117,8 @@
"drawio": "Draw.io XML",
"png": "PNG Image",
"svg": "SVG Image"
}
},
"savedSuccessfully": "Saved successfully!"
},
"history": {
"title": "Diagram History",
@@ -212,6 +213,22 @@
"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

@@ -117,7 +117,8 @@
"drawio": "Draw.io XML",
"png": "PNG 画像",
"svg": "SVG 画像"
}
},
"savedSuccessfully": "保存完了!"
},
"history": {
"title": "ダイアグラム履歴",
@@ -212,6 +213,22 @@
"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

@@ -117,7 +117,8 @@
"drawio": "Draw.io XML",
"png": "PNG 图片",
"svg": "SVG 图片"
}
},
"savedSuccessfully": "保存成功!"
},
"history": {
"title": "图表历史",
@@ -212,6 +213,22 @@
"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 提供商和模型",

338
lib/session-storage.ts Normal file
View File

@@ -0,0 +1,338 @@
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,13 +1,7 @@
// Centralized localStorage keys
// Consolidates all storage keys from chat-panel.tsx and settings-dialog.tsx
// Centralized localStorage keys for quota tracking and settings
// Chat data is now stored in IndexedDB via session-storage.ts
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,6 +6,25 @@ 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
// ============================================================================

3646
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.7",
"version": "0.4.9",
"license": "Apache-2.0",
"private": true,
"main": "dist-electron/main/index.js",
@@ -64,6 +64,7 @@
"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",
@@ -71,7 +72,7 @@
"motion": "^12.23.25",
"negotiator": "^1.0.0",
"next": "^16.0.7",
"ollama-ai-provider-v2": "^1.5.4",
"ollama-ai-provider-v2": "^2.0.0",
"pako": "^2.1.0",
"prism-react-renderer": "^2.4.1",
"react": "^19.1.2",
@@ -89,9 +90,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",
"@tailwindcss/oxide-linux-x64-gnu": "^4.1.18"
"lightningcss-linux-x64-gnu": "^1.30.2"
},
"lint-staged": {
"*.{js,ts,jsx,tsx,json,css}": [

View File

@@ -64,6 +64,24 @@ 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
@@ -90,7 +108,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)
- **Self-contained**: Embedded server, works offline (except draw.io UI which loads from `embed.diagrams.net` by default, configurable via `DRAWIO_BASE_URL`)
## Available Tools
@@ -130,6 +148,33 @@ 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.6",
"version": "0.1.11",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "@next-ai-drawio/mcp-server",
"version": "0.1.6",
"version": "0.1.11",
"license": "Apache-2.0",
"dependencies": {
"@modelcontextprotocol/sdk": "^1.0.4",
"linkedom": "^0.18.0",
"open": "^11.0.0",
"zod": "^3.24.0"
"zod": "^4.0.0"
},
"bin": {
"next-ai-drawio-mcp": "dist/index.js"
@@ -2051,9 +2051,9 @@
}
},
"node_modules/zod": {
"version": "3.25.76",
"resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz",
"integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==",
"version": "4.3.5",
"resolved": "https://registry.npmjs.org/zod/-/zod-4.3.5.tgz",
"integrity": "sha512-k7Nwx6vuWx1IJ9Bjuf4Zt1PEllcwe7cls3VNzm4CQ1/hgtFUK2bRNG3rvnpPUhFjmqJKAKtjV576KnUkHocg/g==",
"license": "MIT",
"peer": true,
"funding": {

View File

@@ -1,6 +1,6 @@
{
"name": "@next-ai-drawio/mcp-server",
"version": "0.1.10",
"version": "0.1.11",
"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": "^3.24.0"
"zod": "^4.0.0"
},
"devDependencies": {
"@types/node": "^24.0.0",

View File

@@ -13,6 +13,45 @@ 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
@@ -127,7 +166,12 @@ function cleanupExpiredSessions(): void {
}
}
setInterval(cleanupExpiredSessions, 5 * 60 * 1000)
const cleanupIntervalId = setInterval(cleanupExpiredSessions, 5 * 60 * 1000)
export function shutdown(): void {
clearInterval(cleanupIntervalId)
stopHttpServer()
}
export function getServerPort(): number {
return serverPort
@@ -150,8 +194,11 @@ 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(url.searchParams.get("mcp") || ""))
res.end(getHtmlPage(sessionId))
} else if (url.pathname === "/api/state") {
handleStateApi(req, res, url)
} else if (url.pathname === "/api/history") {
@@ -178,6 +225,7 @@ 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(
@@ -398,7 +446,7 @@ function getHtmlPage(sessionId: string): string {
</div>
<div id="status" class="status disconnected">Connecting...</div>
</div>
<iframe id="drawio" src="https://embed.diagrams.net/?embed=1&proto=json&spin=1&libraries=1"></iframe>
<iframe id="drawio" src="${normalizeUrl(DRAWIO_BASE_URL)}/?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">
@@ -428,7 +476,7 @@ function getHtmlPage(sessionId: string): string {
let pendingAiSvg = false;
window.addEventListener('message', (e) => {
if (e.origin !== 'https://embed.diagrams.net') return;
if (e.origin !== '${DRAWIO_ORIGIN}') return;
try {
const msg = JSON.parse(e.data);
if (msg.event === 'init') {

View File

@@ -39,6 +39,7 @@ import {
getState,
requestSync,
setState,
shutdown,
startHttpServer,
waitForSync,
} from "./http-server.js"
@@ -47,7 +48,7 @@ import { validateAndFixXml } from "./xml-validation.js"
// Server configuration
const config = {
port: parseInt(process.env.PORT || "6002"),
port: parseInt(process.env.PORT || "6002", 10),
}
// Session state (single session for simplicity)
@@ -618,6 +619,31 @@ 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)
}