Compare commits

..

1 Commits

Author SHA1 Message Date
renovate[bot]
34896aa7f2 fix(deps): update dependency zod to v4 2025-12-31 08:24:25 +00:00
66 changed files with 2015 additions and 10050 deletions

View File

@@ -20,42 +20,15 @@ npm run lint # Check lint errors
npm run check # Run all checks (CI) npm run check # Run all checks (CI)
``` ```
Git hooks via Husky run automatically: Pre-commit hooks via Husky will run Biome automatically on staged files.
- **Pre-commit**: Biome (format/lint) + TypeScript type check
- **Pre-push**: Unit tests
For a better experience, install the [Biome VS Code extension](https://marketplace.visualstudio.com/items?itemName=biomejs.biome) for real-time linting and format-on-save. For a better experience, install the [Biome VS Code extension](https://marketplace.visualstudio.com/items?itemName=biomejs.biome) for real-time linting and format-on-save.
## Testing
Run tests before submitting PRs:
```bash
npm run test # Unit tests (Vitest)
npm run test:e2e # E2E tests (Playwright)
```
E2E tests use mocked API responses - no AI provider needed. Tests are in `tests/e2e/`.
To run a specific test file:
```bash
npx playwright test tests/e2e/diagram-generation.spec.ts
```
To run tests with UI mode:
```bash
npx playwright test --ui
```
## Pull Requests ## Pull Requests
1. Create a feature branch 1. Create a feature branch
2. Make changes (pre-commit runs lint + type check automatically) 2. Make changes and ensure `npm run check` passes
3. Run E2E tests with `npm run test:e2e` 3. Submit PR against `main` with a clear description
4. Push (pre-push runs unit tests automatically)
5. Submit PR against `main` with a clear description
CI will run the full test suite on your PR.
## Issues ## Issues

View File

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

View File

@@ -1,75 +0,0 @@
name: Test
on:
pull_request:
branches: [main]
push:
branches: [main]
jobs:
lint-and-unit:
name: Lint & Unit Tests
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: "20"
cache: "npm"
- name: Install dependencies
run: npm ci
- name: Run lint
run: npm run check
- name: Run unit tests
run: npm run test -- --run
e2e:
name: E2E Tests
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: "20"
cache: "npm"
- name: Install dependencies
run: npm ci
- name: Cache Playwright browsers
uses: actions/cache@v4
id: playwright-cache
with:
path: ~/.cache/ms-playwright
key: playwright-${{ runner.os }}-${{ hashFiles('**/package-lock.json') }}
- name: Install Playwright browsers
if: steps.playwright-cache.outputs.cache-hit != 'true'
run: npx playwright install chromium --with-deps
- name: Install Playwright deps (cached)
if: steps.playwright-cache.outputs.cache-hit == 'true'
run: npx playwright install-deps chromium
- name: Build app
run: npm run build
- name: Run E2E tests
run: npm run test:e2e
env:
CI: true
- name: Upload test results
uses: actions/upload-artifact@v4
if: always()
with:
name: playwright-report
path: playwright-report/
retention-days: 7

2
.gitignore vendored
View File

@@ -14,8 +14,6 @@ packages/*/dist
# testing # testing
/coverage /coverage
/playwright-report/
/test-results/
# next.js # next.js
/.next/ /.next/

View File

@@ -1,2 +1 @@
npx lint-staged npx lint-staged
npx tsc --noEmit

View File

@@ -1,4 +0,0 @@
# Skip if node_modules not installed (e.g., on EC2 push server)
if [ -d "node_modules" ]; then
npm run test -- --run
fi

View File

@@ -1,6 +1,6 @@
"use client" "use client"
import { usePathname, useRouter } from "next/navigation" import { usePathname, useRouter } from "next/navigation"
import { Suspense, useCallback, useEffect, useRef, useState } from "react" import { useCallback, useEffect, useRef, useState } from "react"
import { DrawIoEmbed } from "react-drawio" import { DrawIoEmbed } from "react-drawio"
import type { ImperativePanelHandle } from "react-resizable-panels" import type { ImperativePanelHandle } from "react-resizable-panels"
import ChatPanel from "@/components/chat-panel" import ChatPanel from "@/components/chat-panel"
@@ -17,8 +17,15 @@ const drawioBaseUrl =
process.env.NEXT_PUBLIC_DRAWIO_BASE_URL || "https://embed.diagrams.net" process.env.NEXT_PUBLIC_DRAWIO_BASE_URL || "https://embed.diagrams.net"
export default function Home() { export default function Home() {
const { drawioRef, handleDiagramExport, onDrawioLoad, resetDrawioReady } = const {
useDiagram() drawioRef,
handleDiagramExport,
onDrawioLoad,
resetDrawioReady,
saveDiagramToStorage,
showSaveDialog,
setShowSaveDialog,
} = useDiagram()
const router = useRouter() const router = useRouter()
const pathname = usePathname() const pathname = usePathname()
// Extract current language from pathname (e.g., "/zh/about" → "zh") // Extract current language from pathname (e.g., "/zh/about" → "zh")
@@ -28,12 +35,33 @@ export default function Home() {
const [drawioUi, setDrawioUi] = useState<"min" | "sketch">("min") const [drawioUi, setDrawioUi] = useState<"min" | "sketch">("min")
const [darkMode, setDarkMode] = useState(false) const [darkMode, setDarkMode] = useState(false)
const [isLoaded, setIsLoaded] = useState(false) const [isLoaded, setIsLoaded] = useState(false)
const [isDrawioReady, setIsDrawioReady] = useState(false)
const [closeProtection, setCloseProtection] = useState(false) const [closeProtection, setCloseProtection] = useState(false)
const chatPanelRef = useRef<ImperativePanelHandle>(null) const chatPanelRef = useRef<ImperativePanelHandle>(null)
const isSavingRef = useRef(false)
const mouseOverDrawioRef = useRef(false)
const isMobileRef = 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 // Load preferences from localStorage after mount
useEffect(() => { useEffect(() => {
// Restore saved locale and redirect if needed // Restore saved locale and redirect if needed
@@ -76,29 +104,24 @@ export default function Home() {
setIsLoaded(true) setIsLoaded(true)
}, [pathname, router]) }, [pathname, router])
const handleDrawioLoad = useCallback(() => { const handleDarkModeChange = async () => {
setIsDrawioReady(true) await saveDiagramToStorage()
onDrawioLoad()
}, [onDrawioLoad])
const handleDarkModeChange = () => {
const newValue = !darkMode const newValue = !darkMode
setDarkMode(newValue) setDarkMode(newValue)
localStorage.setItem("next-ai-draw-io-dark-mode", String(newValue)) localStorage.setItem("next-ai-draw-io-dark-mode", String(newValue))
document.documentElement.classList.toggle("dark", newValue) document.documentElement.classList.toggle("dark", newValue)
setIsDrawioReady(false)
resetDrawioReady() resetDrawioReady()
} }
const handleDrawioUiChange = () => { const handleDrawioUiChange = async () => {
await saveDiagramToStorage()
const newUi = drawioUi === "min" ? "sketch" : "min" const newUi = drawioUi === "min" ? "sketch" : "min"
localStorage.setItem("drawio-theme", newUi) localStorage.setItem("drawio-theme", newUi)
setDrawioUi(newUi) setDrawioUi(newUi)
setIsDrawioReady(false)
resetDrawioReady() resetDrawioReady()
} }
// Check mobile - reset draw.io before crossing breakpoint // Check mobile - save diagram and reset draw.io before crossing breakpoint
const isInitialRenderRef = useRef(true) const isInitialRenderRef = useRef(true)
useEffect(() => { useEffect(() => {
const checkMobile = () => { const checkMobile = () => {
@@ -107,7 +130,7 @@ export default function Home() {
!isInitialRenderRef.current && !isInitialRenderRef.current &&
newIsMobile !== isMobileRef.current newIsMobile !== isMobileRef.current
) { ) {
setIsDrawioReady(false) saveDiagramToStorage().catch(() => {})
resetDrawioReady() resetDrawioReady()
} }
isMobileRef.current = newIsMobile isMobileRef.current = newIsMobile
@@ -118,7 +141,7 @@ export default function Home() {
checkMobile() checkMobile()
window.addEventListener("resize", checkMobile) window.addEventListener("resize", checkMobile)
return () => window.removeEventListener("resize", checkMobile) return () => window.removeEventListener("resize", checkMobile)
}, [resetDrawioReady]) }, [saveDiagramToStorage, resetDrawioReady])
const toggleChatPanel = () => { const toggleChatPanel = () => {
const panel = chatPanelRef.current const panel = chatPanelRef.current
@@ -176,36 +199,35 @@ export default function Home() {
className={`h-full relative ${ className={`h-full relative ${
isMobile ? "p-1" : "p-2" isMobile ? "p-1" : "p-2"
}`} }`}
onMouseEnter={() => {
mouseOverDrawioRef.current = true
}}
onMouseLeave={() => {
mouseOverDrawioRef.current = false
}}
> >
<div className="h-full rounded-xl overflow-hidden shadow-soft-lg border border-border/30 relative"> <div className="h-full rounded-xl overflow-hidden shadow-soft-lg border border-border/30">
{isLoaded && ( {isLoaded ? (
<div <DrawIoEmbed
className={`h-full w-full ${isDrawioReady ? "" : "invisible absolute inset-0"}`} key={`${drawioUi}-${darkMode}-${currentLang}`}
> ref={drawioRef}
<DrawIoEmbed onExport={handleDiagramExport}
key={`${drawioUi}-${darkMode}-${currentLang}`} onLoad={onDrawioLoad}
ref={drawioRef} onSave={handleDrawioSave}
onExport={handleDiagramExport} baseUrl={drawioBaseUrl}
onLoad={handleDrawioLoad} urlParameters={{
baseUrl={drawioBaseUrl} ui: drawioUi,
urlParameters={{ spin: true,
ui: drawioUi, libraries: false,
spin: false, saveAndExit: false,
libraries: false, noExitBtn: true,
saveAndExit: false, dark: darkMode,
noSaveBtn: true, lang: currentLang,
noExitBtn: true, }}
dark: darkMode, />
lang: currentLang, ) : (
}} <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>
)}
{(!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>
)} )}
</div> </div>
@@ -228,24 +250,16 @@ export default function Home() {
onExpand={() => setIsChatVisible(true)} onExpand={() => setIsChatVisible(true)}
> >
<div className={`h-full ${isMobile ? "p-1" : "py-2 pr-2"}`}> <div className={`h-full ${isMobile ? "p-1" : "py-2 pr-2"}`}>
<Suspense <ChatPanel
fallback={ isVisible={isChatVisible}
<div className="h-full bg-card rounded-xl border border-border/30 flex items-center justify-center text-muted-foreground"> onToggleVisibility={toggleChatPanel}
Loading chat... drawioUi={drawioUi}
</div> onToggleDrawioUi={handleDrawioUiChange}
} darkMode={darkMode}
> onToggleDarkMode={handleDarkModeChange}
<ChatPanel isMobile={isMobile}
isVisible={isChatVisible} onCloseProtectionChange={setCloseProtection}
onToggleVisibility={toggleChatPanel} />
drawioUi={drawioUi}
onToggleDrawioUi={handleDrawioUiChange}
darkMode={darkMode}
onToggleDarkMode={handleDarkModeChange}
isMobile={isMobile}
onCloseProtectionChange={setCloseProtection}
/>
</Suspense>
</div> </div>
</ResizablePanel> </ResizablePanel>
</ResizablePanelGroup> </ResizablePanelGroup>

View File

@@ -18,11 +18,6 @@ import {
supportsPromptCaching, supportsPromptCaching,
} from "@/lib/ai-providers" } from "@/lib/ai-providers"
import { findCachedResponse } from "@/lib/cached-responses" import { findCachedResponse } from "@/lib/cached-responses"
import {
isMinimalDiagram,
replaceHistoricalToolInputs,
validateFileParts,
} from "@/lib/chat-helpers"
import { import {
checkAndIncrementRequest, checkAndIncrementRequest,
isQuotaEnabled, isQuotaEnabled,
@@ -39,6 +34,93 @@ import { getUserIdFromRequest } from "@/lib/user-id"
export const maxDuration = 120 export const maxDuration = 120
// File upload limits (must match client-side)
const MAX_FILE_SIZE = 2 * 1024 * 1024 // 2MB
const MAX_FILES = 5
// Helper function to validate file parts in messages
function validateFileParts(messages: any[]): {
valid: boolean
error?: string
} {
const lastMessage = messages[messages.length - 1]
const fileParts =
lastMessage?.parts?.filter((p: any) => p.type === "file") || []
if (fileParts.length > MAX_FILES) {
return {
valid: false,
error: `Too many files. Maximum ${MAX_FILES} allowed.`,
}
}
for (const filePart of fileParts) {
// Data URLs format: data:image/png;base64,<data>
// Base64 increases size by ~33%, so we check the decoded size
if (filePart.url?.startsWith("data:")) {
const base64Data = filePart.url.split(",")[1]
if (base64Data) {
const sizeInBytes = Math.ceil((base64Data.length * 3) / 4)
if (sizeInBytes > MAX_FILE_SIZE) {
return {
valid: false,
error: `File exceeds ${MAX_FILE_SIZE / 1024 / 1024}MB limit.`,
}
}
}
}
}
return { valid: true }
}
// Helper function to check if diagram is minimal/empty
function isMinimalDiagram(xml: string): boolean {
const stripped = xml.replace(/\s/g, "")
return !stripped.includes('id="2"')
}
// Helper function to replace historical tool call XML with placeholders
// This reduces token usage and forces LLM to rely on the current diagram XML (source of truth)
// Also fixes invalid/undefined inputs from interrupted streaming
function replaceHistoricalToolInputs(messages: any[]): any[] {
return messages.map((msg) => {
if (msg.role !== "assistant" || !Array.isArray(msg.content)) {
return msg
}
const replacedContent = msg.content
.map((part: any) => {
if (part.type === "tool-call") {
const toolName = part.toolName
// Fix invalid/undefined inputs from interrupted streaming
if (
!part.input ||
typeof part.input !== "object" ||
Object.keys(part.input).length === 0
) {
// Skip tool calls with invalid inputs entirely
return null
}
if (
toolName === "display_diagram" ||
toolName === "edit_diagram"
) {
return {
...part,
input: {
placeholder:
"[XML content replaced - see current diagram XML in system context]",
},
}
}
}
return part
})
.filter(Boolean) // Remove null entries (invalid tool calls)
return { ...msg, content: replacedContent }
})
}
// Helper function to create cached stream response // Helper function to create cached stream response
function createCachedStreamResponse(xml: string): Response { function createCachedStreamResponse(xml: string): Response {
const toolCallId = `cached-${Date.now()}` const toolCallId = `cached-${Date.now()}`

View File

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

View File

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

View File

@@ -134,7 +134,6 @@ export const ModelSelectorLogo = ({
} }
return ( return (
// biome-ignore lint/performance/noImgElement: External URL from models.dev
<img <img
{...props} {...props}
alt={`${provider} logo`} alt={`${provider} logo`}

View File

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

View File

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

View File

@@ -7,6 +7,7 @@ import {
ChevronDown, ChevronDown,
ChevronUp, ChevronUp,
Copy, Copy,
Cpu,
FileCode, FileCode,
FileText, FileText,
Pencil, Pencil,
@@ -25,9 +26,6 @@ import {
ReasoningContent, ReasoningContent,
ReasoningTrigger, ReasoningTrigger,
} from "@/components/ai-elements/reasoning" } 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 { ScrollArea } from "@/components/ui/scroll-area"
import { useDictionary } from "@/hooks/use-dictionary" import { useDictionary } from "@/hooks/use-dictionary"
import { getApiEndpoint } from "@/lib/base-path" import { getApiEndpoint } from "@/lib/base-path"
@@ -35,9 +33,18 @@ import {
applyDiagramOperations, applyDiagramOperations,
convertToLegalXml, convertToLegalXml,
extractCompleteMxCells, extractCompleteMxCells,
isMxCellXmlComplete,
replaceNodes, replaceNodes,
validateAndFixXml, validateAndFixXml,
} from "@/lib/utils" } 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 // Helper to extract complete operations from streaming input
function getCompleteOperations( function getCompleteOperations(
@@ -51,10 +58,60 @@ function getCompleteOperations(
["update", "add", "delete"].includes(op.operation) && ["update", "add", "delete"].includes(op.operation) &&
typeof op.cell_id === "string" && typeof op.cell_id === "string" &&
op.cell_id.length > 0 && op.cell_id.length > 0 &&
// delete doesn't need new_xml, update/add do
(op.operation === "delete" || typeof op.new_xml === "string"), (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" import { useDiagram } from "@/contexts/diagram-context"
// Helper to split text content into regular text and file sections (PDF or text files) // Helper to split text content into regular text and file sections (PDF or text files)
@@ -126,13 +183,6 @@ const getUserOriginalText = (message: UIMessage): string => {
return fullText.replace(filePattern, "").trim() return fullText.replace(filePattern, "").trim()
} }
interface SessionMetadata {
id: string
title: string
updatedAt: number
thumbnailDataUrl?: string
}
interface ChatMessageDisplayProps { interface ChatMessageDisplayProps {
messages: UIMessage[] messages: UIMessage[]
setInput: (input: string) => void setInput: (input: string) => void
@@ -143,11 +193,6 @@ interface ChatMessageDisplayProps {
onRegenerate?: (messageIndex: number) => void onRegenerate?: (messageIndex: number) => void
onEditMessage?: (messageIndex: number, newText: string) => void onEditMessage?: (messageIndex: number, newText: string) => void
status?: "streaming" | "submitted" | "idle" | "error" | "ready" 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({ export function ChatMessageDisplay({
@@ -160,33 +205,14 @@ export function ChatMessageDisplay({
onRegenerate, onRegenerate,
onEditMessage, onEditMessage,
status = "idle", status = "idle",
isRestored = false,
sessions = [],
onSelectSession,
onDeleteSession,
loadedMessageIdsRef,
}: ChatMessageDisplayProps) { }: ChatMessageDisplayProps) {
const dict = useDictionary() const dict = useDictionary()
const { chartXML, loadDiagram: onDisplayChart } = useDiagram() const { chartXML, loadDiagram: onDisplayChart } = useDiagram()
const messagesEndRef = useRef<HTMLDivElement>(null) const messagesEndRef = useRef<HTMLDivElement>(null)
const scrollTopRef = useRef<HTMLDivElement>(null)
const previousXML = useRef<string>("") const previousXML = useRef<string>("")
const processedToolCalls = processedToolCallsRef const processedToolCalls = processedToolCallsRef
// Track the last processed XML per toolCallId to skip redundant processing during streaming // Track the last processed XML per toolCallId to skip redundant processing during streaming
const lastProcessedXmlRef = useRef<Map<string, string>>(new Map()) 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 // Debounce streaming diagram updates - store pending XML and timeout
const pendingXmlRef = useRef<string | null>(null) const pendingXmlRef = useRef<string | null>(null)
const debounceTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>( const debounceTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(
@@ -257,7 +283,7 @@ export function ChatMessageDisplay({
try { try {
await navigator.clipboard.writeText(text) await navigator.clipboard.writeText(text)
setCopyState(messageId, isToolCall, true) setCopyState(messageId, isToolCall, true)
} catch (_err) { } catch (err) {
// Fallback for non-secure contexts (HTTP) or permission denied // Fallback for non-secure contexts (HTTP) or permission denied
const textarea = document.createElement("textarea") const textarea = document.createElement("textarea")
textarea.value = text textarea.value = text
@@ -321,6 +347,7 @@ export function ChatMessageDisplay({
const handleDisplayChart = useCallback( const handleDisplayChart = useCallback(
(xml: string, showToast = false) => { (xml: string, showToast = false) => {
let currentXml = xml || "" let currentXml = xml || ""
const startTime = performance.now()
// During streaming (showToast=false), extract only complete mxCell elements // During streaming (showToast=false), extract only complete mxCell elements
// This allows progressive rendering even with partial/incomplete trailing XML // This allows progressive rendering even with partial/incomplete trailing XML
@@ -344,8 +371,14 @@ export function ChatMessageDisplay({
const parseError = testDoc.querySelector("parsererror") const parseError = testDoc.querySelector("parsererror")
if (parseError) { if (parseError) {
// Only show toast if this is the final XML (not during streaming) // Use console.warn instead of console.error to avoid triggering
// Next.js dev mode error overlay for expected streaming states
// (partial XML during streaming is normal and will be fixed by subsequent updates)
if (showToast) { 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) toast.error(dict.errors.malformedXml)
} }
return // Skip this update return // Skip this update
@@ -359,12 +392,18 @@ export function ChatMessageDisplay({
`<mxfile><diagram name="Page-1" id="page-1"><mxGraphModel><root><mxCell id="0"/><mxCell id="1" parent="0"/></root></mxGraphModel></diagram></mxfile>` `<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 replacedXML = replaceNodes(baseXML, convertedXml)
const xmlProcessTime = performance.now() - startTime
// During streaming (showToast=false), skip heavy validation for lower latency // During streaming (showToast=false), skip heavy validation for lower latency
// The quick DOM parse check above catches malformed XML // The quick DOM parse check above catches malformed XML
// Full validation runs on final output (showToast=true) // Full validation runs on final output (showToast=true)
if (!showToast) { if (!showToast) {
previousXML.current = convertedXml previousXML.current = convertedXml
const loadStartTime = performance.now()
onDisplayChart(replacedXML, true) onDisplayChart(replacedXML, true)
console.log(
`[Streaming] XML processing: ${xmlProcessTime.toFixed(1)}ms, drawio load: ${(performance.now() - loadStartTime).toFixed(1)}ms`,
)
return return
} }
@@ -374,12 +413,30 @@ export function ChatMessageDisplay({
previousXML.current = convertedXml previousXML.current = convertedXml
// Use fixed XML if available, otherwise use original // Use fixed XML if available, otherwise use original
const xmlToLoad = validation.fixed || replacedXML 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) onDisplayChart(xmlToLoad, true)
console.log(
`[Final] XML processing: ${xmlProcessTime.toFixed(1)}ms, validation+load: ${(performance.now() - loadStartTime).toFixed(1)}ms`,
)
} else { } else {
console.error(
"[ChatMessageDisplay] XML validation failed:",
validation.error,
)
toast.error(dict.errors.validationFailed) toast.error(dict.errors.validationFailed)
} }
} catch (error) { } catch (error) {
console.error("Error processing XML:", error) console.error(
"[ChatMessageDisplay] Error processing XML:",
error,
)
// Only show toast if this is the final XML (not during streaming) // Only show toast if this is the final XML (not during streaming)
if (showToast) { if (showToast) {
toast.error(dict.errors.failedToProcess) toast.error(dict.errors.failedToProcess)
@@ -390,22 +447,8 @@ export function ChatMessageDisplay({
[chartXML, onDisplayChart], [chartXML, onDisplayChart],
) )
// Track previous message count to detect bulk loads vs streaming
const prevMessageCountRef = useRef(0)
useEffect(() => { useEffect(() => {
if (messagesEndRef.current && messages.length > 0) { if (messagesEndRef.current) {
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" }) messagesEndRef.current.scrollIntoView({ behavior: "smooth" })
} }
}, [messages]) }, [messages])
@@ -623,19 +666,202 @@ export function ChatMessageDisplay({
// Let the timeouts complete naturally - they're harmless if component unmounts. // Let the timeouts complete naturally - they're harmless if component unmounts.
}, [messages, handleDisplayChart, chartXML]) }, [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 ( return (
<ScrollArea className="h-full w-full scrollbar-thin"> <ScrollArea className="h-full w-full scrollbar-thin">
<div ref={scrollTopRef} /> {messages.length === 0 ? (
{messages.length === 0 && isRestored ? ( <ExamplePanel setInput={setInput} setFiles={setFiles} />
<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"> <div className="py-4 px-4 space-y-4">
{messages.map((message, messageIndex) => { {messages.map((message, messageIndex) => {
const userMessageText = const userMessageText =
@@ -655,21 +881,13 @@ export function ChatMessageDisplay({
.slice(messageIndex + 1) .slice(messageIndex + 1)
.every((m) => m.role !== "user")) .every((m) => m.role !== "user"))
const isEditing = editingMessageId === message.id const isEditing = editingMessageId === message.id
// Skip animation for loaded messages (from session restore)
const isRestoredMessage =
loadedMessageIdsRef?.current.has(message.id) ??
false
return ( return (
<div <div
key={message.id} key={message.id}
className={`flex w-full ${message.role === "user" ? "justify-end" : "justify-start"} ${isRestoredMessage ? "" : "animate-message-in"}`} className={`flex w-full ${message.role === "user" ? "justify-end" : "justify-start"} animate-message-in`}
style={ style={{
isRestoredMessage animationDelay: `${messageIndex * 50}ms`,
? undefined }}
: {
animationDelay: `${messageIndex * 50}ms`,
}
}
> >
{message.role === "user" && {message.role === "user" &&
userMessageText && userMessageText &&
@@ -766,9 +984,6 @@ export function ChatMessageDisplay({
isStreaming={ isStreaming={
isStreamingReasoning isStreamingReasoning
} }
defaultOpen={
!isRestoredMessage
}
> >
<ReasoningTrigger /> <ReasoningTrigger />
<ReasoningContent> <ReasoningContent>
@@ -911,30 +1126,9 @@ export function ChatMessageDisplay({
return groups.map( return groups.map(
(group, groupIndex) => { (group, groupIndex) => {
if (group.type === "tool") { if (group.type === "tool") {
return ( return renderToolPart(
<ToolCallCard group
key={`${message.id}-tool-${group.startIndex}`} .parts[0] as ToolPartLike,
part={
group
.parts[0] as ToolPartLike
}
expandedTools={
expandedTools
}
setExpandedTools={
setExpandedTools
}
onCopy={
copyMessageToClipboard
}
copiedToolCallId={
copiedToolCallId
}
copyFailedToolCallId={
copyFailedToolCallId
}
dict={dict}
/>
) )
} }

View File

@@ -9,39 +9,34 @@ import {
Settings, Settings,
} from "lucide-react" } from "lucide-react"
import Image from "next/image" import Image from "next/image"
import { useRouter, useSearchParams } from "next/navigation"
import type React from "react" import type React from "react"
import { import { useCallback, useEffect, useRef, useState } from "react"
useCallback,
useEffect,
useLayoutEffect,
useRef,
useState,
} from "react"
import { flushSync } from "react-dom" import { flushSync } from "react-dom"
import { Toaster, toast } from "sonner" import { Toaster, toast } from "sonner"
import { ButtonWithTooltip } from "@/components/button-with-tooltip" import { ButtonWithTooltip } from "@/components/button-with-tooltip"
import { ChatInput } from "@/components/chat-input" import { ChatInput } from "@/components/chat-input"
import { ModelConfigDialog } from "@/components/model-config-dialog" import { ModelConfigDialog } from "@/components/model-config-dialog"
import { ResetWarningModal } from "@/components/reset-warning-modal"
import { SettingsDialog } from "@/components/settings-dialog" import { SettingsDialog } from "@/components/settings-dialog"
import { useDiagram } from "@/contexts/diagram-context" import { useDiagram } from "@/contexts/diagram-context"
import { useDiagramToolHandlers } from "@/hooks/use-diagram-tool-handlers" import { useDiagramToolHandlers } from "@/hooks/use-diagram-tool-handlers"
import { useDictionary } from "@/hooks/use-dictionary" import { useDictionary } from "@/hooks/use-dictionary"
import { getSelectedAIConfig, useModelConfig } from "@/hooks/use-model-config" import { getSelectedAIConfig, useModelConfig } from "@/hooks/use-model-config"
import { useSessionManager } from "@/hooks/use-session-manager"
import { getApiEndpoint } from "@/lib/base-path" import { getApiEndpoint } from "@/lib/base-path"
import { findCachedResponse } from "@/lib/cached-responses" import { findCachedResponse } from "@/lib/cached-responses"
import { formatMessage } from "@/lib/i18n/utils" import { formatMessage } from "@/lib/i18n/utils"
import { isPdfFile, isTextFile } from "@/lib/pdf-utils" import { isPdfFile, isTextFile } from "@/lib/pdf-utils"
import { sanitizeMessages } from "@/lib/session-storage"
import { type FileData, useFileProcessor } from "@/lib/use-file-processor" import { type FileData, useFileProcessor } from "@/lib/use-file-processor"
import { useQuotaManager } from "@/lib/use-quota-manager" import { useQuotaManager } from "@/lib/use-quota-manager"
import { cn, formatXML, isRealDiagram } from "@/lib/utils" import { formatXML } from "@/lib/utils"
import { ChatMessageDisplay } from "./chat-message-display" import { ChatMessageDisplay } from "./chat-message-display"
import { DevXmlSimulator } from "./dev-xml-simulator" import { DevXmlSimulator } from "./dev-xml-simulator"
// localStorage keys for persistence // 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" 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 // sessionStorage keys
const SESSION_STORAGE_INPUT_KEY = "next-ai-draw-io-input" const SESSION_STORAGE_INPUT_KEY = "next-ai-draw-io-input"
@@ -118,17 +113,10 @@ export default function ChatPanel({
handleExportWithoutHistory, handleExportWithoutHistory,
resolverRef, resolverRef,
chartXML, chartXML,
latestSvg,
clearDiagram, clearDiagram,
getThumbnailSvg,
diagramHistory,
setDiagramHistory,
} = useDiagram() } = useDiagram()
const dict = useDictionary() const dict = useDictionary()
const router = useRouter()
const searchParams = useSearchParams()
const urlSessionId = searchParams.get("session")
const onFetchChart = (saveToHistory = true) => { const onFetchChart = (saveToHistory = true) => {
return Promise.race([ return Promise.race([
@@ -164,14 +152,11 @@ export default function ChatPanel({
// Model configuration hook // Model configuration hook
const modelConfig = useModelConfig() const modelConfig = useModelConfig()
// Session manager for chat history (pass URL session ID for restoration)
const sessionManager = useSessionManager({ initialSessionId: urlSessionId })
const [input, setInput] = useState("") const [input, setInput] = useState("")
const [dailyRequestLimit, setDailyRequestLimit] = useState(0) const [dailyRequestLimit, setDailyRequestLimit] = useState(0)
const [dailyTokenLimit, setDailyTokenLimit] = useState(0) const [dailyTokenLimit, setDailyTokenLimit] = useState(0)
const [tpmLimit, setTpmLimit] = useState(0) const [tpmLimit, setTpmLimit] = useState(0)
const [showNewChatDialog, setShowNewChatDialog] = useState(false)
const [minimalStyle, setMinimalStyle] = useState(false) const [minimalStyle, setMinimalStyle] = useState(false)
// Restore input from sessionStorage on mount (when ChatPanel remounts due to key change) // Restore input from sessionStorage on mount (when ChatPanel remounts due to key change)
@@ -216,37 +201,13 @@ export default function ChatPanel({
// Flag to track if we've restored from localStorage // Flag to track if we've restored from localStorage
const hasRestoredRef = useRef(false) 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) // Ref to track latest chartXML for use in callbacks (avoids stale closure)
const chartXMLRef = useRef(chartXML) const chartXMLRef = useRef(chartXML)
// Track session ID that was loaded without a diagram (to prevent thumbnail contamination)
const justLoadedSessionIdRef = useRef<string | null>(null)
useEffect(() => { useEffect(() => {
chartXMLRef.current = chartXML chartXMLRef.current = chartXML
// Clear the no-diagram flag when a diagram is generated
if (chartXML) {
justLoadedSessionIdRef.current = null
}
}, [chartXML]) }, [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) // Ref to track consecutive auto-retry count (reset on user action)
const autoRetryCountRef = useRef(0) const autoRetryCountRef = useRef(0)
// Ref to track continuation retry count (for truncation handling) // Ref to track continuation retry count (for truncation handling)
@@ -328,6 +289,32 @@ export default function ChatPanel({
// Silence access code error in console since it's handled by UI // Silence access code error in console since it's handled by UI
if (!error.message.includes("Invalid or missing access code")) { if (!error.message.includes("Invalid or missing access code")) {
console.error("Chat error:", error) 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 // Translate technical errors into user-friendly messages
@@ -373,7 +360,15 @@ export default function ChatPanel({
setShowSettingsDialog(true) setShowSettingsDialog(true)
} }
}, },
onFinish: () => {}, onFinish: ({ message }) => {
// Track actual token usage from server metadata
const metadata = message?.metadata as
| Record<string, unknown>
| undefined
// DEBUG: Log finish reason to diagnose truncation
console.log("[onFinish] finishReason:", metadata?.finishReason)
},
sendAutomaticallyWhen: ({ messages }) => { sendAutomaticallyWhen: ({ messages }) => {
const isInContinuationMode = partialXmlRef.current.length > 0 const isInContinuationMode = partialXmlRef.current.length > 0
@@ -431,199 +426,58 @@ export default function ChatPanel({
messagesRef.current = messages messagesRef.current = messages
}, [messages]) }, [messages])
// Track last synced session ID to detect external changes (e.g., URL back/forward) const messagesEndRef = useRef<HTMLDivElement>(null)
const lastSyncedSessionIdRef = useRef<string | null>(null)
// Helper: Sync UI state with session data (eliminates duplication) // Restore messages and XML snapshots from localStorage on mount
// Track message IDs that are being loaded from session (to skip animations/scroll) useEffect(() => {
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 (hasRestoredRef.current) return
if (sessionManager.isLoading) return // Wait for session manager to load
hasRestoredRef.current = true hasRestoredRef.current = true
try { try {
const currentSession = sessionManager.currentSession // Restore messages
if (currentSession && currentSession.messages.length > 0) { const savedMessages = localStorage.getItem(STORAGE_MESSAGES_KEY)
// Restore from session manager (IndexedDB) if (savedMessages) {
justLoadedSessionRef.current = true const parsed = JSON.parse(savedMessages)
syncUIWithSession(currentSession) if (Array.isArray(parsed) && parsed.length > 0) {
setMessages(parsed)
}
}
// Restore XML snapshots
const savedSnapshots = localStorage.getItem(
STORAGE_XML_SNAPSHOTS_KEY,
)
if (savedSnapshots) {
const parsed = JSON.parse(savedSnapshots)
xmlSnapshotsRef.current = new Map(parsed)
} }
// Initialize lastSyncedSessionIdRef to prevent sync effect from firing immediately
lastSyncedSessionIdRef.current = sessionManager.currentSessionId
// Note: Migration from old localStorage format is handled by session-storage.ts
} catch (error) { } catch (error) {
console.error("Failed to restore session:", error) console.error("Failed to restore from localStorage:", error)
// On complete failure, clear storage to allow recovery
localStorage.removeItem(STORAGE_MESSAGES_KEY)
localStorage.removeItem(STORAGE_XML_SNAPSHOTS_KEY)
toast.error(dict.errors.sessionCorrupted) 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(() => { useEffect(() => {
if (!hasRestoredRef.current) return 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 // Clear any pending save
if (localStorageDebounceRef.current) { if (localStorageDebounceRef.current) {
clearTimeout(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 // Debounce: save after 1 second of no changes
localStorageDebounceRef.current = setTimeout(async () => { localStorageDebounceRef.current = setTimeout(() => {
try { try {
if (messages.length > 0) { localStorage.setItem(
const sessionData = await buildSessionData({ STORAGE_MESSAGES_KEY,
// Only capture thumbnail if there was a diagram AND this isn't a no-diagram session JSON.stringify(messages),
withThumbnail: hasDiagramNow && !isNodiagramSession, )
})
await saveCurrentSessionRef.current(
sessionData,
scheduledForSessionId,
)
}
} catch (error) { } catch (error) {
console.error("Failed to save session:", error) console.error("Failed to save messages to localStorage:", error)
} }
}, LOCAL_STORAGE_DEBOUNCE_MS) }, LOCAL_STORAGE_DEBOUNCE_MS)
@@ -633,62 +487,63 @@ export default function ChatPanel({
clearTimeout(localStorageDebounceRef.current) clearTimeout(localStorageDebounceRef.current)
} }
} }
}, [ }, [messages])
messages,
status,
sessionIsAvailable,
currentSessionId,
buildSessionData,
])
// Update URL when a new session is created (first message sent) // Save XML snapshots to localStorage whenever they change
useEffect(() => { const saveXmlSnapshots = useCallback(() => {
if (sessionManager.currentSessionId && !urlSessionId) { try {
// A session was created but URL doesn't have the session param yet const snapshotsArray = Array.from(xmlSnapshotsRef.current.entries())
router.replace(`?session=${sessionManager.currentSessionId}`, { localStorage.setItem(
scroll: false, STORAGE_XML_SNAPSHOTS_KEY,
}) JSON.stringify(snapshotsArray),
)
} catch (error) {
console.error(
"Failed to save XML snapshots to localStorage:",
error,
)
} }
}, [sessionManager.currentSessionId, urlSessionId, router]) }, [])
// Save session ID to localStorage // Save session ID to localStorage
useEffect(() => { useEffect(() => {
localStorage.setItem(STORAGE_SESSION_ID_KEY, sessionId) localStorage.setItem(STORAGE_SESSION_ID_KEY, sessionId)
}, [sessionId]) }, [sessionId])
// Save session when page becomes hidden (tab switch, close, navigate away)
// This is more reliable than beforeunload for async IndexedDB operations
useEffect(() => { useEffect(() => {
if (!sessionManager.isAvailable) return if (messagesEndRef.current) {
messagesEndRef.current.scrollIntoView({ behavior: "smooth" })
}
}, [messages])
const handleVisibilityChange = async () => { // Save state right before page unload (refresh/close)
if ( useEffect(() => {
document.visibilityState === "hidden" && const handleBeforeUnload = () => {
messagesRef.current.length > 0 try {
) { localStorage.setItem(
try { STORAGE_MESSAGES_KEY,
// Attempt to save session - browser may not wait for completion JSON.stringify(messagesRef.current),
// Skip thumbnail capture as it may not complete in time )
const sessionData = await buildSessionData({ localStorage.setItem(
withThumbnail: false, STORAGE_XML_SNAPSHOTS_KEY,
}) JSON.stringify(
await sessionManager.saveCurrentSession(sessionData) Array.from(xmlSnapshotsRef.current.entries()),
} catch (error) { ),
console.error( )
"Failed to save session on visibility change:", const xml = chartXMLRef.current
error, if (xml && xml.length > 300) {
) localStorage.setItem(STORAGE_DIAGRAM_XML_KEY, xml)
} }
localStorage.setItem(STORAGE_SESSION_ID_KEY, sessionId)
} catch (error) {
console.error("Failed to persist state before unload:", error)
} }
} }
document.addEventListener("visibilitychange", handleVisibilityChange) window.addEventListener("beforeunload", handleBeforeUnload)
return () => return () =>
document.removeEventListener( window.removeEventListener("beforeunload", handleBeforeUnload)
"visibilitychange", }, [sessionId])
handleVisibilityChange,
)
}, [sessionManager, buildSessionData])
const onFormSubmit = async (e: React.FormEvent<HTMLFormElement>) => { const onFormSubmit = async (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault() e.preventDefault()
@@ -772,6 +627,7 @@ export default function ChatPanel({
// Save XML snapshot for this message (will be at index = current messages.length) // Save XML snapshot for this message (will be at index = current messages.length)
const messageIndex = messages.length const messageIndex = messages.length
xmlSnapshotsRef.current.set(messageIndex, chartXml) xmlSnapshotsRef.current.set(messageIndex, chartXml)
saveXmlSnapshots()
sendChatMessage(parts, chartXml, previousXml, sessionId) sendChatMessage(parts, chartXml, previousXml, sessionId)
@@ -785,97 +641,30 @@ export default function ChatPanel({
} }
} }
// Handle session switching from history dropdown const handleNewChat = useCallback(() => {
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([]) setMessages([])
clearDiagram() clearDiagram()
setDiagramHistory([])
handleFileChange([]) // Use handleFileChange to also clear pdfData handleFileChange([]) // Use handleFileChange to also clear pdfData
const newSessionId = `session-${Date.now()}-${Math.random() const newSessionId = `session-${Date.now()}-${Math.random()
.toString(36) .toString(36)
.slice(2, 9)}` .slice(2, 9)}`
setSessionId(newSessionId) setSessionId(newSessionId)
xmlSnapshotsRef.current.clear() xmlSnapshotsRef.current.clear()
sessionStorage.removeItem(SESSION_STORAGE_INPUT_KEY) // Clear localStorage with error handling
toast.success(dict.dialogs.clearSuccess) try {
localStorage.removeItem(STORAGE_MESSAGES_KEY)
localStorage.removeItem(STORAGE_XML_SNAPSHOTS_KEY)
localStorage.removeItem(STORAGE_DIAGRAM_XML_KEY)
localStorage.setItem(STORAGE_SESSION_ID_KEY, newSessionId)
sessionStorage.removeItem(SESSION_STORAGE_INPUT_KEY)
toast.success(dict.dialogs.clearSuccess)
} catch (error) {
console.error("Failed to clear localStorage:", error)
toast.warning(dict.errors.storageUpdateFailed)
}
// Clear URL param to show blank state setShowNewChatDialog(false)
router.replace(window.location.pathname, { scroll: false }) }, [clearDiagram, handleFileChange, setMessages, setSessionId])
}, [
clearDiagram,
handleFileChange,
setMessages,
setSessionId,
sessionManager,
messages,
router,
dict.dialogs.clearSuccess,
buildSessionData,
setDiagramHistory,
])
const handleInputChange = ( const handleInputChange = (
e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>, e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>,
@@ -912,6 +701,7 @@ export default function ChatPanel({
xmlSnapshotsRef.current.delete(key) xmlSnapshotsRef.current.delete(key)
} }
} }
saveXmlSnapshots()
} }
// Send chat message with headers // Send chat message with headers
@@ -1125,16 +915,12 @@ export default function ChatPanel({
// Full view // Full view
return ( return (
<div <div className="h-full flex flex-col bg-card shadow-soft animate-slide-in-right rounded-xl border border-border/30 relative">
className={cn(
"h-full flex flex-col bg-card shadow-soft rounded-xl border border-border/30 relative",
shouldAnimatePanel && "animate-slide-in-right",
)}
>
<Toaster <Toaster
position="bottom-left" position="bottom-center"
richColors richColors
expand expand
style={{ position: "absolute" }}
toastOptions={{ toastOptions={{
style: { style: {
maxWidth: "480px", maxWidth: "480px",
@@ -1147,15 +933,7 @@ export default function ChatPanel({
className={`${isMobile ? "px-3 py-2" : "px-5 py-4"} border-b border-border/50`} 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 justify-between">
<button <div className="flex items-center gap-2 overflow-x-hidden">
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"> <div className="flex items-center gap-2">
<Image <Image
src={ src={
@@ -1174,18 +952,14 @@ export default function ChatPanel({
Next AI Drawio Next AI Drawio
</h1> </h1>
</div> </div>
</button> </div>
<div className="flex items-center gap-1 justify-end overflow-visible"> <div className="flex items-center gap-1 justify-end overflow-visible">
<ButtonWithTooltip <ButtonWithTooltip
tooltipContent={dict.nav.newChat} tooltipContent={dict.nav.newChat}
variant="ghost" variant="ghost"
size="icon" size="icon"
onClick={handleNewChat} onClick={() => setShowNewChatDialog(true)}
disabled={ className="hover:bg-accent"
status === "streaming" || status === "submitted"
}
className="hover:bg-accent disabled:opacity-50 disabled:cursor-not-allowed"
data-testid="new-chat-button"
> >
<MessageSquarePlus <MessageSquarePlus
className={`${isMobile ? "h-4 w-4" : "h-5 w-5"} text-muted-foreground`} className={`${isMobile ? "h-4 w-4" : "h-5 w-5"} text-muted-foreground`}
@@ -1198,7 +972,6 @@ export default function ChatPanel({
size="icon" size="icon"
onClick={() => setShowSettingsDialog(true)} onClick={() => setShowSettingsDialog(true)}
className="hover:bg-accent" className="hover:bg-accent"
data-testid="settings-button"
> >
<Settings <Settings
className={`${isMobile ? "h-4 w-4" : "h-5 w-5"} text-muted-foreground`} className={`${isMobile ? "h-4 w-4" : "h-5 w-5"} text-muted-foreground`}
@@ -1233,11 +1006,6 @@ export default function ChatPanel({
onRegenerate={handleRegenerate} onRegenerate={handleRegenerate}
status={status} status={status}
onEditMessage={handleEditMessage} onEditMessage={handleEditMessage}
isRestored={isRestored}
sessions={sessionManager.sessions}
onSelectSession={handleSelectSession}
onDeleteSession={handleDeleteSession}
loadedMessageIdsRef={loadedMessageIdsRef}
/> />
</main> </main>
@@ -1261,6 +1029,7 @@ export default function ChatPanel({
status={status} status={status}
onSubmit={onFormSubmit} onSubmit={onFormSubmit}
onChange={handleInputChange} onChange={handleInputChange}
onClearChat={handleNewChat}
files={files} files={files}
onFileChange={handleFileChange} onFileChange={handleFileChange}
pdfData={pdfData} pdfData={pdfData}
@@ -1291,6 +1060,12 @@ export default function ChatPanel({
onOpenChange={setShowModelConfigDialog} onOpenChange={setShowModelConfigDialog}
modelConfig={modelConfig} modelConfig={modelConfig}
/> />
<ResetWarningModal
open={showNewChatDialog}
onOpenChange={setShowNewChatDialog}
onClear={handleNewChat}
/>
</div> </div>
) )
} }

View File

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

View File

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

View File

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

File diff suppressed because it is too large Load Diff

View File

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

View File

@@ -11,7 +11,7 @@ services:
# - NEXT_PUBLIC_BASE_PATH=/nextaidrawio # - NEXT_PUBLIC_BASE_PATH=/nextaidrawio
ports: ["3000:3000"] ports: ["3000:3000"]
env_file: .env env_file: .env
# environment: environment:
# # For subdirectory deployment, uncomment and set your path: # For subdirectory deployment, uncomment and set your path:
# NEXT_PUBLIC_BASE_PATH: /nextaidrawio # NEXT_PUBLIC_BASE_PATH: /nextaidrawio
depends_on: [drawio] depends_on: [drawio]

View File

@@ -99,7 +99,7 @@ function handleOptionsRequest(): Response {
}) })
} }
export async function onRequest({ request, env: _env }: any) { export async function onRequest({ request, env }: any) {
if (request.method === "OPTIONS") { if (request.method === "OPTIONS") {
return handleOptionsRequest() return handleOptionsRequest()
} }

View File

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

View File

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

View File

@@ -573,8 +573,8 @@ export function getAIModel(overrides?: ClientOverrides): ModelConfig {
const bedrockProvider = hasClientCredentials const bedrockProvider = hasClientCredentials
? createAmazonBedrock({ ? createAmazonBedrock({
region: bedrockRegion, region: bedrockRegion,
accessKeyId: overrides.awsAccessKeyId as string, accessKeyId: overrides.awsAccessKeyId!,
secretAccessKey: overrides.awsSecretAccessKey as string, secretAccessKey: overrides.awsSecretAccessKey!,
...(overrides?.awsSessionToken && { ...(overrides?.awsSessionToken && {
sessionToken: overrides.awsSessionToken, sessionToken: overrides.awsSessionToken,
}), }),
@@ -786,7 +786,7 @@ export function getAIModel(overrides?: ClientOverrides): ModelConfig {
`data: ${JSON.stringify(data)}\n\n`, `data: ${JSON.stringify(data)}\n\n`,
), ),
) )
} catch (_e) { } catch (e) {
// If parsing fails, forward the original message to avoid breaking the stream. // If parsing fails, forward the original message to avoid breaking the stream.
controller.enqueue( controller.enqueue(
new TextEncoder().encode( new TextEncoder().encode(

View File

@@ -1,89 +0,0 @@
// Shared helper functions for chat route
// Exported for testing
// File upload limits (must match client-side)
export const MAX_FILE_SIZE = 2 * 1024 * 1024 // 2MB
export const MAX_FILES = 5
// Helper function to validate file parts in messages
export function validateFileParts(messages: any[]): {
valid: boolean
error?: string
} {
const lastMessage = messages[messages.length - 1]
const fileParts =
lastMessage?.parts?.filter((p: any) => p.type === "file") || []
if (fileParts.length > MAX_FILES) {
return {
valid: false,
error: `Too many files. Maximum ${MAX_FILES} allowed.`,
}
}
for (const filePart of fileParts) {
// Data URLs format: data:image/png;base64,<data>
// Base64 increases size by ~33%, so we check the decoded size
if (filePart.url?.startsWith("data:")) {
const base64Data = filePart.url.split(",")[1]
if (base64Data) {
const sizeInBytes = Math.ceil((base64Data.length * 3) / 4)
if (sizeInBytes > MAX_FILE_SIZE) {
return {
valid: false,
error: `File exceeds ${MAX_FILE_SIZE / 1024 / 1024}MB limit.`,
}
}
}
}
}
return { valid: true }
}
// Helper function to check if diagram is minimal/empty
export function isMinimalDiagram(xml: string): boolean {
const stripped = xml.replace(/\s/g, "")
return !stripped.includes('id="2"')
}
// Helper function to replace historical tool call XML with placeholders
// This reduces token usage and forces LLM to rely on the current diagram XML (source of truth)
// Also fixes invalid/undefined inputs from interrupted streaming
export function replaceHistoricalToolInputs(messages: any[]): any[] {
return messages.map((msg) => {
if (msg.role !== "assistant" || !Array.isArray(msg.content)) {
return msg
}
const replacedContent = msg.content
.map((part: any) => {
if (part.type === "tool-call") {
const toolName = part.toolName
// Fix invalid/undefined inputs from interrupted streaming
if (
!part.input ||
typeof part.input !== "object" ||
Object.keys(part.input).length === 0
) {
// Skip tool calls with invalid inputs entirely
return null
}
if (
toolName === "display_diagram" ||
toolName === "edit_diagram"
) {
return {
...part,
input: {
placeholder:
"[XML content replaced - see current diagram XML in system context]",
},
}
}
}
return part
})
.filter(Boolean) // Remove null entries (invalid tool calls)
return { ...msg, content: replacedContent }
})
}

View File

@@ -117,8 +117,7 @@
"drawio": "Draw.io XML", "drawio": "Draw.io XML",
"png": "PNG Image", "png": "PNG Image",
"svg": "SVG Image" "svg": "SVG Image"
}, }
"savedSuccessfully": "Saved successfully!"
}, },
"history": { "history": {
"title": "Diagram History", "title": "Diagram History",
@@ -213,22 +212,6 @@
"contactMe": "Contact Me", "contactMe": "Contact Me",
"usageNotice": "Due to high usage, I have changed the model from Claude to minimax-m2 and added some usage limits. See About page for details." "usageNotice": "Due to high usage, I have changed the model from Claude to minimax-m2 and added some usage limits. See About page for details."
}, },
"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": { "modelConfig": {
"title": "AI Model Configuration", "title": "AI Model Configuration",
"description": "Configure multiple AI providers and models", "description": "Configure multiple AI providers and models",

View File

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

View File

@@ -117,8 +117,7 @@
"drawio": "Draw.io XML", "drawio": "Draw.io XML",
"png": "PNG 图片", "png": "PNG 图片",
"svg": "SVG 图片" "svg": "SVG 图片"
}, }
"savedSuccessfully": "保存成功!"
}, },
"history": { "history": {
"title": "图表历史", "title": "图表历史",
@@ -213,22 +212,6 @@
"contactMe": "联系我", "contactMe": "联系我",
"usageNotice": "由于使用量过高,我已将模型从 Claude 更换为 minimax-m2并设置了一些用量限制。详情请查看关于页面。" "usageNotice": "由于使用量过高,我已将模型从 Claude 更换为 minimax-m2并设置了一些用量限制。详情请查看关于页面。"
}, },
"sessionHistory": {
"tooltip": "聊天历史",
"newChat": "新对话",
"empty": "暂无聊天记录",
"emptyHint": "开始对话吧",
"today": "今天",
"yesterday": "昨天",
"thisWeek": "本周",
"earlier": "更早",
"deleteTitle": "删除此对话?",
"deleteDescription": "这将永久删除此聊天会话及其图表。此操作无法撤消。",
"recentChats": "最近对话",
"justNow": "刚刚",
"searchPlaceholder": "搜索对话...",
"noResults": "未找到对话"
},
"modelConfig": { "modelConfig": {
"title": "AI 模型配置", "title": "AI 模型配置",
"description": "配置多个 AI 提供商和模型", "description": "配置多个 AI 提供商和模型",

View File

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

View File

@@ -1,7 +1,13 @@
// Centralized localStorage keys for quota tracking and settings // Centralized localStorage keys
// Chat data is now stored in IndexedDB via session-storage.ts // Consolidates all storage keys from chat-panel.tsx and settings-dialog.tsx
export const STORAGE_KEYS = { 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 // Quota tracking
requestCount: "next-ai-draw-io-request-count", requestCount: "next-ai-draw-io-request-count",
requestDate: "next-ai-draw-io-request-date", requestDate: "next-ai-draw-io-request-date",

View File

@@ -6,25 +6,6 @@ export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs)) 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 // XML Validation/Fix Constants
// ============================================================================ // ============================================================================

5120
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,6 +1,6 @@
{ {
"name": "next-ai-draw-io", "name": "next-ai-draw-io",
"version": "0.4.9", "version": "0.4.7",
"license": "Apache-2.0", "license": "Apache-2.0",
"private": true, "private": true,
"main": "dist-electron/main/index.js", "main": "dist-electron/main/index.js",
@@ -25,9 +25,7 @@
"dist:mac": "npm run electron:build && npm run electron:prepare && npx electron-builder --config electron/electron-builder.yml --mac", "dist:mac": "npm run electron:build && npm run electron:prepare && npx electron-builder --config electron/electron-builder.yml --mac",
"dist:win": "npm run electron:build && npm run electron:prepare && npx electron-builder --config electron/electron-builder.yml --win", "dist:win": "npm run electron:build && npm run electron:prepare && npx electron-builder --config electron/electron-builder.yml --win",
"dist:linux": "npm run electron:build && npm run electron:prepare && npx electron-builder --config electron/electron-builder.yml --linux", "dist:linux": "npm run electron:build && npm run electron:prepare && npx electron-builder --config electron/electron-builder.yml --linux",
"dist:all": "npm run electron:build && npm run electron:prepare && npx electron-builder --config electron/electron-builder.yml --mac --win --linux", "dist:all": "npm run electron:build && npm run electron:prepare && npx electron-builder --config electron/electron-builder.yml --mac --win --linux"
"test": "vitest",
"test:e2e": "playwright test"
}, },
"dependencies": { "dependencies": {
"@ai-sdk/amazon-bedrock": "^4.0.1", "@ai-sdk/amazon-bedrock": "^4.0.1",
@@ -66,14 +64,14 @@
"class-variance-authority": "^0.7.1", "class-variance-authority": "^0.7.1",
"clsx": "^2.1.1", "clsx": "^2.1.1",
"cmdk": "^1.1.1", "cmdk": "^1.1.1",
"idb": "^8.0.3",
"js-tiktoken": "^1.0.21", "js-tiktoken": "^1.0.21",
"jsdom": "^27.0.0",
"jsonrepair": "^3.13.1", "jsonrepair": "^3.13.1",
"lucide-react": "^0.562.0", "lucide-react": "^0.562.0",
"motion": "^12.23.25", "motion": "^12.23.25",
"negotiator": "^1.0.0", "negotiator": "^1.0.0",
"next": "^16.0.7", "next": "^16.0.7",
"ollama-ai-provider-v2": "^2.0.0", "ollama-ai-provider-v2": "^1.5.4",
"pako": "^2.1.0", "pako": "^2.1.0",
"prism-react-renderer": "^2.4.1", "prism-react-renderer": "^2.4.1",
"react": "^19.1.2", "react": "^19.1.2",
@@ -91,9 +89,9 @@
"zod": "^4.1.12" "zod": "^4.1.12"
}, },
"optionalDependencies": { "optionalDependencies": {
"@tailwindcss/oxide-linux-x64-gnu": "^4.1.18",
"lightningcss": "^1.30.2", "lightningcss": "^1.30.2",
"lightningcss-linux-x64-gnu": "^1.30.2" "lightningcss-linux-x64-gnu": "^1.30.2",
"@tailwindcss/oxide-linux-x64-gnu": "^4.1.18"
}, },
"lint-staged": { "lint-staged": {
"*.{js,ts,jsx,tsx,json,css}": [ "*.{js,ts,jsx,tsx,json,css}": [
@@ -104,19 +102,13 @@
"devDependencies": { "devDependencies": {
"@anthropic-ai/tokenizer": "^0.0.4", "@anthropic-ai/tokenizer": "^0.0.4",
"@biomejs/biome": "^2.3.10", "@biomejs/biome": "^2.3.10",
"@playwright/test": "^1.57.0",
"@tailwindcss/postcss": "^4", "@tailwindcss/postcss": "^4",
"@tailwindcss/typography": "^0.5.19", "@tailwindcss/typography": "^0.5.19",
"@testing-library/dom": "^10.4.1",
"@testing-library/react": "^16.3.1",
"@testing-library/user-event": "^14.6.1",
"@types/negotiator": "^0.6.4", "@types/negotiator": "^0.6.4",
"@types/node": "^24.0.0", "@types/node": "^24.0.0",
"@types/pako": "^2.0.3", "@types/pako": "^2.0.3",
"@types/react": "^19", "@types/react": "^19",
"@types/react-dom": "^19", "@types/react-dom": "^19",
"@vitejs/plugin-react": "^5.1.2",
"@vitest/coverage-v8": "^4.0.16",
"concurrently": "^9.2.1", "concurrently": "^9.2.1",
"cross-env": "^10.1.0", "cross-env": "^10.1.0",
"electron": "^39.2.7", "electron": "^39.2.7",
@@ -125,13 +117,10 @@
"eslint": "9.39.2", "eslint": "9.39.2",
"eslint-config-next": "16.1.1", "eslint-config-next": "16.1.1",
"husky": "^9.1.7", "husky": "^9.1.7",
"jsdom": "^27.4.0",
"lint-staged": "^16.2.7", "lint-staged": "^16.2.7",
"shx": "^0.4.0", "shx": "^0.4.0",
"tailwindcss": "^4", "tailwindcss": "^4",
"typescript": "^5", "typescript": "^5",
"vite-tsconfig-paths": "^6.0.3",
"vitest": "^4.0.16",
"wait-on": "^9.0.3", "wait-on": "^9.0.3",
"wrangler": "4.54.0" "wrangler": "4.54.0"
}, },

View File

@@ -64,24 +64,6 @@ Add to Cursor MCP config (`~/.cursor/mcp.json`):
} }
``` ```
### Cline (VS Code Extension)
1. Click the **MCP Servers** icon in Cline's top menu bar
2. Select the **Configure** tab
3. Click **Configure MCP Servers** to edit `cline_mcp_settings.json`
4. Add the drawio server:
```json
{
"mcpServers": {
"drawio": {
"command": "npx",
"args": ["@next-ai-drawio/mcp-server@latest"]
}
}
}
```
### Claude Code CLI ### Claude Code CLI
```bash ```bash
@@ -108,7 +90,7 @@ Use the standard MCP configuration with:
- **Natural Language**: Describe diagrams in plain text - flowcharts, architecture diagrams, etc. - **Natural Language**: Describe diagrams in plain text - flowcharts, architecture diagrams, etc.
- **Edit Support**: Modify existing diagrams with natural language instructions - **Edit Support**: Modify existing diagrams with natural language instructions
- **Export**: Save diagrams as `.drawio` files - **Export**: Save diagrams as `.drawio` files
- **Self-contained**: Embedded server, works offline (except draw.io UI which loads from `embed.diagrams.net` by default, configurable via `DRAWIO_BASE_URL`) - **Self-contained**: Embedded server, works offline (except draw.io UI which loads from embed.diagrams.net)
## Available Tools ## Available Tools
@@ -148,33 +130,6 @@ Use the standard MCP configuration with:
| Variable | Default | Description | | Variable | Default | Description |
|----------|---------|-------------| |----------|---------|-------------|
| `PORT` | `6002` | Port for the embedded HTTP server | | `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 ## Troubleshooting

View File

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

View File

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

View File

@@ -13,45 +13,6 @@ import {
} from "./history.js" } from "./history.js"
import { log } from "./logger.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 { interface SessionState {
xml: string xml: string
version: number version: number
@@ -166,12 +127,7 @@ function cleanupExpiredSessions(): void {
} }
} }
const cleanupIntervalId = setInterval(cleanupExpiredSessions, 5 * 60 * 1000) setInterval(cleanupExpiredSessions, 5 * 60 * 1000)
export function shutdown(): void {
clearInterval(cleanupIntervalId)
stopHttpServer()
}
export function getServerPort(): number { export function getServerPort(): number {
return serverPort return serverPort
@@ -194,11 +150,8 @@ function handleRequest(
} }
if (url.pathname === "/" || url.pathname === "/index.html") { if (url.pathname === "/" || url.pathname === "/index.html") {
const sessionId = url.searchParams.get("mcp") || ""
ensureSessionStateInitialized(sessionId)
res.writeHead(200, { "Content-Type": "text/html" }) res.writeHead(200, { "Content-Type": "text/html" })
res.end(getHtmlPage(sessionId)) res.end(getHtmlPage(url.searchParams.get("mcp") || ""))
} else if (url.pathname === "/api/state") { } else if (url.pathname === "/api/state") {
handleStateApi(req, res, url) handleStateApi(req, res, url)
} else if (url.pathname === "/api/history") { } else if (url.pathname === "/api/history") {
@@ -225,7 +178,6 @@ function handleStateApi(
res.end(JSON.stringify({ error: "sessionId required" })) res.end(JSON.stringify({ error: "sessionId required" }))
return return
} }
ensureSessionStateInitialized(sessionId)
const state = stateStore.get(sessionId) const state = stateStore.get(sessionId)
res.writeHead(200, { "Content-Type": "application/json" }) res.writeHead(200, { "Content-Type": "application/json" })
res.end( res.end(
@@ -446,7 +398,7 @@ function getHtmlPage(sessionId: string): string {
</div> </div>
<div id="status" class="status disconnected">Connecting...</div> <div id="status" class="status disconnected">Connecting...</div>
</div> </div>
<iframe id="drawio" src="${normalizeUrl(DRAWIO_BASE_URL)}/?embed=1&proto=json&spin=1&libraries=1"></iframe> <iframe id="drawio" src="https://embed.diagrams.net/?embed=1&proto=json&spin=1&libraries=1"></iframe>
</div> </div>
<button id="history-btn" title="History" ${sessionId ? "" : "disabled"}> <button id="history-btn" title="History" ${sessionId ? "" : "disabled"}>
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"> <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
@@ -476,7 +428,7 @@ function getHtmlPage(sessionId: string): string {
let pendingAiSvg = false; let pendingAiSvg = false;
window.addEventListener('message', (e) => { window.addEventListener('message', (e) => {
if (e.origin !== '${DRAWIO_ORIGIN}') return; if (e.origin !== 'https://embed.diagrams.net') return;
try { try {
const msg = JSON.parse(e.data); const msg = JSON.parse(e.data);
if (msg.event === 'init') { if (msg.event === 'init') {

View File

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

View File

@@ -1,28 +0,0 @@
import { defineConfig } from "@playwright/test"
export default defineConfig({
testDir: "./tests/e2e",
fullyParallel: true,
forbidOnly: !!process.env.CI,
retries: process.env.CI ? 2 : 0,
workers: process.env.CI ? 1 : undefined,
reporter: process.env.CI ? [["list"], ["html"]] : "html",
webServer: {
command: process.env.CI ? "npm run start" : "npm run dev",
port: process.env.CI ? 6001 : 6002,
reuseExistingServer: !process.env.CI,
timeout: 120 * 1000,
},
use: {
baseURL: process.env.CI
? "http://localhost:6001"
: "http://localhost:6002",
trace: "on-first-retry",
},
projects: [
{
name: "chromium",
use: { browserName: "chromium" },
},
],
})

View File

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

View File

@@ -1,22 +0,0 @@
import { expect, getIframe, test } from "./lib/fixtures"
test.describe("Chat Panel", () => {
test.beforeEach(async ({ page }) => {
await page.goto("/", { waitUntil: "networkidle" })
await getIframe(page).waitFor({ state: "visible", timeout: 30000 })
})
test("page has interactive elements", async ({ page }) => {
const buttons = page.locator("button")
const count = await buttons.count()
expect(count).toBeGreaterThan(0)
})
test("draw.io iframe is interactive", async ({ page }) => {
const iframe = getIframe(page)
await expect(iframe).toBeVisible()
const src = await iframe.getAttribute("src")
expect(src).toBeTruthy()
})
})

View File

@@ -1,137 +0,0 @@
import { SINGLE_BOX_XML } from "./fixtures/diagrams"
import {
expect,
getChatInput,
getIframe,
sendMessage,
test,
} from "./lib/fixtures"
import { createMockSSEResponse } from "./lib/helpers"
test.describe("Copy/Paste Functionality", () => {
test("can paste text into chat input", async ({ page }) => {
await page.goto("/", { waitUntil: "networkidle" })
await getIframe(page).waitFor({ state: "visible", timeout: 30000 })
const chatInput = getChatInput(page)
await expect(chatInput).toBeVisible({ timeout: 10000 })
await chatInput.focus()
await page.keyboard.insertText("Create a flowchart diagram")
await expect(chatInput).toHaveValue("Create a flowchart diagram")
})
test("can paste multiline text into chat input", async ({ page }) => {
await page.goto("/", { waitUntil: "networkidle" })
await getIframe(page).waitFor({ state: "visible", timeout: 30000 })
const chatInput = getChatInput(page)
await expect(chatInput).toBeVisible({ timeout: 10000 })
await chatInput.focus()
const multilineText = "Line 1\nLine 2\nLine 3"
await page.keyboard.insertText(multilineText)
await expect(chatInput).toHaveValue(multilineText)
})
test("copy button copies response text", async ({ page }) => {
await page.route("**/api/chat", async (route) => {
await route.fulfill({
status: 200,
contentType: "text/event-stream",
body: createMockSSEResponse(
SINGLE_BOX_XML,
"Here is your diagram with a test box.",
),
})
})
await page.goto("/", { waitUntil: "networkidle" })
await getIframe(page).waitFor({ state: "visible", timeout: 30000 })
await sendMessage(page, "Create a test box")
// Wait for response
await expect(
page.locator('text="Here is your diagram with a test box."'),
).toBeVisible({ timeout: 15000 })
// Find copy button in message
const copyButton = page.locator(
'[data-testid="copy-button"], button[aria-label*="Copy"], button:has(svg.lucide-copy), button:has(svg.lucide-clipboard)',
)
// Copy button feature may not exist - skip if not available
const buttonCount = await copyButton.count()
if (buttonCount === 0) {
test.skip()
return
}
await copyButton.first().click()
await expect(
page.locator('text="Copied"').or(page.locator("svg.lucide-check")),
).toBeVisible({ timeout: 3000 })
})
test("keyboard shortcuts work in chat input", async ({ page }) => {
await page.goto("/", { waitUntil: "networkidle" })
await getIframe(page).waitFor({ state: "visible", timeout: 30000 })
const chatInput = getChatInput(page)
await expect(chatInput).toBeVisible({ timeout: 10000 })
await chatInput.fill("Hello world")
await chatInput.press("ControlOrMeta+a")
await chatInput.fill("New text")
await expect(chatInput).toHaveValue("New text")
})
test("can undo/redo in chat input", async ({ page }) => {
await page.goto("/", { waitUntil: "networkidle" })
await getIframe(page).waitFor({ state: "visible", timeout: 30000 })
const chatInput = getChatInput(page)
await expect(chatInput).toBeVisible({ timeout: 10000 })
await chatInput.fill("First text")
await chatInput.press("Tab")
await chatInput.focus()
await chatInput.fill("Second text")
await chatInput.press("ControlOrMeta+z")
// Verify page is still functional after undo
await expect(chatInput).toBeVisible()
})
test("chat input handles special characters", async ({ page }) => {
await page.goto("/", { waitUntil: "networkidle" })
await getIframe(page).waitFor({ state: "visible", timeout: 30000 })
const chatInput = getChatInput(page)
await expect(chatInput).toBeVisible({ timeout: 10000 })
const specialText = "Test <>&\"' special chars 日本語 中文 🎉"
await chatInput.fill(specialText)
await expect(chatInput).toHaveValue(specialText)
})
test("long text in chat input scrolls", async ({ page }) => {
await page.goto("/", { waitUntil: "networkidle" })
await getIframe(page).waitFor({ state: "visible", timeout: 30000 })
const chatInput = getChatInput(page)
await expect(chatInput).toBeVisible({ timeout: 10000 })
const longText = "This is a very long text. ".repeat(50)
await chatInput.fill(longText)
const value = await chatInput.inputValue()
expect(value.length).toBeGreaterThan(500)
})
})

