mirror of
https://github.com/DayuanJiang/next-ai-draw-io.git
synced 2026-01-04 23:32:30 +08:00
Compare commits
1 Commits
renovate/z
...
3df60ad81d
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3df60ad81d |
2
.github/workflows/electron-release.yml
vendored
2
.github/workflows/electron-release.yml
vendored
@@ -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 }}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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,10 +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 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(
|
||||||
@@ -52,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)
|
||||||
@@ -127,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
|
||||||
@@ -144,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({
|
||||||
@@ -161,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>(
|
||||||
@@ -258,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
|
||||||
@@ -322,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
|
||||||
@@ -345,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
|
||||||
@@ -360,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
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -375,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)
|
||||||
@@ -391,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])
|
||||||
@@ -624,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 =
|
||||||
@@ -656,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 &&
|
||||||
@@ -767,9 +984,6 @@ export function ChatMessageDisplay({
|
|||||||
isStreaming={
|
isStreaming={
|
||||||
isStreamingReasoning
|
isStreamingReasoning
|
||||||
}
|
}
|
||||||
defaultOpen={
|
|
||||||
!isRestoredMessage
|
|
||||||
}
|
|
||||||
>
|
>
|
||||||
<ReasoningTrigger />
|
<ReasoningTrigger />
|
||||||
<ReasoningContent>
|
<ReasoningContent>
|
||||||
@@ -912,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}
|
|
||||||
/>
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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,17 +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"
|
|
||||||
>
|
>
|
||||||
<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`}
|
||||||
@@ -1231,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>
|
||||||
|
|
||||||
@@ -1259,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}
|
||||||
@@ -1289,6 +1060,12 @@ export default function ChatPanel({
|
|||||||
onOpenChange={setShowModelConfigDialog}
|
onOpenChange={setShowModelConfigDialog}
|
||||||
modelConfig={modelConfig}
|
modelConfig={modelConfig}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<ResetWarningModal
|
||||||
|
open={showNewChatDialog}
|
||||||
|
onOpenChange={setShowNewChatDialog}
|
||||||
|
onClear={handleNewChat}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -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>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -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
@@ -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,
|
||||||
|
|||||||
@@ -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]
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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(
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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プロバイダーとモデルを設定",
|
||||||
|
|||||||
@@ -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 提供商和模型",
|
||||||
|
|||||||
@@ -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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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",
|
||||||
|
|||||||
19
lib/utils.ts
19
lib/utils.ts
@@ -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
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
|
|||||||
3724
package-lock.json
generated
3724
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -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",
|
||||||
@@ -64,7 +64,6 @@
|
|||||||
"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",
|
"jsdom": "^27.0.0",
|
||||||
"jsonrepair": "^3.13.1",
|
"jsonrepair": "^3.13.1",
|
||||||
@@ -72,7 +71,7 @@
|
|||||||
"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",
|
||||||
@@ -90,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}": [
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
6
packages/mcp-server/package-lock.json
generated
6
packages/mcp-server/package-lock.json
generated
@@ -2051,9 +2051,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/zod": {
|
"node_modules/zod": {
|
||||||
"version": "4.3.5",
|
"version": "4.3.2",
|
||||||
"resolved": "https://registry.npmjs.org/zod/-/zod-4.3.5.tgz",
|
"resolved": "https://registry.npmjs.org/zod/-/zod-4.3.2.tgz",
|
||||||
"integrity": "sha512-k7Nwx6vuWx1IJ9Bjuf4Zt1PEllcwe7cls3VNzm4CQ1/hgtFUK2bRNG3rvnpPUhFjmqJKAKtjV576KnUkHocg/g==",
|
"integrity": "sha512-b8L8yn4rIVfiXyHAmnr52/ZEpDumlT0bmxiq3Ws1ybrinhflGpt12Hvv54kYnEsGPRs6o/Ka3/ppA2OWY21IVg==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
"peer": true,
|
||||||
"funding": {
|
"funding": {
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -194,11 +155,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 +183,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 +403,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 +433,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') {
|
||||||
|
|||||||
@@ -48,7 +48,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)
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user