View File

@@ -1,128 +0,0 @@
import {
CAT_DIAGRAM_XML,
FLOWCHART_XML,
NEW_NODE_XML,
} from "./fixtures/diagrams"
import {
createMultiTurnMock,
expect,
getChatInput,
sendMessage,
test,
waitForComplete,
waitForCompleteCount,
} from "./lib/fixtures"
import { createMockSSEResponse } from "./lib/helpers"
test.describe("Diagram Generation", () => {
test.beforeEach(async ({ page }) => {
await page.route("**/api/chat", async (route) => {
await route.fulfill({
status: 200,
contentType: "text/event-stream",
body: createMockSSEResponse(
CAT_DIAGRAM_XML,
"I'll create a diagram for you.",
),
})
})
await page.goto("/", { waitUntil: "networkidle" })
await page
.locator("iframe")
.waitFor({ state: "visible", timeout: 30000 })
})
test("generates and displays a diagram", async ({ page }) => {
await sendMessage(page, "Draw a cat")
await expect(page.locator('text="Generate Diagram"')).toBeVisible({
timeout: 15000,
})
await waitForComplete(page)
})
test("chat input clears after sending", async ({ page }) => {
const chatInput = getChatInput(page)
await expect(chatInput).toBeVisible({ timeout: 10000 })
await chatInput.fill("Draw a cat")
await chatInput.press("ControlOrMeta+Enter")
await expect(chatInput).toHaveValue("", { timeout: 5000 })
})
test("user message appears in chat", async ({ page }) => {
await sendMessage(page, "Draw a cute cat")
await expect(page.locator('text="Draw a cute cat"')).toBeVisible({
timeout: 10000,
})
})
test("assistant text message appears in chat", async ({ page }) => {
await sendMessage(page, "Draw a cat")
await expect(
page.locator('text="I\'ll create a diagram for you."'),
).toBeVisible({ timeout: 10000 })
})
})
test.describe("Diagram Edit", () => {
test.beforeEach(async ({ page }) => {
await page.route(
"**/api/chat",
createMultiTurnMock([
{ xml: FLOWCHART_XML, text: "I'll create a diagram for you." },
{
xml: FLOWCHART_XML.replace("Process", "Updated Process"),
text: "I'll create a diagram for you.",
},
]),
)
await page.goto("/", { waitUntil: "networkidle" })
await page
.locator("iframe")
.waitFor({ state: "visible", timeout: 30000 })
})
test("can edit an existing diagram", async ({ page }) => {
// First: create initial diagram
await sendMessage(page, "Create a flowchart")
await waitForComplete(page)
// Second: edit the diagram
await sendMessage(page, "Change Process to Updated Process")
await waitForCompleteCount(page, 2)
})
})
test.describe("Diagram Append", () => {
test.beforeEach(async ({ page }) => {
await page.route(
"**/api/chat",
createMultiTurnMock([
{ xml: FLOWCHART_XML, text: "I'll create a diagram for you." },
{
xml: NEW_NODE_XML,
text: "I'll create a diagram for you.",
toolName: "append_diagram",
},
]),
)
await page.goto("/", { waitUntil: "networkidle" })
await page
.locator("iframe")
.waitFor({ state: "visible", timeout: 30000 })
})
test("can append to an existing diagram", async ({ page }) => {
// First: create initial diagram
await sendMessage(page, "Create a flowchart")
await waitForComplete(page)
// Second: append to diagram
await sendMessage(page, "Add a new node to the right")
await waitForCompleteCount(page, 2)
})
})

View File

@@ -1,136 +0,0 @@
import { TRUNCATED_XML } from "./fixtures/diagrams"
import {
createErrorMock,
expect,
getChatInput,
getIframe,
sendMessage,
test,
} from "./lib/fixtures"
test.describe("Error Handling", () => {
test("displays error message when API returns 500", async ({ page }) => {
await page.route(
"**/api/chat",
createErrorMock(500, "Internal server error"),
)
await page.goto("/", { waitUntil: "networkidle" })
await getIframe(page).waitFor({ state: "visible", timeout: 30000 })
await sendMessage(page, "Draw a cat")
// Should show error indication
const errorIndicator = page
.locator('[role="alert"]')
.or(page.locator("[data-sonner-toast]"))
.or(page.locator("text=/error|failed|something went wrong/i"))
await expect(errorIndicator.first()).toBeVisible({ timeout: 10000 })
// User should be able to type again
const chatInput = getChatInput(page)
await chatInput.fill("Retry message")
await expect(chatInput).toHaveValue("Retry message")
})
test("displays error message when API returns 429 rate limit", async ({
page,
}) => {
await page.route(
"**/api/chat",
createErrorMock(429, "Rate limit exceeded"),
)
await page.goto("/", { waitUntil: "networkidle" })
await getIframe(page).waitFor({ state: "visible", timeout: 30000 })
await sendMessage(page, "Draw a cat")
// Should show error indication for rate limit
const errorIndicator = page
.locator('[role="alert"]')
.or(page.locator("[data-sonner-toast]"))
.or(page.locator("text=/rate limit|too many|try again/i"))
await expect(errorIndicator.first()).toBeVisible({ timeout: 10000 })
// User should be able to type again
const chatInput = getChatInput(page)
await chatInput.fill("Retry after rate limit")
await expect(chatInput).toHaveValue("Retry after rate limit")
})
test("handles network timeout gracefully", async ({ page }) => {
await page.route("**/api/chat", async (route) => {
await new Promise((resolve) => setTimeout(resolve, 2000))
await route.abort("timedout")
})
await page.goto("/", { waitUntil: "networkidle" })
await getIframe(page).waitFor({ state: "visible", timeout: 30000 })
await sendMessage(page, "Draw a cat")
// Should show error indication for network failure
const errorIndicator = page
.locator('[role="alert"]')
.or(page.locator("[data-sonner-toast]"))
.or(page.locator("text=/error|failed|network|timeout/i"))
await expect(errorIndicator.first()).toBeVisible({ timeout: 10000 })
// After timeout, user should be able to type again
const chatInput = getChatInput(page)
await chatInput.fill("Try again after timeout")
await expect(chatInput).toHaveValue("Try again after timeout")
})
test("shows truncated badge for incomplete XML", async ({ page }) => {
const toolCallId = `call_${Date.now()}`
const textId = `text_${Date.now()}`
const messageId = `msg_${Date.now()}`
const events = [
{ type: "start", messageId },
{ type: "text-start", id: textId },
{ type: "text-delta", id: textId, delta: "Creating diagram..." },
{ type: "text-end", id: textId },
{
type: "tool-input-start",
toolCallId,
toolName: "display_diagram",
},
{
type: "tool-input-available",
toolCallId,
toolName: "display_diagram",
input: { xml: TRUNCATED_XML },
},
{
type: "tool-output-error",
toolCallId,
error: "XML validation failed",
},
{ type: "finish" },
]
await page.route("**/api/chat", async (route) => {
await route.fulfill({
status: 200,
contentType: "text/event-stream",
body:
events
.map((e) => `data: ${JSON.stringify(e)}\n\n`)
.join("") + "data: [DONE]\n\n",
})
})
await page.goto("/", { waitUntil: "networkidle" })
await getIframe(page).waitFor({ state: "visible", timeout: 30000 })
await sendMessage(page, "Draw something")
// Should show truncated badge
await expect(page.locator('text="Truncated"')).toBeVisible({
timeout: 15000,
})
})
})

View File

@@ -1,152 +0,0 @@
import { SINGLE_BOX_XML } from "./fixtures/diagrams"
import {
expect,
getChatInput,
getIframe,
sendMessage,
test,
} from "./lib/fixtures"
import { createMockSSEResponse } from "./lib/helpers"
test.describe("File Upload", () => {
test("upload button opens file picker", async ({ page }) => {
await page.goto("/", { waitUntil: "networkidle" })
await getIframe(page).waitFor({ state: "visible", timeout: 30000 })
const uploadButton = page.locator(
'button[aria-label="Upload file"], button:has(svg.lucide-image)',
)
await expect(uploadButton.first()).toBeVisible({ timeout: 10000 })
await expect(uploadButton.first()).toBeEnabled()
})
test("shows file preview after selecting image", async ({ page }) => {
await page.goto("/", { waitUntil: "networkidle" })
await getIframe(page).waitFor({ state: "visible", timeout: 30000 })
const fileInput = page.locator('input[type="file"]')
await fileInput.setInputFiles({
name: "test-image.png",
mimeType: "image/png",
buffer: Buffer.from(
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==",
"base64",
),
})
await expect(
page.locator('[role="alert"][data-type="error"]'),
).not.toBeVisible({ timeout: 2000 })
})
test("can remove uploaded file", async ({ page }) => {
await page.goto("/", { waitUntil: "networkidle" })
await getIframe(page).waitFor({ state: "visible", timeout: 30000 })
const fileInput = page.locator('input[type="file"]')
await fileInput.setInputFiles({
name: "test-image.png",
mimeType: "image/png",
buffer: Buffer.from(
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==",
"base64",
),
})
await expect(
page.locator('[role="alert"][data-type="error"]'),
).not.toBeVisible({ timeout: 2000 })
const removeButton = page.locator(
'[data-testid="remove-file-button"], button[aria-label*="Remove"], button:has(svg.lucide-x)',
)
const removeButtonCount = await removeButton.count()
if (removeButtonCount === 0) {
test.skip()
return
}
await removeButton.first().click()
await expect(removeButton.first()).not.toBeVisible({ timeout: 2000 })
})
test("sends file with message to API", async ({ page }) => {
let capturedRequest: any = null
await page.route("**/api/chat", async (route) => {
capturedRequest = route.request()
await route.fulfill({
status: 200,
contentType: "text/event-stream",
body: createMockSSEResponse(
SINGLE_BOX_XML,
"Based on your image, here is a diagram:",
),
})
})
await page.goto("/", { waitUntil: "networkidle" })
await getIframe(page).waitFor({ state: "visible", timeout: 30000 })
const fileInput = page.locator('input[type="file"]')
await fileInput.setInputFiles({
name: "architecture.png",
mimeType: "image/png",
buffer: Buffer.from(
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==",
"base64",
),
})
await sendMessage(page, "Convert this to a diagram")
await expect(
page.locator('text="Based on your image, here is a diagram:"'),
).toBeVisible({ timeout: 15000 })
expect(capturedRequest).not.toBeNull()
})
test("shows error for oversized file", async ({ page }) => {
await page.goto("/", { waitUntil: "networkidle" })
await getIframe(page).waitFor({ state: "visible", timeout: 30000 })
const fileInput = page.locator('input[type="file"]')
const largeBuffer = Buffer.alloc(3 * 1024 * 1024, "x")
await fileInput.setInputFiles({
name: "large-image.png",
mimeType: "image/png",
buffer: largeBuffer,
})
await expect(
page.locator('[role="alert"], [data-sonner-toast]').first(),
).toBeVisible({ timeout: 5000 })
})
test("drag and drop file upload works", async ({ page }) => {
await page.goto("/", { waitUntil: "networkidle" })
await getIframe(page).waitFor({ state: "visible", timeout: 30000 })
const chatForm = page.locator("form").first()
const dataTransfer = await page.evaluateHandle(() => {
const dt = new DataTransfer()
const file = new File(["test content"], "dropped-image.png", {
type: "image/png",
})
dt.items.add(file)
return dt
})
await chatForm.dispatchEvent("dragover", { dataTransfer })
await chatForm.dispatchEvent("drop", { dataTransfer })
await expect(getChatInput(page)).toBeVisible({ timeout: 3000 })
})
})

View File

@@ -1,50 +0,0 @@
/**
* Shared XML diagram fixtures for E2E tests
*/
// Simple cat diagram
export const CAT_DIAGRAM_XML = `<mxCell id="cat-head" value="Cat Head" style="ellipse;whiteSpace=wrap;html=1;fillColor=#FFE4B5;" vertex="1" parent="1">
<mxGeometry x="200" y="100" width="100" height="80" as="geometry"/>
</mxCell>
<mxCell id="cat-body" value="Cat Body" style="ellipse;whiteSpace=wrap;html=1;fillColor=#FFE4B5;" vertex="1" parent="1">
<mxGeometry x="180" y="180" width="140" height="100" as="geometry"/>
</mxCell>`
// Simple flowchart
export const FLOWCHART_XML = `<mxCell id="start" value="Start" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#d5e8d4;" vertex="1" parent="1">
<mxGeometry x="200" y="50" width="100" height="40" as="geometry"/>
</mxCell>
<mxCell id="process" value="Process" style="rounded=0;whiteSpace=wrap;html=1;fillColor=#dae8fc;" vertex="1" parent="1">
<mxGeometry x="200" y="130" width="100" height="40" as="geometry"/>
</mxCell>
<mxCell id="end" value="End" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f8cecc;" vertex="1" parent="1">
<mxGeometry x="200" y="210" width="100" height="40" as="geometry"/>
</mxCell>`
// Simple single box
export const SINGLE_BOX_XML = `<mxCell id="box" value="Test Box" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#d5e8d4;" vertex="1" parent="1">
<mxGeometry x="100" y="100" width="120" height="60" as="geometry"/>
</mxCell>`
// Test node for iframe interaction tests
export const TEST_NODE_XML = `<mxCell id="test-node-123" value="Test Node" style="rounded=1;fillColor=#d5e8d4;" vertex="1" parent="1">
<mxGeometry x="100" y="100" width="120" height="60" as="geometry"/>
</mxCell>`
// Architecture box
export const ARCHITECTURE_XML = `<mxCell id="arch" value="Architecture" style="rounded=1;" vertex="1" parent="1">
<mxGeometry x="100" y="100" width="120" height="50" as="geometry"/>
</mxCell>`
// New node for append tests
export const NEW_NODE_XML = `<mxCell id="new-node" value="New Node" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#fff2cc;" vertex="1" parent="1">
<mxGeometry x="350" y="130" width="100" height="40" as="geometry"/>
</mxCell>`
// Truncated XML for error tests
export const TRUNCATED_XML = `<mxCell id="node1" value="Start" style="rounded=1;" vertex="1" parent="1">
<mxGeometry x="100" y="100" width="100" height="40"`
// Simple boxes for multi-turn tests
export const createBoxXml = (id: string, label: string, y = 100) =>
`<mxCell id="${id}" value="${label}" style="rounded=1;" vertex="1" parent="1"><mxGeometry x="100" y="${y}" width="100" height="40" as="geometry"/></mxCell>`

View File

@@ -1,215 +0,0 @@
import { SINGLE_BOX_XML } from "./fixtures/diagrams"
import {
expect,
expectBeforeAndAfterReload,
getChatInput,
getIframe,
getIframeContent,
openSettings,
sendMessage,
test,
waitForComplete,
waitForText,
} from "./lib/fixtures"
import { createMockSSEResponse } from "./lib/helpers"
test.describe("History and Session Restore", () => {
test("new chat button clears conversation", async ({ page }) => {
await page.route("**/api/chat", async (route) => {
await route.fulfill({
status: 200,
contentType: "text/event-stream",
body: createMockSSEResponse(
SINGLE_BOX_XML,
"Created your test diagram.",
),
})
})
await page.goto("/", { waitUntil: "networkidle" })
await getIframe(page).waitFor({ state: "visible", timeout: 30000 })
await test.step("create a conversation", async () => {
await sendMessage(page, "Create a test diagram")
await waitForText(page, "Created your test diagram.")
})
await test.step("click new chat button", async () => {
const newChatButton = page.locator(
'[data-testid="new-chat-button"]',
)
await expect(newChatButton).toBeVisible({ timeout: 5000 })
await newChatButton.click()
})
await test.step("verify conversation is cleared", async () => {
await expect(
page.locator('text="Created your test diagram."'),
).not.toBeVisible({ timeout: 5000 })
})
})
test("chat history sidebar shows past conversations", async ({ page }) => {
await page.goto("/", { waitUntil: "networkidle" })
await getIframe(page).waitFor({ state: "visible", timeout: 30000 })
const historyButton = page.locator(
'button[aria-label*="History"]:not([disabled]), button:has(svg.lucide-history):not([disabled]), button:has(svg.lucide-menu):not([disabled]), button:has(svg.lucide-sidebar):not([disabled]), button:has(svg.lucide-panel-left):not([disabled])',
)
const buttonCount = await historyButton.count()
if (buttonCount === 0) {
test.skip()
return
}
await historyButton.first().click()
await expect(getChatInput(page)).toBeVisible({ timeout: 3000 })
})
test("conversation persists after page reload", async ({ page }) => {
await page.route("**/api/chat", async (route) => {
await route.fulfill({
status: 200,
contentType: "text/event-stream",
body: createMockSSEResponse(
SINGLE_BOX_XML,
"This message should persist.",
),
})
})
await page.goto("/", { waitUntil: "networkidle" })
await getIframe(page).waitFor({ state: "visible", timeout: 30000 })
await test.step("create conversation", async () => {
await sendMessage(page, "Create persistent diagram")
await waitForText(page, "This message should persist.")
})
await test.step("verify message appears before reload", async () => {
await expect(getChatInput(page)).toBeVisible({ timeout: 10000 })
await expect(
page.locator('text="This message should persist."'),
).toBeVisible({ timeout: 10000 })
})
// Note: After reload, mocked responses won't persist since we're not
// testing with real localStorage. We just verify the app loads correctly.
await test.step("verify app loads after reload", async () => {
await page.reload({ waitUntil: "networkidle" })
await getIframe(page).waitFor({ state: "visible", timeout: 30000 })
await expect(getChatInput(page)).toBeVisible({ timeout: 10000 })
})
})
test("diagram state persists after reload", async ({ page }) => {
await page.route("**/api/chat", async (route) => {
await route.fulfill({
status: 200,
contentType: "text/event-stream",
body: createMockSSEResponse(
SINGLE_BOX_XML,
"Created a diagram that should be saved.",
),
})
})
await page.goto("/", { waitUntil: "networkidle" })
await getIframe(page).waitFor({ state: "visible", timeout: 30000 })
await sendMessage(page, "Create saveable diagram")
await waitForComplete(page)
await page.reload({ waitUntil: "networkidle" })
await getIframe(page).waitFor({ state: "visible", timeout: 30000 })
const frame = getIframeContent(page)
await expect(
frame
.locator(".geMenubarContainer, .geDiagramContainer, canvas")
.first(),
).toBeVisible({ timeout: 30000 })
})
test("can restore from browser back/forward", async ({ page }) => {
await page.route("**/api/chat", async (route) => {
await route.fulfill({
status: 200,
contentType: "text/event-stream",
body: createMockSSEResponse(
SINGLE_BOX_XML,
"Testing browser navigation.",
),
})
})
await page.goto("/", { waitUntil: "networkidle" })
await getIframe(page).waitFor({ state: "visible", timeout: 30000 })
await sendMessage(page, "Test navigation")
await waitForText(page, "Testing browser navigation.")
await page.goto("/about", { waitUntil: "networkidle" })
await page.goBack({ waitUntil: "networkidle" })
await getIframe(page).waitFor({ state: "visible", timeout: 30000 })
await expect(getChatInput(page)).toBeVisible({ timeout: 10000 })
})
test("settings are restored after reload", async ({ page }) => {
await page.goto("/", { waitUntil: "networkidle" })
await getIframe(page).waitFor({ state: "visible", timeout: 30000 })
await openSettings(page)
await page.keyboard.press("Escape")
await page.reload({ waitUntil: "networkidle" })
await getIframe(page).waitFor({ state: "visible", timeout: 30000 })
await openSettings(page)
})
test("model selection persists", async ({ page }) => {
await page.goto("/", { waitUntil: "networkidle" })
await getIframe(page).waitFor({ state: "visible", timeout: 30000 })
const modelSelector = page.locator(
'button[aria-label*="Model"], [data-testid="model-selector"], button:has-text("Claude")',
)
const selectorCount = await modelSelector.count()
if (selectorCount === 0) {
test.skip()
return
}
const initialModel = await modelSelector.first().textContent()
await page.reload({ waitUntil: "networkidle" })
await getIframe(page).waitFor({ state: "visible", timeout: 30000 })
const modelAfterReload = await modelSelector.first().textContent()
expect(modelAfterReload).toBe(initialModel)
})
test("handles localStorage quota exceeded gracefully", async ({ page }) => {
await page.goto("/", { waitUntil: "networkidle" })
await getIframe(page).waitFor({ state: "visible", timeout: 30000 })
await page.evaluate(() => {
try {
const largeData = "x".repeat(5 * 1024 * 1024)
localStorage.setItem("test-large-data", largeData)
} catch {
// Expected to fail on some browsers
}
})
await expect(getChatInput(page)).toBeVisible({ timeout: 10000 })
await page.evaluate(() => {
localStorage.removeItem("test-large-data")
})
})
})

View File

@@ -1,18 +0,0 @@
import { expect, getIframe, test } from "./lib/fixtures"
test.describe("History Dialog", () => {
test.beforeEach(async ({ page }) => {
await page.goto("/", { waitUntil: "networkidle" })
await getIframe(page).waitFor({ state: "visible", timeout: 30000 })
})
test("history button exists in UI", async ({ page }) => {
// History button may be disabled initially (no history)
// Just verify it exists in the DOM
const historyButton = page
.locator("button")
.filter({ has: page.locator("svg") })
const count = await historyButton.count()
expect(count).toBeGreaterThan(0)
})
})

View File

@@ -1,122 +0,0 @@
import { TEST_NODE_XML } from "./fixtures/diagrams"
import {
expect,
getChatInput,
getIframe,
getIframeContent,
sendMessage,
test,
waitForComplete,
} from "./lib/fixtures"
import { createMockSSEResponse } from "./lib/helpers"
test.describe("Iframe Interaction", () => {
test("draw.io iframe loads successfully", async ({ page }) => {
await page.goto("/", { waitUntil: "networkidle" })
const iframe = getIframe(page)
await expect(iframe).toBeVisible({ timeout: 30000 })
// iframe should have loaded draw.io content
const frame = getIframeContent(page)
await expect(
frame
.locator(".geMenubarContainer, .geDiagramContainer, canvas")
.first(),
).toBeVisible({ timeout: 30000 })
})
test("can interact with draw.io toolbar", async ({ page }) => {
await page.goto("/", { waitUntil: "networkidle" })
await getIframe(page).waitFor({ state: "visible", timeout: 30000 })
const frame = getIframeContent(page)
// Draw.io menu items should be accessible
await expect(
frame
.locator('text="Diagram"')
.or(frame.locator('[title*="Diagram"]')),
).toBeVisible({ timeout: 10000 })
})
test("diagram XML is rendered in iframe after generation", async ({
page,
}) => {
await page.route("**/api/chat", async (route) => {
await route.fulfill({
status: 200,
contentType: "text/event-stream",
body: createMockSSEResponse(
TEST_NODE_XML,
"Here is your diagram:",
),
})
})
await page.goto("/", { waitUntil: "networkidle" })
await getIframe(page).waitFor({ state: "visible", timeout: 30000 })
await sendMessage(page, "Create a test node")
await waitForComplete(page)
// Give draw.io time to render
await page.waitForTimeout(1000)
})
test("zoom controls work in draw.io", async ({ page }) => {
await page.goto("/", { waitUntil: "networkidle" })
await getIframe(page).waitFor({ state: "visible", timeout: 30000 })
const frame = getIframeContent(page)
// draw.io should be loaded and functional - check for diagram container
await expect(
frame.locator(".geDiagramContainer, canvas").first(),
).toBeVisible({ timeout: 10000 })
})
test("can resize the panel divider", async ({ page }) => {
await page.goto("/", { waitUntil: "networkidle" })
await getIframe(page).waitFor({ state: "visible", timeout: 30000 })
// Find the resizer/divider between panels
const resizer = page.locator(
'[role="separator"], [data-panel-resize-handle-id], .resize-handle',
)
if ((await resizer.count()) > 0) {
await expect(resizer.first()).toBeVisible()
const box = await resizer.first().boundingBox()
if (box) {
await page.mouse.move(
box.x + box.width / 2,
box.y + box.height / 2,
)
await page.mouse.down()
await page.mouse.move(box.x + 50, box.y + box.height / 2)
await page.mouse.up()
}
}
})
test("iframe responds to window resize", async ({ page }) => {
await page.goto("/", { waitUntil: "networkidle" })
await getIframe(page).waitFor({ state: "visible", timeout: 30000 })
const iframe = getIframe(page)
const initialBox = await iframe.boundingBox()
// Resize window
await page.setViewportSize({ width: 800, height: 600 })
await page.waitForTimeout(500)
const newBox = await iframe.boundingBox()
expect(newBox).toBeDefined()
if (initialBox && newBox) {
expect(newBox.width).toBeLessThanOrEqual(800)
}
})
})

View File

@@ -1,26 +0,0 @@
import { expect, getIframe, openSettings, test } from "./lib/fixtures"
test.describe("Keyboard Interactions", () => {
test.beforeEach(async ({ page }) => {
await page.goto("/", { waitUntil: "networkidle" })
await getIframe(page).waitFor({ state: "visible", timeout: 30000 })
})
test("Escape closes settings dialog", async ({ page }) => {
await openSettings(page)
const dialog = page.locator('[role="dialog"]')
await expect(dialog).toBeVisible({ timeout: 5000 })
await page.keyboard.press("Escape")
await expect(dialog).not.toBeVisible({ timeout: 2000 })
})
test("page is keyboard accessible", async ({ page }) => {
const focusableElements = page.locator(
'button, [tabindex="0"], input, textarea, a[href]',
)
const count = await focusableElements.count()
expect(count).toBeGreaterThan(0)
})
})

View File

@@ -1,105 +0,0 @@
import {
expect,
expectBeforeAndAfterReload,
getChatInput,
getIframe,
openSettings,
sleep,
test,
} from "./lib/fixtures"
test.describe("Language Switching", () => {
test("loads English by default", async ({ page }) => {
await page.goto("/", { waitUntil: "networkidle" })
await getIframe(page).waitFor({ state: "visible", timeout: 30000 })
const chatInput = getChatInput(page)
await expect(chatInput).toBeVisible({ timeout: 10000 })
await expect(page.locator('button:has-text("Send")')).toBeVisible()
})
test("can switch to Japanese", async ({ page }) => {
await page.goto("/", { waitUntil: "networkidle" })
await getIframe(page).waitFor({ state: "visible", timeout: 30000 })
await test.step("open settings and select Japanese", async () => {
await openSettings(page)
const languageSelector = page.locator('button:has-text("English")')
await languageSelector.first().click()
await page.locator('text="日本語"').click()
})
await test.step("verify UI is in Japanese", async () => {
await expect(page.locator('button:has-text("送信")')).toBeVisible({
timeout: 5000,
})
})
})
test("can switch to Chinese", async ({ page }) => {
await page.goto("/", { waitUntil: "networkidle" })
await getIframe(page).waitFor({ state: "visible", timeout: 30000 })
await test.step("open settings and select Chinese", async () => {
await openSettings(page)
const languageSelector = page.locator('button:has-text("English")')
await languageSelector.first().click()
await page.locator('text="中文"').click()
})
await test.step("verify UI is in Chinese", async () => {
await expect(page.locator('button:has-text("发送")')).toBeVisible({
timeout: 5000,
})
})
})
test("language persists after reload", async ({ page }) => {
await page.goto("/", { waitUntil: "networkidle" })
await getIframe(page).waitFor({ state: "visible", timeout: 30000 })
await test.step("switch to Japanese", async () => {
await openSettings(page)
const languageSelector = page.locator('button:has-text("English")')
await languageSelector.first().click()
await page.locator('text="日本語"').click()
await page.keyboard.press("Escape")
await sleep(500)
})
await test.step("verify Japanese before reload", async () => {
await expect(page.locator('button:has-text("送信")')).toBeVisible({
timeout: 10000,
})
})
await test.step("reload and verify Japanese persists", async () => {
await page.reload({ waitUntil: "networkidle" })
await getIframe(page).waitFor({ state: "visible", timeout: 30000 })
// Wait for hydration and localStorage to be read
await sleep(1000)
await expect(page.locator('button:has-text("送信")')).toBeVisible({
timeout: 10000,
})
})
})
test("Japanese locale URL works", async ({ page }) => {
await page.goto("/ja", { waitUntil: "networkidle" })
await getIframe(page).waitFor({ state: "visible", timeout: 30000 })
await expect(page.locator('button:has-text("送信")')).toBeVisible({
timeout: 10000,
})
})
test("Chinese locale URL works", async ({ page }) => {
await page.goto("/zh", { waitUntil: "networkidle" })
await getIframe(page).waitFor({ state: "visible", timeout: 30000 })
await expect(page.locator('button:has-text("发送")')).toBeVisible({
timeout: 10000,
})
})
})

View File

@@ -1,208 +0,0 @@
/**
* Playwright test fixtures for E2E tests
* Uses test.extend to provide common setup and helpers
*/
import { test as base, expect, type Page, type Route } from "@playwright/test"
import { createMockSSEResponse, createTextOnlyResponse } from "./helpers"
/**
* Extended test with common fixtures
*/
export const test = base.extend<{
/** Page with iframe already loaded */
appPage: Page
}>({
appPage: async ({ page }, use) => {
await page.goto("/", { waitUntil: "networkidle" })
await page
.locator("iframe")
.waitFor({ state: "visible", timeout: 30000 })
await use(page)
},
})
export { expect }
// ============================================
// Locator helpers
// ============================================
/** Get the chat input textarea */
export function getChatInput(page: Page) {
return page.locator('textarea[aria-label="Chat input"]')
}
/** Get the draw.io iframe */
export function getIframe(page: Page) {
return page.locator("iframe")
}
/** Get the iframe's frame locator for internal queries */
export function getIframeContent(page: Page) {
return page.frameLocator("iframe")
}
/** Get the settings button */
export function getSettingsButton(page: Page) {
return page.locator('[data-testid="settings-button"]')
}
// ============================================
// Action helpers
// ============================================
/** Send a message in the chat input */
export async function sendMessage(page: Page, message: string) {
const chatInput = getChatInput(page)
await expect(chatInput).toBeVisible({ timeout: 10000 })
await chatInput.fill(message)
await chatInput.press("ControlOrMeta+Enter")
}
/** Wait for diagram generation to complete */
export async function waitForComplete(page: Page, timeout = 15000) {
await expect(page.locator('text="Complete"')).toBeVisible({ timeout })
}
/** Wait for N "Complete" badges */
export async function waitForCompleteCount(
page: Page,
count: number,
timeout = 15000,
) {
await expect(page.locator('text="Complete"')).toHaveCount(count, {
timeout,
})
}
/** Wait for a specific text to appear */
export async function waitForText(page: Page, text: string, timeout = 15000) {
await expect(page.locator(`text="${text}"`)).toBeVisible({ timeout })
}
/** Open settings dialog */
export async function openSettings(page: Page) {
await getSettingsButton(page).click()
await expect(page.locator('[role="dialog"]')).toBeVisible({ timeout: 5000 })
}
// ============================================
// Mock helpers
// ============================================
interface MockResponse {
xml: string
text: string
toolName?: string
}
/**
* Create a multi-turn mock handler
* Each request gets the next response in the array
*/
export function createMultiTurnMock(responses: MockResponse[]) {
let requestCount = 0
return async (route: Route) => {
const response =
responses[requestCount] || responses[responses.length - 1]
requestCount++
await route.fulfill({
status: 200,
contentType: "text/event-stream",
body: createMockSSEResponse(
response.xml,
response.text,
response.toolName,
),
})
}
}
/**
* Create a mock that returns text-only responses
*/
export function createTextOnlyMock(responses: string[]) {
let requestCount = 0
return async (route: Route) => {
const text = responses[requestCount] || responses[responses.length - 1]
requestCount++
await route.fulfill({
status: 200,
contentType: "text/event-stream",
body: createTextOnlyResponse(text),
})
}
}
/**
* Create a mock that alternates between text and diagram responses
*/
export function createMixedMock(
responses: Array<
| { type: "text"; text: string }
| { type: "diagram"; xml: string; text: string }
>,
) {
let requestCount = 0
return async (route: Route) => {
const response =
responses[requestCount] || responses[responses.length - 1]
requestCount++
if (response.type === "text") {
await route.fulfill({
status: 200,
contentType: "text/event-stream",
body: createTextOnlyResponse(response.text),
})
} else {
await route.fulfill({
status: 200,
contentType: "text/event-stream",
body: createMockSSEResponse(response.xml, response.text),
})
}
}
}
/**
* Create a mock that returns an error
*/
export function createErrorMock(status: number, error: string) {
return async (route: Route) => {
await route.fulfill({
status,
contentType: "application/json",
body: JSON.stringify({ error }),
})
}
}
// ============================================
// Persistence helpers
// ============================================
/**
* Test that state persists across page reload.
* Runs assertions before reload, reloads page, then runs assertions again.
* Keep assertions narrow and explicit - test one specific thing.
*
* @param page - Playwright page
* @param description - What persistence is being tested (for debugging)
* @param assertion - Async function with expect() calls
*/
export async function expectBeforeAndAfterReload(
page: Page,
description: string,
assertion: () => Promise<void>,
) {
await test.step(`verify ${description} before reload`, assertion)
await page.reload({ waitUntil: "networkidle" })
await getIframe(page).waitFor({ state: "visible", timeout: 30000 })
await test.step(`verify ${description} after reload`, assertion)
}
/** Simple sleep helper */
export function sleep(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms))
}

View File

@@ -1,88 +0,0 @@
/**
* Shared test helpers for E2E tests
*/
/**
* Creates a mock SSE response for the chat API
* Format matches AI SDK UI message stream protocol
*/
export function createMockSSEResponse(
xml: string,
text: string,
toolName = "display_diagram",
) {
const messageId = `msg_${Date.now()}`
const toolCallId = `call_${Date.now()}`
const textId = `text_${Date.now()}`
const events = [
{ type: "start", messageId },
{ type: "text-start", id: textId },
{ type: "text-delta", id: textId, delta: text },
{ type: "text-end", id: textId },
{ type: "tool-input-start", toolCallId, toolName },
{ type: "tool-input-available", toolCallId, toolName, input: { xml } },
{
type: "tool-output-available",
toolCallId,
output: "Successfully displayed the diagram",
},
{ type: "finish" },
]
return (
events.map((e) => `data: ${JSON.stringify(e)}\n\n`).join("") +
"data: [DONE]\n\n"
)
}
/**
* Creates a text-only SSE response (no tool call)
*/
export function createTextOnlyResponse(text: string) {
const messageId = `msg_${Date.now()}`
const textId = `text_${Date.now()}`
const events = [
{ type: "start", messageId },
{ type: "text-start", id: textId },
{ type: "text-delta", id: textId, delta: text },
{ type: "text-end", id: textId },
{ type: "finish" },
]
return (
events.map((e) => `data: ${JSON.stringify(e)}\n\n`).join("") +
"data: [DONE]\n\n"
)
}
/**
* Creates a mock SSE response with a tool error
*/
export function createToolErrorResponse(text: string, errorMessage: string) {
const messageId = `msg_${Date.now()}`
const toolCallId = `call_${Date.now()}`
const textId = `text_${Date.now()}`
const events = [
{ type: "start", messageId },
{ type: "text-start", id: textId },
{ type: "text-delta", id: textId, delta: text },
{ type: "text-end", id: textId },
{ type: "tool-input-start", toolCallId, toolName: "display_diagram" },
{
type: "tool-input-available",
toolCallId,
toolName: "display_diagram",
input: { xml: "<invalid>" },
},
{ type: "tool-output-error", toolCallId, error: errorMessage },
{ type: "finish" },
]
return (
events.map((e) => `data: ${JSON.stringify(e)}\n\n`).join("") +
"data: [DONE]\n\n"
)
}

View File

@@ -1,19 +0,0 @@
import { expect, getIframe, openSettings, test } from "./lib/fixtures"
test.describe("Model Configuration", () => {
test.beforeEach(async ({ page }) => {
await page.goto("/", { waitUntil: "networkidle" })
await getIframe(page).waitFor({ state: "visible", timeout: 30000 })
})
test("settings dialog opens and shows configuration options", async ({
page,
}) => {
await openSettings(page)
const dialog = page.locator('[role="dialog"]')
const buttons = dialog.locator("button")
const buttonCount = await buttons.count()
expect(buttonCount).toBeGreaterThan(0)
})
})

View File

@@ -1,113 +0,0 @@
import { ARCHITECTURE_XML, createBoxXml } from "./fixtures/diagrams"
import {
createMixedMock,
createMultiTurnMock,
expect,
getChatInput,
sendMessage,
test,
waitForComplete,
waitForText,
} from "./lib/fixtures"
import { createTextOnlyResponse } from "./lib/helpers"
test.describe("Multi-turn Conversation", () => {
test("handles multiple diagram requests in sequence", async ({ page }) => {
await page.route(
"**/api/chat",
createMultiTurnMock([
{
xml: createBoxXml("box1", "First"),
text: "Creating diagram 1...",
},
{
xml: createBoxXml("box2", "Second", 200),
text: "Creating diagram 2...",
},
]),
)
await page.goto("/", { waitUntil: "networkidle" })
await page
.locator("iframe")
.waitFor({ state: "visible", timeout: 30000 })
// First request
await sendMessage(page, "Draw first box")
await waitForText(page, "Creating diagram 1...")
// Second request
await sendMessage(page, "Draw second box")
await waitForText(page, "Creating diagram 2...")
// Both messages should be visible
await expect(page.locator('text="Draw first box"')).toBeVisible()
await expect(page.locator('text="Draw second box"')).toBeVisible()
})
test("preserves conversation history", async ({ page }) => {
let requestCount = 0
await page.route("**/api/chat", async (route) => {
requestCount++
const request = route.request()
const body = JSON.parse(request.postData() || "{}")
// Verify messages array grows with each request
if (requestCount === 2) {
expect(body.messages?.length).toBeGreaterThan(1)
}
await route.fulfill({
status: 200,
contentType: "text/event-stream",
body: createTextOnlyResponse(`Response ${requestCount}`),
})
})
await page.goto("/", { waitUntil: "networkidle" })
await page
.locator("iframe")
.waitFor({ state: "visible", timeout: 30000 })
// First message
await sendMessage(page, "Hello")
await waitForText(page, "Response 1")
// Second message (should include history)
await sendMessage(page, "Follow up question")
await waitForText(page, "Response 2")
})
test("can continue after a text-only response", async ({ page }) => {
await page.route(
"**/api/chat",
createMixedMock([
{
type: "text",
text: "I understand. Let me explain the architecture first.",
},
{
type: "diagram",
xml: ARCHITECTURE_XML,
text: "Here is the diagram:",
},
]),
)
await page.goto("/", { waitUntil: "networkidle" })
await page
.locator("iframe")
.waitFor({ state: "visible", timeout: 30000 })
// Ask for explanation first
await sendMessage(page, "Explain the architecture")
await waitForText(
page,
"I understand. Let me explain the architecture first.",
)
// Then ask for diagram
await sendMessage(page, "Now show it as a diagram")
await waitForComplete(page)
})
})

View File

@@ -1,16 +0,0 @@
import { expect, getIframe, test } from "./lib/fixtures"
test.describe("Save Dialog", () => {
test.beforeEach(async ({ page }) => {
await page.goto("/", { waitUntil: "networkidle" })
await getIframe(page).waitFor({ state: "visible", timeout: 30000 })
})
test("save/download buttons exist", async ({ page }) => {
const buttons = page
.locator("button")
.filter({ has: page.locator("svg") })
const count = await buttons.count()
expect(count).toBeGreaterThan(0)
})
})

View File

@@ -1,34 +0,0 @@
import {
expect,
getIframe,
getSettingsButton,
openSettings,
test,
} from "./lib/fixtures"
test.describe("Settings", () => {
test.beforeEach(async ({ page }) => {
await page.goto("/", { waitUntil: "networkidle" })
await getIframe(page).waitFor({ state: "visible", timeout: 30000 })
})
test("settings dialog opens", async ({ page }) => {
await openSettings(page)
// openSettings already verifies dialog is visible
})
test("language selection is available", async ({ page }) => {
await openSettings(page)
const dialog = page.locator('[role="dialog"]')
await expect(dialog.locator('text="English"')).toBeVisible()
})
test("draw.io theme toggle exists", async ({ page }) => {
await openSettings(page)
const dialog = page.locator('[role="dialog"]')
const themeText = dialog.locator("text=/sketch|minimal/i")
await expect(themeText.first()).toBeVisible()
})
})

View File

@@ -1,36 +0,0 @@
import { expect, getIframe, openSettings, test } from "./lib/fixtures"
test.describe("Smoke Tests", () => {
test("homepage loads without errors", async ({ page }) => {
const errors: string[] = []
page.on("pageerror", (err) => errors.push(err.message))
await page.goto("/", { waitUntil: "networkidle" })
await expect(page).toHaveTitle(/Draw\.io/i, { timeout: 10000 })
const iframe = getIframe(page)
await expect(iframe).toBeVisible({ timeout: 30000 })
expect(errors).toEqual([])
})
test("Japanese locale page loads", async ({ page }) => {
const errors: string[] = []
page.on("pageerror", (err) => errors.push(err.message))
await page.goto("/ja", { waitUntil: "networkidle" })
await expect(page).toHaveTitle(/Draw\.io/i, { timeout: 10000 })
const iframe = getIframe(page)
await expect(iframe).toBeVisible({ timeout: 30000 })
expect(errors).toEqual([])
})
test("settings dialog opens", async ({ page }) => {
await page.goto("/", { waitUntil: "networkidle" })
await getIframe(page).waitFor({ state: "visible", timeout: 30000 })
await openSettings(page)
})
})

View File

@@ -1,88 +0,0 @@
import { expect, getIframe, openSettings, sleep, test } from "./lib/fixtures"
test.describe("Theme Switching", () => {
test("can toggle app dark mode", async ({ page }) => {
await page.goto("/", { waitUntil: "networkidle" })
await getIframe(page).waitFor({ state: "visible", timeout: 30000 })
await openSettings(page)
const html = page.locator("html")
const initialClass = await html.getAttribute("class")
const themeButton = page.locator(
"button:has(svg.lucide-sun), button:has(svg.lucide-moon)",
)
if ((await themeButton.count()) > 0) {
await test.step("toggle theme", async () => {
await themeButton.first().click()
await sleep(500)
})
await test.step("verify theme changed", async () => {
const newClass = await html.getAttribute("class")
expect(newClass).not.toBe(initialClass)
})
}
})
test("theme persists after page reload", async ({ page }) => {
await page.goto("/", { waitUntil: "networkidle" })
await getIframe(page).waitFor({ state: "visible", timeout: 30000 })
await openSettings(page)
const themeButton = page.locator(
"button:has(svg.lucide-sun), button:has(svg.lucide-moon)",
)
if ((await themeButton.count()) > 0) {
let themeClass: string | null
await test.step("change theme", async () => {
await themeButton.first().click()
await sleep(300)
themeClass = await page.locator("html").getAttribute("class")
await page.keyboard.press("Escape")
})
await test.step("reload page", async () => {
await page.reload({ waitUntil: "networkidle" })
await getIframe(page).waitFor({
state: "visible",
timeout: 30000,
})
})
await test.step("verify theme persisted", async () => {
const reloadedClass = await page
.locator("html")
.getAttribute("class")
expect(reloadedClass).toBe(themeClass)
})
}
})
test("draw.io theme toggle exists", async ({ page }) => {
await page.goto("/", { waitUntil: "networkidle" })
await getIframe(page).waitFor({ state: "visible", timeout: 30000 })
await openSettings(page)
await expect(
page.locator('[role="dialog"], [role="menu"], form').first(),
).toBeVisible({ timeout: 5000 })
})
test("system theme preference is respected", async ({ page }) => {
await page.emulateMedia({ colorScheme: "dark" })
await page.goto("/", { waitUntil: "networkidle" })
await getIframe(page).waitFor({ state: "visible", timeout: 30000 })
const html = page.locator("html")
const classes = await html.getAttribute("class")
expect(classes).toBeDefined()
})
})

View File

@@ -1,20 +0,0 @@
import { expect, getIframe, test } from "./lib/fixtures"
test.describe("File Upload Area", () => {
test.beforeEach(async ({ page }) => {
await page.goto("/", { waitUntil: "networkidle" })
await getIframe(page).waitFor({ state: "visible", timeout: 30000 })
})
test("page loads without console errors", async ({ page }) => {
const errors: string[] = []
page.on("pageerror", (err) => errors.push(err.message))
await page.waitForTimeout(1000)
const criticalErrors = errors.filter(
(e) => !e.includes("ResizeObserver") && !e.includes("Script error"),
)
expect(criticalErrors).toEqual([])
})
})

View File

@@ -1,50 +0,0 @@
import { describe, expect, it } from "vitest"
import { supportsImageInput, supportsPromptCaching } from "@/lib/ai-providers"
describe("supportsPromptCaching", () => {
it("returns true for Claude models", () => {
expect(supportsPromptCaching("claude-sonnet-4-5")).toBe(true)
expect(supportsPromptCaching("anthropic.claude-3-5-sonnet")).toBe(true)
expect(supportsPromptCaching("us.anthropic.claude-3-5-sonnet")).toBe(
true,
)
expect(supportsPromptCaching("eu.anthropic.claude-3-5-sonnet")).toBe(
true,
)
})
it("returns false for non-Claude models", () => {
expect(supportsPromptCaching("gpt-4o")).toBe(false)
expect(supportsPromptCaching("gemini-pro")).toBe(false)
expect(supportsPromptCaching("deepseek-chat")).toBe(false)
})
})
describe("supportsImageInput", () => {
it("returns true for models with vision capability", () => {
expect(supportsImageInput("gpt-4-vision")).toBe(true)
expect(supportsImageInput("qwen-vl")).toBe(true)
expect(supportsImageInput("deepseek-vl")).toBe(true)
})
it("returns false for Kimi K2 models without vision", () => {
expect(supportsImageInput("kimi-k2")).toBe(false)
expect(supportsImageInput("moonshot/kimi-k2")).toBe(false)
})
it("returns false for DeepSeek text models", () => {
expect(supportsImageInput("deepseek-chat")).toBe(false)
expect(supportsImageInput("deepseek-coder")).toBe(false)
})
it("returns false for Qwen text models", () => {
expect(supportsImageInput("qwen-turbo")).toBe(false)
expect(supportsImageInput("qwen-plus")).toBe(false)
})
it("returns true for Claude and GPT models by default", () => {
expect(supportsImageInput("claude-sonnet-4-5")).toBe(true)
expect(supportsImageInput("gpt-4o")).toBe(true)
expect(supportsImageInput("gemini-pro")).toBe(true)
})
})

View File

@@ -1,54 +0,0 @@
import { describe, expect, it } from "vitest"
import {
CACHED_EXAMPLE_RESPONSES,
findCachedResponse,
} from "@/lib/cached-responses"
describe("findCachedResponse", () => {
it("returns cached response for exact match without image", () => {
const result = findCachedResponse(
"Give me a **animated connector** diagram of transformer's architecture",
false,
)
expect(result).toBeDefined()
expect(result?.xml).toContain("Transformer Architecture")
})
it("returns cached response for exact match with image", () => {
const result = findCachedResponse("Replicate this in aws style", true)
expect(result).toBeDefined()
expect(result?.xml).toContain("AWS")
})
it("returns undefined for non-matching prompt", () => {
const result = findCachedResponse(
"random prompt that doesn't exist",
false,
)
expect(result).toBeUndefined()
})
it("returns undefined when hasImage doesn't match", () => {
// This prompt exists but requires hasImage=true
const result = findCachedResponse("Replicate this in aws style", false)
expect(result).toBeUndefined()
})
it("returns undefined for partial match", () => {
const result = findCachedResponse("Give me a diagram", false)
expect(result).toBeUndefined()
})
it("returns response for Draw a cat prompt", () => {
const result = findCachedResponse("Draw a cat for me", false)
expect(result).toBeDefined()
expect(result?.xml).toContain("ellipse")
})
it("all cached responses have non-empty xml", () => {
for (const response of CACHED_EXAMPLE_RESPONSES) {
expect(response.xml).not.toBe("")
expect(response.xml.length).toBeGreaterThan(0)
}
})
})

View File

@@ -1,171 +0,0 @@
// @vitest-environment node
import { describe, expect, it } from "vitest"
import {
isMinimalDiagram,
replaceHistoricalToolInputs,
validateFileParts,
} from "@/lib/chat-helpers"
describe("validateFileParts", () => {
it("returns valid for no files", () => {
const messages = [
{ role: "user", parts: [{ type: "text", text: "hello" }] },
]
expect(validateFileParts(messages)).toEqual({ valid: true })
})
it("returns valid for files under limit", () => {
const smallBase64 = btoa("x".repeat(100))
const messages = [
{
role: "user",
parts: [
{
type: "file",
url: `data:image/png;base64,${smallBase64}`,
},
],
},
]
expect(validateFileParts(messages)).toEqual({ valid: true })
})
it("returns error for too many files", () => {
const messages = [
{
role: "user",
parts: Array(6)
.fill(null)
.map(() => ({
type: "file",
url: "data:image/png;base64,abc",
})),
},
]
const result = validateFileParts(messages)
expect(result.valid).toBe(false)
expect(result.error).toContain("Too many files")
})
it("returns error for file exceeding size limit", () => {
// Create base64 that decodes to > 2MB
const largeBase64 = btoa("x".repeat(3 * 1024 * 1024))
const messages = [
{
role: "user",
parts: [
{
type: "file",
url: `data:image/png;base64,${largeBase64}`,
},
],
},
]
const result = validateFileParts(messages)
expect(result.valid).toBe(false)
expect(result.error).toContain("exceeds")
})
})
describe("isMinimalDiagram", () => {
it("returns true for empty diagram", () => {
const xml = '<mxCell id="0"/><mxCell id="1" parent="0"/>'
expect(isMinimalDiagram(xml)).toBe(true)
})
it("returns false for diagram with content", () => {
const xml =
'<mxCell id="0"/><mxCell id="1" parent="0"/><mxCell id="2" value="Hello"/>'
expect(isMinimalDiagram(xml)).toBe(false)
})
it("handles whitespace correctly", () => {
const xml = ' <mxCell id="0"/> <mxCell id="1" parent="0"/> '
expect(isMinimalDiagram(xml)).toBe(true)
})
})
describe("replaceHistoricalToolInputs", () => {
it("replaces display_diagram tool inputs with placeholder", () => {
const messages = [
{
role: "assistant",
content: [
{
type: "tool-call",
toolName: "display_diagram",
input: { xml: "<mxCell...>" },
},
],
},
]
const result = replaceHistoricalToolInputs(messages)
expect(result[0].content[0].input.placeholder).toContain(
"XML content replaced",
)
})
it("replaces edit_diagram tool inputs with placeholder", () => {
const messages = [
{
role: "assistant",
content: [
{
type: "tool-call",
toolName: "edit_diagram",
input: { operations: [] },
},
],
},
]
const result = replaceHistoricalToolInputs(messages)
expect(result[0].content[0].input.placeholder).toContain(
"XML content replaced",
)
})
it("removes tool calls with invalid inputs", () => {
const messages = [
{
role: "assistant",
content: [
{
type: "tool-call",
toolName: "display_diagram",
input: {},
},
{
type: "tool-call",
toolName: "display_diagram",
input: null,
},
],
},
]
const result = replaceHistoricalToolInputs(messages)
expect(result[0].content).toHaveLength(0)
})
it("preserves non-assistant messages", () => {
const messages = [{ role: "user", content: "hello" }]
const result = replaceHistoricalToolInputs(messages)
expect(result).toEqual(messages)
})
it("preserves other tool calls", () => {
const messages = [
{
role: "assistant",
content: [
{
type: "tool-call",
toolName: "other_tool",
input: { foo: "bar" },
},
],
},
]
const result = replaceHistoricalToolInputs(messages)
expect(result[0].content[0].input).toEqual({ foo: "bar" })
})
})

View File

@@ -1,86 +0,0 @@
import { describe, expect, it } from "vitest"
import { cn, isMxCellXmlComplete, wrapWithMxFile } from "@/lib/utils"
describe("isMxCellXmlComplete", () => {
it("returns false for empty/null input", () => {
expect(isMxCellXmlComplete("")).toBe(false)
expect(isMxCellXmlComplete(null)).toBe(false)
expect(isMxCellXmlComplete(undefined)).toBe(false)
})
it("returns true for self-closing mxCell", () => {
const xml =
'<mxCell id="2" value="Hello" style="rounded=1;" vertex="1" parent="1"/>'
expect(isMxCellXmlComplete(xml)).toBe(true)
})
it("returns true for mxCell with closing tag", () => {
const xml = `<mxCell id="2" value="Hello" vertex="1" parent="1">
<mxGeometry x="100" y="100" width="120" height="60" as="geometry"/>
</mxCell>`
expect(isMxCellXmlComplete(xml)).toBe(true)
})
it("returns false for truncated mxCell", () => {
const xml =
'<mxCell id="2" value="Hello" style="rounded=1;" vertex="1" parent'
expect(isMxCellXmlComplete(xml)).toBe(false)
})
it("returns false for mxCell with unclosed geometry", () => {
const xml = `<mxCell id="2" value="Hello" vertex="1" parent="1">
<mxGeometry x="100" y="100" width="120"`
expect(isMxCellXmlComplete(xml)).toBe(false)
})
it("returns true for multiple complete mxCells", () => {
const xml = `<mxCell id="2" value="A" vertex="1" parent="1"/>
<mxCell id="3" value="B" vertex="1" parent="1"/>`
expect(isMxCellXmlComplete(xml)).toBe(true)
})
})
describe("wrapWithMxFile", () => {
it("wraps empty string with default structure", () => {
const result = wrapWithMxFile("")
expect(result).toContain("<mxfile>")
expect(result).toContain("<mxGraphModel>")
expect(result).toContain('<mxCell id="0"/>')
expect(result).toContain('<mxCell id="1" parent="0"/>')
})
it("wraps raw mxCell content", () => {
const xml = '<mxCell id="2" value="Hello"/>'
const result = wrapWithMxFile(xml)
expect(result).toContain("<mxfile>")
expect(result).toContain(xml)
expect(result).toContain("</mxfile>")
})
it("returns full mxfile unchanged", () => {
const fullXml =
'<mxfile><diagram name="Page-1"><mxGraphModel></mxGraphModel></diagram></mxfile>'
const result = wrapWithMxFile(fullXml)
expect(result).toBe(fullXml)
})
it("handles whitespace in input", () => {
const result = wrapWithMxFile(" ")
expect(result).toContain("<mxfile>")
})
})
describe("cn (class name utility)", () => {
it("merges class names", () => {
expect(cn("foo", "bar")).toBe("foo bar")
})
it("handles conditional classes", () => {
expect(cn("foo", false && "bar", "baz")).toBe("foo baz")
})
it("merges tailwind classes correctly", () => {
expect(cn("px-2", "px-4")).toBe("px-4")
expect(cn("text-red-500", "text-blue-500")).toBe("text-blue-500")
})
})

View File

@@ -1,17 +0,0 @@
import react from "@vitejs/plugin-react"
import tsconfigPaths from "vite-tsconfig-paths"
import { defineConfig } from "vitest/config"
export default defineConfig({
plugins: [tsconfigPaths(), react()],
test: {
environment: "jsdom",
include: ["tests/**/*.test.{ts,tsx}"],
coverage: {
provider: "v8",
reporter: ["text", "json", "html"],
include: ["lib/**/*.ts", "app/**/*.ts", "app/**/*.tsx"],
exclude: ["**/*.test.ts", "**/*.test.tsx", "**/*.d.ts"],
},
},
})