Compare commits

..
Author SHA1 Message Date
renovate[bot] 399fcf6903 fix(deps): update core framework packages 2026-10-05 23:39:38 +00:00
120 changed files with 9696 additions and 14059 deletions
+19 -8
View File
@@ -34,13 +34,18 @@ jobs:
node-version: 24
cache: "npm"
# The download script reads .env files with @next/env
- name: Download draw.io static files for offline use
run: |
rm -rf public/drawio
git clone --depth 1 https://github.com/jgraph/drawio.git /tmp/drawio
mkdir -p public/drawio
cp -r /tmp/drawio/src/main/webapp/* public/drawio/
rm -rf public/drawio/WEB-INF
rm -rf public/drawio/META-INF
- name: Install dependencies
run: npm install
- name: Download draw.io static files for offline use
run: node scripts/fetch-drawio.mjs
- name: Build and publish
run: npm run dist:${{ matrix.platform }}
env:
@@ -61,13 +66,19 @@ jobs:
node-version: 24
cache: "npm"
# The download script reads .env files with @next/env
- name: Download draw.io static files for offline use
shell: bash
run: |
rm -rf public/drawio
git clone --depth 1 https://github.com/jgraph/drawio.git /tmp/drawio
mkdir -p public/drawio
cp -r /tmp/drawio/src/main/webapp/* public/drawio/
rm -rf public/drawio/WEB-INF
rm -rf public/drawio/META-INF
- name: Install dependencies
run: npm install
- name: Download draw.io static files for offline use
run: node scripts/fetch-drawio.mjs
# Build WITHOUT publishing
- name: Build Windows app
run: npm run dist:win:build
+2 -3
View File
@@ -23,9 +23,8 @@ COPY . .
# Disable Next.js telemetry during build
ENV NEXT_TELEMETRY_DISABLED=1
# Build-time argument for an external draw.io URL. Leave empty to use the
# copy bundled into public/drawio (downloaded by `npm run build`)
ARG NEXT_PUBLIC_DRAWIO_BASE_URL=""
# Build-time argument for self-hosted draw.io URL
ARG NEXT_PUBLIC_DRAWIO_BASE_URL=https://embed.diagrams.net
ENV NEXT_PUBLIC_DRAWIO_BASE_URL=${NEXT_PUBLIC_DRAWIO_BASE_URL}
# Build-time argument to show About link and Notice icon
+2 -3
View File
@@ -107,8 +107,7 @@ Here are some example prompts and their generated diagrams:
- **Image-Based Diagram Replication**: Upload existing diagrams or images and have the AI replicate and enhance them automatically
- **PDF & Text File Upload**: Upload PDF documents and text files to extract content and generate diagrams from existing documents
- **AI Reasoning Display**: View the AI's thinking process for supported models (OpenAI o1/o3, Gemini, Claude, etc.)
- **Versions and Undo**: Every AI change shows up in the chat as a version with a thumbnail. Compare it with the canvas, restore it, or undo it; Ctrl+Z on the canvas also takes back an AI change in one step. Shapes the AI just changed are highlighted.
- **Ask About a Selection**: Select shapes on the canvas and ask the AI to change just those.
- **Diagram History**: Comprehensive version control that tracks all changes, allowing you to view and restore previous versions of your diagrams before the AI editing.
- **Interactive Chat Interface**: Communicate with AI to refine your diagrams in real-time
- **Cloud Architecture Diagram Support**: Specialized support for generating cloud architecture diagrams (AWS, GCP, Azure)
- **Animated Connectors**: Create dynamic and animated connectors between diagram elements for better visualization
@@ -264,7 +263,7 @@ The application uses the following technologies:
- **Next.js**: For the frontend framework and routing
- **Vercel AI SDK** (`ai` + `@ai-sdk/*`): For streaming AI responses and multi-provider support
- **draw.io**: The editor runs from a copy bundled into `public/drawio` (downloaded by `npm run dev` / `npm run build`), so the app can drive it directly
- **react-drawio**: For diagram representation and manipulation
Diagrams are represented as XML that can be rendered in draw.io. The AI processes your commands and generates or modifies this XML accordingly.
+3 -6
View File
@@ -149,10 +149,8 @@ export default function AboutCN() {
:上传现有图表或图像,让AI自动复制和增强
</li>
<li>
<strong>版本与撤销</strong>
:AI
的每次修改都会成为聊天里的一个版本,可以对比、恢复或撤销;在画布上按
Ctrl+Z 也能撤回
<strong>图表历史记录</strong>
:全面的版本控制,跟踪所有更改,允许您查看和恢复AI编辑前的图表版本
</li>
<li>
<strong>交互式聊天界面</strong>
@@ -296,8 +294,7 @@ export default function AboutCN() {
):用于流式AI响应和多提供商支持
</li>
<li>
<strong>draw.io</strong>
:内置在应用里的编辑器,应用可以直接控制它
<strong>react-drawio</strong>:用于图表表示和操作
</li>
</ul>
<p className="text-gray-700 mt-4">
+4 -6
View File
@@ -138,10 +138,8 @@ export default function AboutJA() {
:既存のダイアグラムや画像をアップロードし、AIが自動的に複製・強化
</li>
<li>
<strong>バージョンと取り消し</strong>
:AI
の変更はそれぞれチャット内のバージョンになり、比較・復元・取り消しができます。キャンバスで
Ctrl+Z を押しても元に戻せます
<strong>ダイアグラム履歴</strong>
:すべての変更を追跡する包括的なバージョン管理。AI編集前のダイアグラムの以前のバージョンを表示・復元可能
</li>
<li>
<strong>
@@ -292,8 +290,8 @@ export default function AboutJA() {
):ストリーミングAIレスポンスとマルチプロバイダーサポート
</li>
<li>
<strong>draw.io</strong>
:アプリに同梱されたエディター。アプリから直接操作できます
<strong>react-drawio</strong>
:ダイアグラムの表現と操作
</li>
</ul>
<p className="text-gray-700 mt-4">
+6 -6
View File
@@ -150,10 +150,10 @@ export default function About() {
replicate and enhance them automatically
</li>
<li>
<strong>Versions and Undo</strong>: Every AI change
becomes a version in the chat that you can compare,
restore or undo; Ctrl+Z on the canvas takes it back
too
<strong>Diagram History</strong>: Comprehensive
version control that tracks all changes, allowing
you to view and restore previous versions of your
diagrams before the AI editing
</li>
<li>
<strong>Interactive Chat Interface</strong>:
@@ -304,8 +304,8 @@ export default function About() {
and multi-provider support
</li>
<li>
<strong>draw.io</strong>: The editor, bundled with
the app so it can be driven directly
<strong>react-drawio</strong>: For diagram
representation and manipulation
</li>
</ul>
<p className="text-gray-700 mt-4">
+4 -15
View File
@@ -1,25 +1,19 @@
import { GoogleAnalytics } from "@next/third-parties/google"
import type { Metadata, Viewport } from "next"
import { Instrument_Sans, JetBrains_Mono } from "next/font/google"
import { JetBrains_Mono, Plus_Jakarta_Sans } from "next/font/google"
import { notFound } from "next/navigation"
import Script from "next/script"
import { AppToaster } from "@/components/workspace/app-toaster"
import { DiagramProvider } from "@/contexts/diagram-context"
import { DictionaryProvider } from "@/hooks/use-dictionary"
import type { Locale } from "@/lib/i18n/config"
import { i18n } from "@/lib/i18n/config"
import { getDictionary, hasLocale } from "@/lib/i18n/dictionaries"
import { THEME_INIT_SCRIPT } from "@/stores/settings-store"
import "../globals.css"
const instrumentSans = Instrument_Sans({
variable: "--font-latin",
const plusJakarta = Plus_Jakarta_Sans({
variable: "--font-sans",
subsets: ["latin"],
weight: ["400", "500", "600", "700"],
// Chinese and Japanese text falls through to system fonts, in the
// order globals.css picks for the page's language
fallback: [],
})
const jetbrainsMono = JetBrains_Mono({
@@ -171,22 +165,17 @@ export default async function RootLayout({
return (
<html lang={validLang} suppressHydrationWarning>
<head>
{/* Apply dark mode before the first paint (no white flash) */}
<Script id="theme-init" strategy="beforeInteractive">
{THEME_INIT_SCRIPT}
</Script>
<script
type="application/ld+json"
dangerouslySetInnerHTML={{ __html: JSON.stringify(jsonLd) }}
/>
</head>
<body
className={`${instrumentSans.variable} ${jetbrainsMono.variable} antialiased`}
className={`${plusJakarta.variable} ${jetbrainsMono.variable} antialiased`}
>
<DictionaryProvider dictionary={dictionary}>
<DiagramProvider>{children}</DiagramProvider>
</DictionaryProvider>
<AppToaster />
</body>
{process.env.NEXT_PUBLIC_GA_ID && (
<GoogleAnalytics gaId={process.env.NEXT_PUBLIC_GA_ID} />
+251 -6
View File
@@ -1,11 +1,256 @@
import { Suspense } from "react"
import { Workspace } from "@/components/workspace/workspace"
"use client"
import { usePathname, useRouter } from "next/navigation"
import { Suspense, useCallback, useEffect, useRef, useState } from "react"
import { DrawIoEmbed } from "react-drawio"
import type { ImperativePanelHandle } from "react-resizable-panels"
import ChatPanel from "@/components/chat-panel"
import {
ResizableHandle,
ResizablePanel,
ResizablePanelGroup,
} from "@/components/ui/resizable"
import { useDiagram } from "@/contexts/diagram-context"
import { type DrawioTheme, isDrawioTheme } from "@/lib/drawio-themes"
import { i18n, type Locale } from "@/lib/i18n/config"
export default function Home() {
// The workspace reads ?session= from the URL, which needs a Suspense boundary
const {
drawioRef,
handleDiagramExport,
handleDiagramAutoSave,
onDrawioLoad,
resetDrawioReady,
} = useDiagram()
const router = useRouter()
const pathname = usePathname()
// Extract current language from pathname (e.g., "/zh/about" → "zh")
const currentLang = (pathname.split("/")[1] || i18n.defaultLocale) as Locale
const [isMobile, setIsMobile] = useState(false)
const [isChatVisible, setIsChatVisible] = useState(true)
const [drawioUi, setDrawioUi] = useState<DrawioTheme>("kennedy")
const [darkMode, setDarkMode] = useState(false)
const [isLoaded, setIsLoaded] = useState(false)
const [isDrawioReady, setIsDrawioReady] = useState(false)
const [isElectron, setIsElectron] = useState(false)
const [drawioBaseUrl, setDrawioBaseUrl] = useState(
process.env.NEXT_PUBLIC_DRAWIO_BASE_URL || "https://embed.diagrams.net",
)
const chatPanelRef = useRef<ImperativePanelHandle>(null)
// Load preferences from localStorage after mount
useEffect(() => {
// Restore saved locale and redirect if needed
const savedLocale = localStorage.getItem("next-ai-draw-io-locale")
if (savedLocale && i18n.locales.includes(savedLocale as Locale)) {
const pathParts = pathname.split("/").filter(Boolean)
const currentLocale = pathParts[0]
if (currentLocale !== savedLocale) {
pathParts[0] = savedLocale
// Keep the query (e.g. ?session=) and hash
const { search, hash } = window.location
router.replace(`/${pathParts.join("/")}${search}${hash}`)
return // Wait for redirect
}
}
const savedUi = localStorage.getItem("drawio-theme")
if (isDrawioTheme(savedUi)) {
setDrawioUi(savedUi)
}
const savedDarkMode = localStorage.getItem("next-ai-draw-io-dark-mode")
if (savedDarkMode !== null) {
const isDark = savedDarkMode === "true"
setDarkMode(isDark)
document.documentElement.classList.toggle("dark", isDark)
} else {
const prefersDark = window.matchMedia(
"(prefers-color-scheme: dark)",
).matches
setDarkMode(prefersDark)
document.documentElement.classList.toggle("dark", prefersDark)
}
// Detect Electron and use bundled draw.io files for offline use
// Note: react-drawio uses `new URL(baseUrl)` so we need absolute URL
// Include /index.html because Next.js doesn't auto-serve index.html for directories
const electronDetected =
!process.env.NEXT_PUBLIC_DRAWIO_BASE_URL &&
!!(window as unknown as { electronAPI?: unknown }).electronAPI
if (electronDetected) {
setIsElectron(true)
setDrawioBaseUrl(`${window.location.origin}/drawio/index.html`)
}
setIsLoaded(true)
}, [pathname, router])
const handleDrawioLoad = useCallback(() => {
setIsDrawioReady(true)
onDrawioLoad()
}, [onDrawioLoad])
const handleDarkModeChange = () => {
const newValue = !darkMode
setDarkMode(newValue)
localStorage.setItem("next-ai-draw-io-dark-mode", String(newValue))
document.documentElement.classList.toggle("dark", newValue)
setIsDrawioReady(false)
resetDrawioReady()
}
const handleDrawioUiChange = (theme: DrawioTheme) => {
localStorage.setItem("drawio-theme", theme)
setDrawioUi(theme)
setIsDrawioReady(false)
resetDrawioReady()
}
// Check mobile. No panel is remounted when crossing the breakpoint, so
// the draw.io ready state and the chat's turn stay as they are.
useEffect(() => {
const checkMobile = () => {
setIsMobile(window.innerWidth < 768)
}
checkMobile()
window.addEventListener("resize", checkMobile)
return () => window.removeEventListener("resize", checkMobile)
}, [])
// Give the chat panel the size of this side of the breakpoint. It is
// open on both sides: the mobile panel cannot be collapsed, and one
// collapsed on desktop comes back open
useEffect(() => {
chatPanelRef.current?.resize(isMobile ? 50 : 33)
setIsChatVisible(true)
}, [isMobile])
const toggleChatPanel = () => {
const panel = chatPanelRef.current
if (panel) {
if (panel.isCollapsed()) {
panel.expand()
setIsChatVisible(true)
} else {
panel.collapse()
setIsChatVisible(false)
}
}
}
// Keyboard shortcut for toggling chat panel
useEffect(() => {
const handleKeyDown = (event: KeyboardEvent) => {
if ((event.ctrlKey || event.metaKey) && event.key === "b") {
event.preventDefault()
toggleChatPanel()
}
}
window.addEventListener("keydown", handleKeyDown)
return () => window.removeEventListener("keydown", handleKeyDown)
}, [])
return (
<Suspense fallback={<div className="fixed inset-0 bg-canvas" />}>
<Workspace />
</Suspense>
<div className="h-screen bg-background relative overflow-hidden">
<ResizablePanelGroup
id="main-panel-group"
direction={isMobile ? "vertical" : "horizontal"}
className="h-full"
>
<ResizablePanel
id="drawio-panel"
defaultSize={isMobile ? 50 : 67}
minSize={20}
>
<div
className={`h-full relative ${
isMobile ? "p-1" : "p-2"
}`}
>
<div className="h-full rounded-xl overflow-hidden shadow-soft-lg border border-border/30 relative">
{isLoaded && (
<div
className={`h-full w-full ${isDrawioReady ? "" : "invisible absolute inset-0"}`}
>
<DrawIoEmbed
key={`${drawioUi}-${darkMode}-${currentLang}-${isElectron}`}
ref={drawioRef}
autosave
onAutoSave={handleDiagramAutoSave}
onExport={handleDiagramExport}
onLoad={handleDrawioLoad}
baseUrl={drawioBaseUrl}
urlParameters={{
ui: drawioUi,
spin: false,
libraries: false,
saveAndExit: false,
noSaveBtn: true,
noExitBtn: true,
dark:
darkMode || drawioUi === "dark",
// draw.io names Traditional Chinese "zh-tw"
lang:
currentLang === "zh-Hant"
? "zh-tw"
: currentLang,
// Enable offline mode in Electron to disable external service calls
...(isElectron && {
offline: true,
}),
}}
/>
</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>
</ResizablePanel>
<ResizableHandle withHandle />
{/* Chat Panel */}
<ResizablePanel
id="chat-panel"
ref={chatPanelRef}
defaultSize={isMobile ? 50 : 33}
minSize={isMobile ? 20 : 15}
maxSize={isMobile ? 80 : 50}
collapsible={!isMobile}
collapsedSize={isMobile ? 0 : 3}
onCollapse={() => setIsChatVisible(false)}
onExpand={() => setIsChatVisible(true)}
>
<div className={`h-full ${isMobile ? "p-1" : "py-2 pr-2"}`}>
<Suspense
fallback={
<div className="h-full bg-card rounded-xl border border-border/30 flex items-center justify-center text-muted-foreground">
Loading chat...
</div>
}
>
<ChatPanel
isVisible={isChatVisible}
onToggleVisibility={toggleChatPanel}
drawioUi={drawioUi}
onDrawioUiChange={handleDrawioUiChange}
darkMode={darkMode}
onToggleDarkMode={handleDarkModeChange}
isMobile={isMobile}
/>
</Suspense>
</div>
</ResizablePanel>
</ResizablePanelGroup>
</div>
)
}
+1 -3
View File
@@ -25,7 +25,6 @@ import { findCachedResponse } from "@/lib/cached-responses"
import {
dropInvalidToolCalls,
fixToolInputJson,
formatSelectionContext,
replaceHistoricalToolInputs,
validateFileParts,
} from "@/lib/chat-helpers"
@@ -514,7 +513,6 @@ ${userInputText}
const isSingleSystemProvider =
SINGLE_SYSTEM_PROVIDERS.has(resolvedProvider) || isCustomOpenAIEndpoint
const selectionContext = formatSelectionContext(body.selectedCells)
const xmlContext = `${
previousXml
? `Previous diagram XML (before user's last message):
@@ -529,7 +527,7 @@ ${previousXml}
${xml || ""}
"""
IMPORTANT: The "Current diagram XML" is the SINGLE SOURCE OF TRUTH for what's on the canvas right now. The user can manually add, delete, or modify shapes directly in draw.io. Always count and describe elements based on the CURRENT XML, not on what you previously generated. If both previous and current XML are shown, compare them to understand what the user changed.${selectionContext ? `\n\n${selectionContext}` : ""}`
IMPORTANT: The "Current diagram XML" is the SINGLE SOURCE OF TRUTH for what's on the canvas right now. The user can manually add, delete, or modify shapes directly in draw.io. Always count and describe elements based on the CURRENT XML, not on what you previously generated. If both previous and current XML are shown, compare them to understand what the user changed.`
const systemMessages = isSingleSystemProvider
? [
+245 -263
View File
@@ -8,7 +8,7 @@
@theme inline {
--color-background: var(--background);
--color-foreground: var(--foreground);
--font-sans: var(--font-latin), var(--font-cjk), system-ui, sans-serif;
--font-sans: var(--font-sans);
--font-mono: var(--font-mono);
--color-sidebar-ring: var(--sidebar-ring);
--color-sidebar-border: var(--sidebar-border);
@@ -43,13 +43,157 @@
--radius-md: calc(var(--radius) - 2px);
--radius-lg: var(--radius);
--radius-xl: calc(var(--radius) + 4px);
}
/* App-specific tokens */
--color-canvas: var(--canvas);
--color-faint: var(--faint);
--color-marker: var(--marker);
--color-marker-soft: var(--marker-soft);
--color-marker-ink: var(--marker-ink);
:root {
--radius: 0.75rem;
/* Clean Light Modern Palette */
--background: oklch(0.985 0.002 240);
--foreground: oklch(0.23 0.02 260);
--card: oklch(1 0 0);
--card-foreground: oklch(0.23 0.02 260);
--popover: oklch(1 0 0);
--popover-foreground: oklch(0.23 0.02 260);
/* Dark primary - slightly lighter */
--primary: oklch(0.35 0.01 260);
--primary-foreground: oklch(0.99 0 0);
/* Warm gray secondary */
--secondary: oklch(0.96 0.005 260);
--secondary-foreground: oklch(0.35 0.02 260);
/* Light muted tones */
--muted: oklch(0.965 0.005 260);
--muted-foreground: oklch(0.5 0.02 260);
/* Soft lavender accent */
--accent: oklch(0.94 0.03 280);
--accent-foreground: oklch(0.35 0.08 270);
/* Muted rose destructive */
--destructive: oklch(0.45 0.12 10);
/* Subtle borders */
--border: oklch(0.92 0.01 260);
--input: oklch(0.94 0.01 260);
--ring: oklch(0.25 0.01 260);
/* Chart colors - harmonious palette */
--chart-1: oklch(0.55 0.18 265);
--chart-2: oklch(0.65 0.15 170);
--chart-3: oklch(0.7 0.18 45);
--chart-4: oklch(0.6 0.2 330);
--chart-5: oklch(0.5 0.15 200);
/* Sidebar */
--sidebar: oklch(0.99 0.002 260);
--sidebar-foreground: oklch(0.23 0.02 260);
--sidebar-primary: oklch(0.55 0.18 265);
--sidebar-primary-foreground: oklch(0.99 0 0);
--sidebar-accent: oklch(0.96 0.02 270);
--sidebar-accent-foreground: oklch(0.35 0.05 265);
--sidebar-border: oklch(0.93 0.01 260);
--sidebar-ring: oklch(0.55 0.18 265);
}
.dark {
--background: oklch(0.15 0.015 260);
--foreground: oklch(0.95 0.01 260);
--card: oklch(0.2 0.015 260);
--card-foreground: oklch(0.95 0.01 260);
--popover: oklch(0.2 0.015 260);
--popover-foreground: oklch(0.95 0.01 260);
--primary: oklch(0.7 0.16 265);
--primary-foreground: oklch(0.15 0.02 260);
--secondary: oklch(0.25 0.015 260);
--secondary-foreground: oklch(0.9 0.01 260);
--muted: oklch(0.25 0.015 260);
--muted-foreground: oklch(0.65 0.02 260);
--accent: oklch(0.3 0.04 280);
--accent-foreground: oklch(0.9 0.03 270);
--destructive: oklch(0.55 0.12 10);
--border: oklch(0.28 0.015 260);
--input: oklch(0.25 0.015 260);
--ring: oklch(0.7 0.16 265);
--chart-1: oklch(0.7 0.16 265);
--chart-2: oklch(0.7 0.13 170);
--chart-3: oklch(0.75 0.16 45);
--chart-4: oklch(0.7 0.18 330);
--chart-5: oklch(0.6 0.13 200);
--sidebar: oklch(0.18 0.015 260);
--sidebar-foreground: oklch(0.95 0.01 260);
--sidebar-primary: oklch(0.7 0.16 265);
--sidebar-primary-foreground: oklch(0.15 0.02 260);
--sidebar-accent: oklch(0.25 0.03 270);
--sidebar-accent-foreground: oklch(0.9 0.02 265);
--sidebar-border: oklch(0.28 0.015 260);
--sidebar-ring: oklch(0.7 0.16 265);
}
/* ============================================
REFINED MINIMAL DESIGN SYSTEM
============================================ */
:root {
/* Surface layers for depth */
--surface-0: oklch(1 0 0);
--surface-1: oklch(0.985 0.002 240);
--surface-2: oklch(0.97 0.004 240);
--surface-elevated: oklch(1 0 0);
/* Subtle borders */
--border-subtle: oklch(0.94 0.008 260);
--border-default: oklch(0.91 0.012 260);
/* Interactive states */
--interactive-hover: oklch(0.96 0.015 260);
--interactive-active: oklch(0.93 0.02 265);
/* Success state */
--success: oklch(0.65 0.18 145);
--success-muted: oklch(0.95 0.03 145);
/* Animation timing */
--duration-fast: 120ms;
--duration-normal: 200ms;
--duration-slow: 300ms;
--ease-out: cubic-bezier(0.16, 1, 0.3, 1);
--ease-in-out: cubic-bezier(0.4, 0, 0.2, 1);
--ease-spring: cubic-bezier(0.34, 1.56, 0.64, 1);
}
.dark {
--surface-0: oklch(0.15 0.015 260);
--surface-1: oklch(0.18 0.015 260);
--surface-2: oklch(0.22 0.015 260);
--surface-elevated: oklch(0.25 0.015 260);
--border-subtle: oklch(0.25 0.012 260);
--border-default: oklch(0.3 0.015 260);
--interactive-hover: oklch(0.25 0.02 265);
--interactive-active: oklch(0.3 0.025 270);
--success: oklch(0.7 0.16 145);
--success-muted: oklch(0.25 0.04 145);
}
/* Expose surface colors to Tailwind */
@theme inline {
--color-surface-0: var(--surface-0);
--color-surface-1: var(--surface-1);
--color-surface-2: var(--surface-2);
@@ -62,185 +206,12 @@
--color-success-muted: var(--success-muted);
}
/*
* Light: a quiet light table. Ink for primary actions, a highlighter
* yellow reserved for what the AI just changed.
*/
:root {
--radius: 0.75rem;
--canvas: #f4f5f7;
--background: #f4f5f7;
--foreground: #14181d;
--card: #ffffff;
--card-foreground: #14181d;
--popover: #ffffff;
--popover-foreground: #14181d;
--primary: #14181d;
--primary-foreground: #ffffff;
--secondary: #f1f3f5;
--secondary-foreground: #14181d;
--muted: #f1f3f5;
--muted-foreground: #5d6672;
--faint: #687180;
--accent: #eef0f3;
--accent-foreground: #14181d;
--destructive: #c9372c;
--border: #e4e7eb;
--input: #dfe3e8;
--ring: #14181d;
--marker: #ffd84d;
--marker-soft: #fff4bf;
--marker-ink: #6b5200;
--chart-1: #3b6fd8;
--chart-2: #1f9e86;
--chart-3: #e8892c;
--chart-4: #c2417a;
--chart-5: #2a8fb8;
--sidebar: #ffffff;
--sidebar-foreground: #14181d;
--sidebar-primary: #14181d;
--sidebar-primary-foreground: #ffffff;
--sidebar-accent: #f1f3f5;
--sidebar-accent-foreground: #14181d;
--sidebar-border: #e4e7eb;
--sidebar-ring: #14181d;
--surface-0: #ffffff;
--surface-1: #f7f8fa;
--surface-2: #f1f3f5;
--surface-elevated: #ffffff;
--border-subtle: #eceef1;
--border-default: #e4e7eb;
--interactive-hover: #f1f3f5;
--interactive-active: #e7eaee;
--success: #1f9e5a;
--success-muted: #e3f5ea;
--shadow-float:
0 1px 2px rgb(20 24 29 / 0.05), 0 10px 30px -8px rgb(20 24 29 / 0.14);
--shadow-pop:
0 1px 2px rgb(20 24 29 / 0.06), 0 18px 48px -12px rgb(20 24 29 / 0.22);
--duration-fast: 120ms;
--duration-normal: 200ms;
--duration-slow: 300ms;
--ease-out: cubic-bezier(0.16, 1, 0.3, 1);
--ease-in-out: cubic-bezier(0.4, 0, 0.2, 1);
--ease-spring: cubic-bezier(0.34, 1.56, 0.64, 1);
}
/*
* Han characters are drawn differently in Simplified Chinese, Traditional
* Chinese and Japanese: each language lists its own system fonts first.
*/
:root {
--font-cjk:
"PingFang SC", "Hiragino Sans GB", "Noto Sans CJK SC",
"Microsoft YaHei", "Hiragino Sans", "Meiryo";
}
:root:lang(ja) {
--font-cjk:
"Hiragino Sans", "Hiragino Kaku Gothic ProN", "Noto Sans CJK JP",
"Yu Gothic UI", "Meiryo", "PingFang SC";
}
:root:lang(zh-Hant) {
--font-cjk:
"PingFang TC", "Noto Sans CJK TC", "Microsoft JhengHei",
"Hiragino Sans", "PingFang SC";
}
/*
* Dark: a blueprint-blue table. Surfaces get lighter the higher they float.
* The diagram stays on a white sheet so it looks the way it will export;
* primary actions are paper-white, and amber only marks AI changes.
*/
.dark {
--canvas: #0f2440;
--background: #0c1a29;
--foreground: #e9f0f6;
--card: #16304f;
--card-foreground: #e9f0f6;
--popover: #1b3a5d;
--popover-foreground: #e9f0f6;
--primary: #e9f0f6;
--primary-foreground: #0c1a29;
--secondary: #1d3b5e;
--secondary-foreground: #e9f0f6;
--muted: #1d3b5e;
--muted-foreground: #a8bbce;
--faint: #8aa0b6;
--accent: #21426a;
--accent-foreground: #e9f0f6;
--destructive: #f07167;
--border: rgb(170 200 230 / 0.16);
--input: rgb(170 200 230 / 0.24);
--ring: #e9f0f6;
--marker: #f5b13d;
--marker-soft: rgb(245 177 61 / 0.16);
--marker-ink: #f7c46b;
--chart-1: #7aa2ff;
--chart-2: #4fd1b4;
--chart-3: #f5b13d;
--chart-4: #f08bb4;
--chart-5: #5cc3ea;
--sidebar: #16304f;
--sidebar-foreground: #e9f0f6;
--sidebar-primary: #e9f0f6;
--sidebar-primary-foreground: #0c1a29;
--sidebar-accent: #1d3b5e;
--sidebar-accent-foreground: #e9f0f6;
--sidebar-border: rgb(170 200 230 / 0.16);
--sidebar-ring: #e9f0f6;
--surface-0: #16304f;
--surface-1: #132b48;
--surface-2: #1d3b5e;
--surface-elevated: #1b3a5d;
--border-subtle: rgb(170 200 230 / 0.12);
--border-default: rgb(170 200 230 / 0.2);
--interactive-hover: #21426a;
--interactive-active: #264a75;
--success: #4fd18b;
--success-muted: rgb(79 209 139 / 0.14);
--shadow-float:
0 0 0 1px rgb(170 200 230 / 0.1), 0 14px 34px -12px rgb(0 0 0 / 0.55);
--shadow-pop:
0 0 0 1px rgb(170 200 230 / 0.14), 0 22px 56px -14px rgb(0 0 0 / 0.65);
}
@layer base {
* {
@apply border-border outline-ring/50;
}
html {
color-scheme: light;
}
html.dark {
color-scheme: dark;
}
body {
@apply bg-background text-foreground font-sans;
font-size: 14px;
line-height: 1.55;
-webkit-font-smoothing: antialiased;
}
::selection {
background: var(--marker-soft);
color: inherit;
}
}
@@ -250,61 +221,49 @@
width: 100% !important;
}
/* Custom scrollbar */
@layer utilities {
.scrollbar-thin {
scrollbar-width: thin;
scrollbar-color: color-mix(in oklab, var(--foreground) 18%, transparent)
transparent;
scrollbar-color: oklch(0.85 0.01 260) transparent;
}
.scrollbar-thin::-webkit-scrollbar {
width: 6px;
}
.scrollbar-thin::-webkit-scrollbar-track {
background: transparent;
}
.scrollbar-thin::-webkit-scrollbar-thumb {
background-color: color-mix(
in oklab,
var(--foreground) 18%,
transparent
);
background-color: oklch(0.85 0.01 260);
border-radius: 3px;
}
.scrollbar-thin::-webkit-scrollbar-thumb:hover {
background-color: color-mix(
in oklab,
var(--foreground) 30%,
transparent
);
background-color: oklch(0.75 0.01 260);
}
/* Diagram pictures always render as on paper, also in dark mode
(draw.io SVGs use light-dark() colors) */
.sheet-light {
color-scheme: light;
/* Dark mode scrollbar */
.dark .scrollbar-thin {
scrollbar-color: oklch(0.35 0.015 260) transparent;
}
.shadow-float {
box-shadow: var(--shadow-float);
.dark .scrollbar-thin::-webkit-scrollbar-thumb {
background-color: oklch(0.35 0.015 260);
}
.shadow-pop {
box-shadow: var(--shadow-pop);
}
/* Older names kept for components that still use them */
.shadow-soft {
box-shadow: var(--shadow-float);
}
.shadow-soft-lg {
box-shadow: var(--shadow-pop);
}
.shadow-dialog {
box-shadow: var(--shadow-pop);
.dark .scrollbar-thin::-webkit-scrollbar-thumb:hover {
background-color: oklch(0.45 0.015 260);
}
}
/* Smooth page transitions */
@keyframes fadeIn {
from {
opacity: 0;
transform: translateY(6px);
transform: translateY(8px);
}
to {
opacity: 1;
@@ -323,44 +282,83 @@
}
}
.animate-fade-in {
animation: fadeIn 0.3s ease-out forwards;
}
.animate-slide-in-right {
animation: slideInRight 0.3s ease-out forwards;
}
/* Message bubble animations */
@keyframes messageIn {
from {
opacity: 0;
transform: translateY(8px);
transform: translateY(12px) scale(0.98);
}
to {
opacity: 1;
transform: translateY(0);
}
}
/* The first prompt moves from the middle of the canvas into the panel */
@keyframes panelIn {
from {
opacity: 0;
transform: translateX(12px) scale(0.985);
}
to {
opacity: 1;
transform: translateX(0) scale(1);
}
}
@keyframes heroOut {
from {
opacity: 1;
transform: translateY(0) scale(1);
}
to {
opacity: 0;
transform: translateY(-8px) scale(0.98);
}
}
.animate-message-in {
animation: messageIn 0.25s ease-out forwards;
}
/* Subtle floating shadow for cards */
.shadow-soft {
box-shadow:
0 1px 2px oklch(0.23 0.02 260 / 0.04),
0 4px 12px oklch(0.23 0.02 260 / 0.06),
0 8px 24px oklch(0.23 0.02 260 / 0.04);
}
.shadow-soft-lg {
box-shadow:
0 2px 4px oklch(0.23 0.02 260 / 0.04),
0 8px 20px oklch(0.23 0.02 260 / 0.08),
0 16px 40px oklch(0.23 0.02 260 / 0.06);
}
/* Gradient text utility */
.text-gradient-primary {
background: linear-gradient(
135deg,
oklch(0.55 0.18 265),
oklch(0.6 0.2 290)
);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
}
/* ============================================
REFINED DIALOG STYLES
============================================ */
/* Refined dialog shadow - multi-layer soft shadow */
.shadow-dialog {
box-shadow:
0 0 0 1px oklch(0 0 0 / 0.03),
0 2px 4px oklch(0 0 0 / 0.02),
0 12px 24px oklch(0 0 0 / 0.06),
0 24px 48px oklch(0 0 0 / 0.04);
}
.dark .shadow-dialog {
box-shadow:
0 0 0 1px oklch(1 0 0 / 0.05),
0 2px 4px oklch(0 0 0 / 0.2),
0 12px 24px oklch(0 0 0 / 0.3),
0 24px 48px oklch(0 0 0 / 0.2);
}
/* Dialog animations */
@keyframes dialog-in {
from {
opacity: 0;
transform: translate(-50%, -48%) scale(0.97);
transform: translate(-50%, -48%) scale(0.96);
}
to {
opacity: 1;
@@ -375,10 +373,19 @@
}
to {
opacity: 0;
transform: translate(-50%, -48%) scale(0.97);
transform: translate(-50%, -48%) scale(0.96);
}
}
.animate-dialog-in {
animation: dialog-in var(--duration-normal) var(--ease-out) forwards;
}
.animate-dialog-out {
animation: dialog-out 150ms var(--ease-out) forwards;
}
/* Check pop animation for validation success */
@keyframes check-pop {
0% {
transform: scale(0.8);
@@ -393,37 +400,12 @@
}
}
.animate-fade-in {
animation: fadeIn 0.25s var(--ease-out) forwards;
}
.animate-slide-in-right {
animation: slideInRight 0.3s var(--ease-out) forwards;
}
.animate-message-in {
animation: messageIn 0.22s var(--ease-out) forwards;
}
.animate-panel-in {
animation: panelIn 0.32s var(--ease-out) both;
}
.animate-hero-out {
animation: heroOut 0.2s var(--ease-out) forwards;
}
.animate-dialog-in {
animation: dialog-in var(--duration-normal) var(--ease-out) forwards;
}
.animate-dialog-out {
animation: dialog-out 150ms var(--ease-out) forwards;
}
.animate-check-pop {
animation: check-pop 0.25s var(--ease-spring) forwards;
}
/* Reduced motion support */
@media (prefers-reduced-motion: reduce) {
.animate-fade-in,
.animate-slide-in-right,
.animate-message-in,
.animate-panel-in,
.animate-hero-out,
.animate-dialog-in,
.animate-dialog-out,
.animate-check-pop {
-202
View File
@@ -1,202 +0,0 @@
import { useParams } from "next/navigation"
import { useEffect, useRef, useState } from "react"
import { CanvasToolbar } from "@/components/canvas/canvas-toolbar"
import { DrawioFrame } from "@/components/canvas/drawio-frame"
import { PageTabs } from "@/components/canvas/page-tabs"
import { SelectionAsk } from "@/components/canvas/selection-ask"
import { useChatEngine } from "@/components/chat/chat-engine"
import { useDiagram } from "@/contexts/diagram-context"
import { useDictionary } from "@/hooks/use-dictionary"
import { getDrawioCss, getDrawioSrc } from "@/lib/drawio/drawio-config"
import { keepCenter, refitIfRecent } from "@/lib/drawio/editor-bridge"
import { i18n, type Locale } from "@/lib/i18n/config"
import { cn } from "@/lib/utils"
import { useCanvasStore } from "@/stores/canvas-store"
import { useSettingsStore } from "@/stores/settings-store"
/** File types the "Open .drawio file" picker accepts */
export const DIAGRAM_FILE_ACCEPT =
".drawio,.xml,.svg,application/xml,image/svg+xml"
/**
* The draw.io canvas with the app's own toolbar, page tabs and the
* "ask AI" button laid over it. With an external (cross-origin) draw.io the
* overlays are hidden and draw.io's own toolbar is used.
*/
export function CanvasStage({
className,
style,
compact = false,
hideOverlays = false,
}: {
className?: string
style?: React.CSSProperties
compact?: boolean
/** Hide toolbar and buttons (e.g. while the start screen covers the canvas) */
hideOverlays?: boolean
}) {
const dict = useDictionary()
const params = useParams<{ lang: string }>()
const lang = (
i18n.locales.includes(params.lang as Locale)
? params.lang
: i18n.defaultLocale
) as Locale
const {
drawioRef,
handleDiagramAutoSave,
handleDiagramExport,
onDrawioLoad,
resetDrawioReady,
isDrawioReady,
} = useDiagram()
const engine = useChatEngine()
const isDark = useSettingsStore((s) => s.isDark)
const hasEditor = useCanvasStore((s) => s.hasEditor)
// The model sees the first page only, so asking about shapes elsewhere
// would change the wrong page
const onFirstPage = useCanvasStore(
(s) => s.pages.length === 0 || s.pages[0]?.id === s.currentPageId,
)
// Canvas width before the chat panel slides (React passes no
// transitionstart, so this is the width after the previous slide)
const settledWidthRef = useRef(0)
const [src, setSrc] = useState<string | null>(null)
// null until draw.io tells us whether we can drive it directly
const [editorAccess, setEditorAccess] = useState<boolean | null>(null)
// Bumped to reload draw.io when its CSS can only change with a reload
const [reloadKey, setReloadKey] = useState(0)
const stageRef = useRef<HTMLDivElement>(null)
const fileInputRef = useRef<HTMLInputElement>(null)
const [size, setSize] = useState({ width: 0, height: 0 })
useEffect(() => {
const isElectron = !!(window as { electronAPI?: unknown }).electronAPI
setSrc(getDrawioSrc({ lang, isElectron }))
}, [lang])
// A new iframe starts over: draw.io reports load and access again
useEffect(() => {
resetDrawioReady()
setEditorAccess(null)
}, [src, reloadKey])
// Cross-origin draw.io only takes CSS when it starts, so reload it when
// the theme changes. Same-origin updates the CSS in place.
const lastDarkRef = useRef(isDark)
useEffect(() => {
if (lastDarkRef.current === isDark) return
lastDarkRef.current = isDark
if (editorAccess === false) setReloadKey((key) => key + 1)
}, [isDark, editorAccess])
useEffect(() => {
const stage = stageRef.current
if (!stage) return
settledWidthRef.current = stage.clientWidth
const observer = new ResizeObserver(([entry]) => {
setSize({
width: entry.contentRect.width,
height: entry.contentRect.height,
})
refitIfRecent()
})
observer.observe(stage)
return () => observer.disconnect()
}, [])
const showOverlays = hasEditor && isDrawioReady && !hideOverlays
// The full toolbar is about 590 px wide; a narrower canvas (small window
// with the chat panel open) gets the short one
const compactToolbar = compact || (size.width > 0 && size.width < 640)
return (
<div
ref={stageRef}
className={cn("absolute overflow-hidden bg-canvas", className)}
style={style}
data-testid="canvas-stage"
// The chat panel slides in or out (the canvas's right edge moves):
// once the canvas has its final size, fit a diagram just drawn,
// or keep the view's middle
onTransitionEnd={(event) => {
if (event.target !== event.currentTarget) return
if (event.propertyName !== "right") return
const stage = event.currentTarget
// Let draw.io take the new size first
setTimeout(() => {
const before = settledWidthRef.current
settledWidthRef.current = stage.clientWidth
if (refitIfRecent(4000)) return
keepCenter(before, stage.clientWidth)
}, 60)
}}
>
{src && (
<DrawioFrame
key={`${src}#${reloadKey}`}
ref={drawioRef}
src={src}
configCss={getDrawioCss({
dark: isDark,
customChrome: false,
})}
editorCss={getDrawioCss({
dark: isDark,
customChrome: true,
})}
onLoad={onDrawioLoad}
onAutoSave={handleDiagramAutoSave}
onExport={handleDiagramExport}
onEditorAccess={setEditorAccess}
/>
)}
{/* Covers the iframe until draw.io has loaded and been styled */}
{(!isDrawioReady || editorAccess === null) && (
<div
className="absolute inset-0 flex items-center justify-center bg-canvas"
data-testid="canvas-loading"
>
<div className="flex items-center gap-2.5 text-[13px] text-muted-foreground">
<span className="size-3.5 animate-spin rounded-full border-2 border-muted-foreground/30 border-t-muted-foreground" />
{dict.workspace.canvasLoading}
</div>
</div>
)}
{showOverlays && (
<>
<div className="pointer-events-none absolute inset-x-0 top-3 z-10 flex justify-center px-3">
<CanvasToolbar
compact={compactToolbar}
onOpenFile={() => fileInputRef.current?.click()}
/>
</div>
{/* Full width, so the tabs' max width is the canvas's */}
<div className="pointer-events-none absolute inset-x-3 bottom-3 z-10 flex">
<PageTabs />
</div>
<SelectionAsk
width={size.width}
height={size.height}
hidden={engine.isBusy || !onFirstPage}
/>
</>
)}
<input
ref={fileInputRef}
type="file"
accept={DIAGRAM_FILE_ACCEPT}
className="hidden"
onChange={(event) => {
const file = event.target.files?.[0]
if (file) engine.openDiagramFile(file)
event.target.value = ""
}}
/>
</div>
)
}
-392
View File
@@ -1,392 +0,0 @@
import {
Check,
Download,
Ellipsis,
Maximize2,
Minus,
MousePointer2,
Paintbrush,
PenLine,
Plus,
Redo2,
Shapes,
Spline,
Type,
Undo2,
} from "lucide-react"
import { useMemo, useState } from "react"
import { useChatEngine } from "@/components/chat/chat-engine"
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@/components/ui/popover"
import { IconButton, ToolbarDivider } from "@/components/workspace/icon-button"
import { useDictionary } from "@/hooks/use-dictionary"
import {
fitDiagram,
hasAction,
runAction,
stopFreehand,
toggleShapesPanel,
viewToggles,
} from "@/lib/drawio/editor-bridge"
import { isMac, modKey } from "@/lib/platform"
import { cn } from "@/lib/utils"
import { useCanvasStore } from "@/stores/canvas-store"
import { useUiStore } from "@/stores/ui-store"
const mod = modKey
interface MenuItem {
action: string
label: string
shortcut?: string
}
function MenuButton({
children,
onClick,
shortcut,
checked,
disabled,
}: {
children: React.ReactNode
onClick: () => void
shortcut?: string
/** On/off items: a check mark when on, and room for it when off */
checked?: boolean
disabled?: boolean
}) {
return (
<button
type="button"
onClick={onClick}
disabled={disabled}
{...(checked !== undefined && {
role: "menuitemcheckbox",
"aria-checked": checked,
})}
className="flex w-full items-center justify-between gap-6 rounded-md px-2.5 py-1.5 text-left text-[13px] text-foreground hover:bg-accent disabled:pointer-events-none disabled:opacity-40"
>
<span className="flex items-center gap-2">
{checked !== undefined && (
<Check
className={cn(
"size-3.5 shrink-0",
!checked && "invisible",
)}
/>
)}
{children}
</span>
{shortcut && (
<span className="text-xs text-muted-foreground">
{shortcut}
</span>
)}
</button>
)
}
/**
* The canvas toolbar. Every button calls an existing draw.io action; a
* button whose action is missing in this draw.io build is not shown.
*/
export function CanvasToolbar({
compact = false,
onOpenFile,
}: {
compact?: boolean
onOpenFile: () => void
}) {
const dict = useDictionary()
const t = dict.workspace
const canUndo = useCanvasStore((s) => s.canUndo)
const canRedo = useCanvasStore((s) => s.canRedo)
const zoom = useCanvasStore((s) => s.zoom)
const isFreehand = useCanvasStore((s) => s.isFreehand)
const isFormatOpen = useCanvasStore((s) => s.isFormatOpen)
const isShapesOpen = useCanvasStore((s) => s.isShapesOpen)
const setSaveDialogOpen = useUiStore((s) => s.setSaveDialogOpen)
const engine = useChatEngine()
const [moreOpen, setMoreOpen] = useState(false)
const [zoomOpen, setZoomOpen] = useState(false)
// Checked once per mount; hasAction warns about missing actions
const available = useMemo(() => {
const names = [
"undo",
"redo",
"toggleShapes",
"insertText",
"insertEdge",
"insertFreehand",
"zoomIn",
"zoomOut",
"fitWindow",
"resetView",
"format",
"findReplace",
"editDiagram",
"layers",
"outline",
"grid",
"pageView",
"insertImage",
"insertLink",
"selectAll",
"print",
]
return new Set(names.filter((name) => hasAction(name)))
}, [])
// Read when the menu opens (draw.io has no event for these)
const toggles = moreOpen ? viewToggles() : { grid: false, pageView: false }
const checkedOf = (action: string) =>
action === "grid"
? toggles.grid
: action === "pageView"
? toggles.pageView
: undefined
const moreItems: MenuItem[] = [
{ action: "findReplace", label: t.findReplace, shortcut: `${mod}F` },
{ action: "layers", label: t.layers, shortcut: `${mod}⇧L` },
{ action: "outline", label: t.outline, shortcut: `${mod}⇧O` },
{ action: "grid", label: t.toggleGrid, shortcut: `${mod}⇧G` },
{ action: "pageView", label: t.togglePageView },
{ action: "insertImage", label: t.insertImage },
{ action: "insertLink", label: t.insertLink },
{ action: "selectAll", label: t.selectAll, shortcut: `${mod}A` },
{ action: "editDiagram", label: t.editDiagram },
{ action: "print", label: t.print, shortcut: `${mod}P` },
].filter((item) => available.has(item.action))
const tool = (action: string) => () => {
if (isFreehand && action !== "insertFreehand") stopFreehand()
runAction(action)
}
return (
<div
role="toolbar"
aria-label={t.toolbar}
className={cn(
"pointer-events-auto flex items-center gap-0.5 rounded-xl bg-card p-1 shadow-float",
compact && "max-w-full overflow-x-auto",
)}
>
{available.has("undo") && (
<IconButton
label={t.undo}
shortcut={`${mod}Z`}
onClick={tool("undo")}
disabled={!canUndo}
data-testid="toolbar-undo"
>
<Undo2 />
</IconButton>
)}
{available.has("redo") && (
<IconButton
label={t.redo}
shortcut={isMac ? "⌘⇧Z" : "Ctrl+Y"}
onClick={tool("redo")}
disabled={!canRedo}
>
<Redo2 />
</IconButton>
)}
<ToolbarDivider />
{!compact && (
<IconButton
label={t.select}
active={!isFreehand}
onClick={() => stopFreehand()}
>
<MousePointer2 />
</IconButton>
)}
{available.has("toggleShapes") && (
<IconButton
label={t.shapes}
shortcut={`${mod}⇧K`}
active={isShapesOpen}
onClick={() => {
if (isFreehand) stopFreehand()
toggleShapesPanel()
}}
data-testid="toolbar-shapes"
>
<Shapes />
</IconButton>
)}
{!compact && available.has("insertText") && (
<IconButton label={t.text} onClick={tool("insertText")}>
<Type />
</IconButton>
)}
{!compact && available.has("insertEdge") && (
<IconButton label={t.connector} onClick={tool("insertEdge")}>
<Spline />
</IconButton>
)}
{!compact && available.has("insertFreehand") && (
<IconButton
label={t.freehand}
shortcut="X"
active={isFreehand}
onClick={tool("insertFreehand")}
>
<PenLine />
</IconButton>
)}
<ToolbarDivider />
{!compact && available.has("zoomOut") && (
<IconButton
label={t.zoomOut}
shortcut={`${mod}−`}
onClick={tool("zoomOut")}
>
<Minus />
</IconButton>
)}
<Popover open={zoomOpen} onOpenChange={setZoomOpen}>
<PopoverTrigger asChild>
<button
type="button"
aria-label={t.zoomMenu}
className="h-8 min-w-12 rounded-lg px-1.5 text-xs tabular-nums text-muted-foreground hover:bg-accent hover:text-foreground"
>
{zoom}%
</button>
</PopoverTrigger>
<PopoverContent className="w-44 p-1" align="center">
{available.has("fitWindow") && (
<MenuButton
shortcut={`${mod}⇧H`}
onClick={() => {
runAction("fitWindow")
setZoomOpen(false)
}}
>
{t.fitWindow}
</MenuButton>
)}
{available.has("resetView") && (
<MenuButton
shortcut={`${mod}H`}
onClick={() => {
runAction("resetView")
setZoomOpen(false)
}}
>
{t.zoomReset}
</MenuButton>
)}
{compact && available.has("zoomIn") && (
<MenuButton onClick={() => runAction("zoomIn")}>
{t.zoomIn}
</MenuButton>
)}
{compact && available.has("zoomOut") && (
<MenuButton onClick={() => runAction("zoomOut")}>
{t.zoomOut}
</MenuButton>
)}
</PopoverContent>
</Popover>
{!compact && available.has("zoomIn") && (
<IconButton
label={t.zoomIn}
shortcut={`${mod}+`}
onClick={tool("zoomIn")}
>
<Plus />
</IconButton>
)}
{!compact && (
<IconButton
label={t.fitWindow}
onClick={() => fitDiagram()}
data-testid="toolbar-fit"
>
<Maximize2 />
</IconButton>
)}
<ToolbarDivider />
{available.has("format") && (
<IconButton
label={t.style}
shortcut={`${mod}⇧P`}
active={isFormatOpen}
onClick={tool("format")}
data-testid="toolbar-style"
>
<Paintbrush />
</IconButton>
)}
<Popover open={moreOpen} onOpenChange={setMoreOpen}>
<PopoverTrigger asChild>
<IconButton label={t.more} data-testid="toolbar-more">
<Ellipsis />
</IconButton>
</PopoverTrigger>
<PopoverContent className="w-60 p-1" align="end">
<MenuButton
disabled={engine.isBusy}
onClick={() => {
setMoreOpen(false)
onOpenFile()
}}
>
{t.openFile}
</MenuButton>
{compact && available.has("insertText") && (
<MenuButton
onClick={() => {
setMoreOpen(false)
runAction("insertText")
}}
>
{t.text}
</MenuButton>
)}
{compact && available.has("insertFreehand") && (
<MenuButton
onClick={() => {
setMoreOpen(false)
runAction("insertFreehand")
}}
>
{t.freehand}
</MenuButton>
)}
<div className="my-1 h-px bg-border" />
{moreItems.map((item) => (
<MenuButton
key={item.action}
checked={checkedOf(item.action)}
shortcut={compact ? undefined : item.shortcut}
onClick={() => {
setMoreOpen(false)
runAction(item.action)
}}
>
{item.label}
</MenuButton>
))}
</PopoverContent>
</Popover>
<button
type="button"
onClick={() => setSaveDialogOpen(true)}
className="ml-1 inline-flex h-8 shrink-0 items-center gap-1.5 rounded-lg bg-primary px-3 text-[13px] font-medium text-primary-foreground transition-opacity hover:opacity-90"
data-testid="toolbar-export"
>
<Download className="size-4" />
{!compact && t.export}
</button>
</div>
)
}
-233
View File
@@ -1,233 +0,0 @@
"use client"
import { forwardRef, useEffect, useImperativeHandle, useRef } from "react"
import {
DRAWIO_CONFIG,
EMPTY_DRAWIO_DOCUMENT,
} from "@/lib/drawio/drawio-config"
import {
attachEditor,
detachEditor,
watchForEditorUi,
} from "@/lib/drawio/editor-bridge"
export interface DrawioExportEvent {
event: "export"
format: string
data: string
xml: string
message: { message?: string; [key: string]: unknown }
}
export interface DrawioFrameHandle {
load: (data: { xml: string }) => void
exportDiagram: (data: {
format: string
message?: string
[key: string]: unknown
}) => void
}
interface DrawioFrameProps {
src: string
/** CSS sent with the configure message (works cross-origin too) */
configCss: string
/** CSS injected once the editor is reachable directly (same origin) */
editorCss: string
onLoad?: () => void
onAutoSave?: (data: { xml?: string }) => void
onExport?: (data: DrawioExportEvent) => void
/** Called once we know whether the editor can be driven directly */
onEditorAccess?: (hasEditor: boolean) => void
}
const STYLE_ID = "next-ai-drawio-theme"
/**
* The draw.io editor in an iframe, speaking draw.io's postMessage embed
* protocol (same messages react-drawio used). When the iframe is same-origin
* it also hands the editor object to editor-bridge for direct control.
*/
export const DrawioFrame = forwardRef<DrawioFrameHandle, DrawioFrameProps>(
function DrawioFrame(
{
src,
configCss,
editorCss,
onLoad,
onAutoSave,
onExport,
onEditorAccess,
},
ref,
) {
const iframeRef = useRef<HTMLIFrameElement>(null)
const editorUiRef = useRef<any>(null)
const attachedRef = useRef(false)
const configCssRef = useRef(configCss)
configCssRef.current = configCss
const editorCssRef = useRef(editorCss)
editorCssRef.current = editorCss
// Handlers change on every render; the message listener reads refs
const handlersRef = useRef({
onLoad,
onAutoSave,
onExport,
onEditorAccess,
})
handlersRef.current = { onLoad, onAutoSave, onExport, onEditorAccess }
// draw.io's origin: messages go only there, and only its messages
// count (the frame could be navigated to another site)
// (read on use: there is no window while the page renders on the server)
const drawioOrigin = () => new URL(src, window.location.href).origin
const send = (action: string, data: Record<string, unknown> = {}) => {
iframeRef.current?.contentWindow?.postMessage(
JSON.stringify({ action, ...data }),
drawioOrigin(),
)
}
useImperativeHandle(ref, () => ({
load: ({ xml }) => send("load", { xml, autosave: 1 }),
exportDiagram: (data) => send("export", data),
}))
// Same-origin only: keep the theme CSS current without a reload
const applyThemeCss = () => {
if (!attachedRef.current) return
try {
const doc = iframeRef.current?.contentDocument
if (!doc?.head) return
let style = doc.getElementById(STYLE_ID)
if (!style) {
style = doc.createElement("style")
style.id = STYLE_ID
doc.head.appendChild(style)
}
style.textContent = editorCssRef.current
} catch {
// Cross-origin: the configure message already applied it
}
}
useEffect(() => {
applyThemeCss()
}, [editorCss])
useEffect(() => {
const iframe = iframeRef.current
if (!iframe) return
editorUiRef.current = null
attachedRef.current = false
let reported: boolean | null = null
const report = (value: boolean) => {
if (reported === value) return
reported = value
handlersRef.current.onEditorAccess?.(value)
}
// Watch for draw.io's scripts so the editor object can be caught
// as it starts. Stops once hooked, or when the frame turns out to
// be cross-origin.
const startedAt = Date.now()
const poll = setInterval(() => {
const frameWindow = iframe.contentWindow as any
if (!frameWindow) return
let ready = false
try {
ready =
frameWindow.location.href !== "about:blank" &&
!!frameWindow.EditorUi
} catch {
clearInterval(poll)
report(false)
return
}
if (ready) {
clearInterval(poll)
const ok = watchForEditorUi(frameWindow, (ui) => {
editorUiRef.current = ui
})
if (!ok) report(false)
} else if (Date.now() - startedAt > 60_000) {
clearInterval(poll)
}
}, 5)
const handleMessage = (event: MessageEvent) => {
if (event.source !== iframe.contentWindow) return
if (event.origin !== drawioOrigin()) return
if (typeof event.data !== "string") return
let data: any
try {
data = JSON.parse(event.data)
} catch {
return
}
const handlers = handlersRef.current
switch (data.event) {
case "init":
send("load", {
xml: EMPTY_DRAWIO_DOCUMENT,
autosave: 1,
})
break
case "configure":
send("configure", {
config: {
...DRAWIO_CONFIG,
css: configCssRef.current,
},
})
break
case "load": {
const tryAttach = () => {
const ui = editorUiRef.current
if (!ui || !iframe.contentWindow) return false
attachEditor(ui, iframe.contentWindow, iframe)
attachedRef.current = true
applyThemeCss()
report(true)
return true
}
// The editor object is usually caught before the
// first load; give it a moment, then fall back to
// draw.io's own toolbar
if (!tryAttach()) {
setTimeout(() => {
if (!tryAttach()) report(false)
}, 800)
}
handlers.onLoad?.()
break
}
case "autosave":
handlers.onAutoSave?.(data)
break
case "export":
handlers.onExport?.(data)
break
}
}
window.addEventListener("message", handleMessage)
return () => {
clearInterval(poll)
window.removeEventListener("message", handleMessage)
detachEditor()
}
}, [src])
return (
<iframe
ref={iframeRef}
src={src}
title="draw.io"
allow="clipboard-read; clipboard-write"
className="block h-full w-full border-0"
/>
)
},
)
-118
View File
@@ -1,118 +0,0 @@
import { ChevronDown, Plus } from "lucide-react"
import { useState } from "react"
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@/components/ui/popover"
import { IconButton } from "@/components/workspace/icon-button"
import { useDictionary } from "@/hooks/use-dictionary"
import {
addPage,
canManagePages,
duplicatePage,
removePage,
renamePage,
selectPage,
} from "@/lib/drawio/editor-bridge"
import { cn } from "@/lib/utils"
import { useCanvasStore } from "@/stores/canvas-store"
/** Page switcher at the bottom of the canvas (replaces draw.io's page tabs) */
export function PageTabs() {
const dict = useDictionary()
const t = dict.workspace
const pages = useCanvasStore((s) => s.pages)
const currentPageId = useCanvasStore((s) => s.currentPageId)
const [menuFor, setMenuFor] = useState<string | null>(null)
if (!canManagePages() || pages.length === 0) return null
return (
<div
className="pointer-events-auto flex max-w-[min(640px,100%)] items-center gap-0.5 overflow-x-auto rounded-xl bg-card p-1 shadow-float"
data-testid="page-tabs"
>
{pages.map((page) => {
const isCurrent = page.id === currentPageId
if (!isCurrent) {
return (
<button
key={page.id}
type="button"
onClick={() => selectPage(page.id)}
className="h-7 max-w-40 shrink-0 truncate rounded-lg px-2.5 text-[13px] text-muted-foreground hover:bg-accent hover:text-foreground"
>
{page.name}
</button>
)
}
return (
<Popover
key={page.id}
open={menuFor === page.id}
onOpenChange={(open) =>
setMenuFor(open ? page.id : null)
}
>
<PopoverTrigger asChild>
<button
type="button"
className={cn(
"flex h-7 max-w-44 shrink-0 items-center gap-1 rounded-lg bg-accent px-2.5 text-[13px] font-medium text-foreground",
)}
>
<span className="truncate">{page.name}</span>
<ChevronDown className="size-3.5 text-muted-foreground" />
</button>
</PopoverTrigger>
<PopoverContent
side="top"
align="start"
className="w-44 p-1"
>
{[
{
label: t.renamePage,
run: () => renamePage(page.id),
},
{
label: t.duplicatePage,
run: () => duplicatePage(page.id),
},
...(pages.length > 1
? [
{
label: t.deletePage,
run: () => removePage(page.id),
},
]
: []),
].map((item) => (
<button
key={item.label}
type="button"
onClick={() => {
setMenuFor(null)
item.run()
}}
className="w-full rounded-md px-2.5 py-1.5 text-left text-[13px] hover:bg-accent"
>
{item.label}
</button>
))}
</PopoverContent>
</Popover>
)
})}
<IconButton
label={t.addPage}
size="sm"
tooltipSide="top"
onClick={addPage}
>
<Plus />
</IconButton>
</div>
)
}
-68
View File
@@ -1,68 +0,0 @@
import { Sparkles } from "lucide-react"
import { useDictionary } from "@/hooks/use-dictionary"
import { modKey } from "@/lib/platform"
import { useCanvasStore } from "@/stores/canvas-store"
import { useUiStore } from "@/stores/ui-store"
const BUTTON_HEIGHT = 32
const GAP = 10
/**
* "Ask AI to change this" next to the selected shapes. Clicking it moves
* focus to the chat input, where the selection already shows as a chip.
*/
export function SelectionAsk({
width,
height,
hidden,
}: {
/** Size of the canvas area, for keeping the button inside it */
width: number
height: number
hidden: boolean
}) {
const dict = useDictionary()
const rect = useCanvasStore((s) => s.selectionRect)
const selection = useCanvasStore((s) => s.selection)
const isFreehand = useCanvasStore((s) => s.isFreehand)
const focusComposer = useUiStore((s) => s.focusComposer)
if (hidden || !rect || selection.length === 0 || isFreehand) return null
// Selection scrolled out of view
if (
rect.x + rect.width < 0 ||
rect.y + rect.height < 0 ||
rect.x > width ||
rect.y > height
) {
return null
}
// Above the selection's top-right corner, kept inside the canvas
const approxWidth = 168
const left = Math.min(
Math.max(8, rect.x + rect.width - approxWidth / 2),
width - approxWidth - 8,
)
const above = rect.y - BUTTON_HEIGHT - GAP
const top =
above > 64
? above
: Math.min(rect.y + rect.height + GAP, height - BUTTON_HEIGHT - 72)
return (
<button
type="button"
onClick={focusComposer}
className="pointer-events-auto absolute z-10 flex h-8 items-center gap-1.5 rounded-xl bg-card pr-2.5 pl-2 text-[13px] font-medium text-foreground shadow-pop transition-transform animate-fade-in hover:-translate-y-px"
style={{ left, top }}
data-testid="selection-ask"
>
<Sparkles className="size-4 text-marker-ink" />
{dict.workspace.askAi}
<kbd className="ml-1 font-sans text-[11px] text-muted-foreground">
{modKey}/
</kbd>
</button>
)
}
+227
View File
@@ -0,0 +1,227 @@
"use client"
import {
Cloud,
FileText,
GitBranch,
Palette,
Terminal,
Zap,
} from "lucide-react"
import { useDictionary } from "@/hooks/use-dictionary"
import { getAssetUrl } from "@/lib/base-path"
interface ExampleCardProps {
icon: React.ReactNode
title: string
description: string
onClick: () => void
isNew?: boolean
}
function ExampleCard({
icon,
title,
description,
onClick,
isNew,
}: ExampleCardProps) {
const dict = useDictionary()
return (
<button
onClick={onClick}
className={`group w-full text-left p-4 rounded-xl border bg-card hover:bg-accent/50 hover:border-primary/30 transition-all duration-200 hover:shadow-sm ${
isNew
? "border-primary/40 ring-1 ring-primary/20"
: "border-border/60"
}`}
>
<div className="flex items-start gap-3">
<div
className={`w-9 h-9 rounded-lg flex items-center justify-center shrink-0 transition-colors ${
isNew
? "bg-primary/20 group-hover:bg-primary/25"
: "bg-primary/10 group-hover:bg-primary/15"
}`}
>
{icon}
</div>
<div className="min-w-0">
<div className="flex items-center gap-2">
<h3 className="text-sm font-medium text-foreground group-hover:text-primary transition-colors">
{title}
</h3>
{isNew && (
<span className="px-1.5 py-0.5 text-[10px] font-semibold bg-primary text-primary-foreground rounded">
{dict.common.new}
</span>
)}
</div>
<p className="text-xs text-muted-foreground mt-0.5 line-clamp-2">
{description}
</p>
</div>
</div>
</button>
)
}
export default function ExamplePanel({
setInput,
setFiles,
minimal = false,
}: {
setInput: (input: string) => void
setFiles: (files: File[]) => void
minimal?: boolean
}) {
const dict = useDictionary()
const isSelfHosted = process.env.NEXT_PUBLIC_SELFHOSTED === "true"
const handleReplicateFlowchart = async () => {
setInput("Replicate this flowchart.")
try {
const response = await fetch(getAssetUrl("/example.png"))
const blob = await response.blob()
const file = new File([blob], "example.png", { type: "image/png" })
setFiles([file])
} catch (error) {
console.error(dict.errors.failedToLoadExample, error)
}
}
const handleReplicateArchitecture = async () => {
setInput("Replicate this in aws style")
try {
const response = await fetch(getAssetUrl("/architecture.png"))
const blob = await response.blob()
const file = new File([blob], "architecture.png", {
type: "image/png",
})
setFiles([file])
} catch (error) {
console.error(dict.errors.failedToLoadExample, error)
}
}
const handlePdfExample = async () => {
setInput("Summarize this paper as a diagram")
try {
const response = await fetch(getAssetUrl("/chain-of-thought.txt"))
const blob = await response.blob()
const file = new File([blob], "chain-of-thought.txt", {
type: "text/plain",
})
setFiles([file])
} catch (error) {
console.error(dict.errors.failedToLoadExample, error)
}
}
return (
<div className={minimal ? "" : "py-6 px-2 animate-fade-in"}>
{!minimal && (
<>
{/* MCP Server Notice, hidden on self-hosted deployments */}
{!isSelfHosted && (
<a
href="https://github.com/DayuanJiang/next-ai-draw-io/tree/main/packages/mcp-server"
target="_blank"
rel="noopener noreferrer"
className="block mb-4 p-3 rounded-xl bg-gradient-to-r from-purple-500/10 to-blue-500/10 border border-purple-500/20 hover:border-purple-500/40 transition-colors group"
>
<div className="flex items-center gap-3">
<div className="w-8 h-8 rounded-lg bg-purple-500/20 flex items-center justify-center shrink-0">
<Terminal className="w-4 h-4 text-purple-500" />
</div>
<div className="min-w-0">
<div className="flex items-center gap-2">
<span className="text-sm font-medium text-foreground group-hover:text-purple-500 transition-colors">
{dict.examples.mcpServer}
</span>
</div>
<p className="text-xs text-muted-foreground">
{dict.examples.mcpDescription}
</p>
</div>
</div>
</a>
)}
{/* Welcome section */}
<div className="text-center mb-6">
<h2 className="text-lg font-semibold text-foreground mb-2">
{dict.examples.title}
</h2>
<p className="text-sm text-muted-foreground max-w-xs mx-auto">
{dict.examples.subtitle}
</p>
</div>
</>
)}
{/* Examples grid */}
<div className="space-y-3">
{!minimal && (
<p className="text-xs font-medium text-muted-foreground uppercase tracking-wider px-1">
{dict.examples.quickExamples}
</p>
)}
<div className="grid gap-2">
<ExampleCard
icon={<FileText className="w-4 h-4 text-primary" />}
title={dict.examples.paperToDiagram}
description={dict.examples.paperDescription}
onClick={handlePdfExample}
isNew
/>
<ExampleCard
icon={<Zap className="w-4 h-4 text-primary" />}
title={dict.examples.animatedDiagram}
description={dict.examples.animatedDescription}
onClick={() => {
setInput(
"Give me a **animated connector** diagram of transformer's architecture",
)
setFiles([])
}}
/>
<ExampleCard
icon={<Cloud className="w-4 h-4 text-primary" />}
title={dict.examples.awsArchitecture}
description={dict.examples.awsDescription}
onClick={handleReplicateArchitecture}
/>
<ExampleCard
icon={<GitBranch className="w-4 h-4 text-primary" />}
title={dict.examples.replicateFlowchart}
description={dict.examples.replicateDescription}
onClick={handleReplicateFlowchart}
/>
<ExampleCard
icon={<Palette className="w-4 h-4 text-primary" />}
title={dict.examples.creativeDrawing}
description={dict.examples.creativeDescription}
onClick={() => {
setInput("Draw a cat for me")
setFiles([])
}}
/>
</div>
<p className="text-[11px] text-muted-foreground/60 text-center mt-4">
{dict.examples.cachedNote}
</p>
</div>
</div>
)
}
+669
View File
@@ -0,0 +1,669 @@
"use client"
import {
BookmarkPlus,
Download,
History,
Image as ImageIcon,
Link,
Send,
Square,
} from "lucide-react"
import type React from "react"
import {
type Dispatch,
forwardRef,
type SetStateAction,
useCallback,
useEffect,
useImperativeHandle,
useRef,
useState,
} from "react"
import { toast } from "sonner"
import { ButtonWithTooltip } from "@/components/button-with-tooltip"
import { TemplateCreateDialog } from "@/components/chat/TemplateCreateDialog"
import { ErrorToast } from "@/components/error-toast"
import { HistoryDialog } from "@/components/history-dialog"
import { ModelSelector } from "@/components/model-selector"
import { SaveDialog } from "@/components/save-dialog"
import { Button } from "@/components/ui/button"
import { Textarea } from "@/components/ui/textarea"
import { UrlInputDialog } from "@/components/url-input-dialog"
import { useDiagram } from "@/contexts/diagram-context"
import { useDictionary } from "@/hooks/use-dictionary"
import { formatMessage } from "@/lib/i18n/utils"
import { isPdfFile, isTextFile } from "@/lib/pdf-utils"
import { STORAGE_KEYS } from "@/lib/storage"
import type { FlattenedModel } from "@/lib/types/model-config"
import { extractUrlContent, type UrlData } from "@/lib/url-utils"
import { isRealDiagram } from "@/lib/utils"
import { FilePreviewList } from "./file-preview-list"
const MAX_IMAGE_SIZE = 2 * 1024 * 1024 // 2MB
const MAX_FILES = 5
// Image formats every supported model provider accepts (SVG is read as text)
const SUPPORTED_IMAGE_TYPES = [
"image/png",
"image/jpeg",
"image/gif",
"image/webp",
]
function isValidFileType(file: File): boolean {
return (
SUPPORTED_IMAGE_TYPES.includes(file.type) ||
isPdfFile(file) ||
isTextFile(file)
)
}
function formatFileSize(bytes: number): string {
const mb = bytes / 1024 / 1024
if (mb < 0.01) return `${(bytes / 1024).toFixed(0)}KB`
return `${mb.toFixed(2)}MB`
}
function showErrorToast(message: React.ReactNode) {
toast.custom(
(t) => (
<ErrorToast message={message} onDismiss={() => toast.dismiss(t)} />
),
{ duration: 5000 },
)
}
interface ValidationResult {
validFiles: File[]
errors: string[]
}
function validateFiles(
newFiles: File[],
existingCount: number,
dict: any,
): ValidationResult {
const errors: string[] = []
const validFiles: File[] = []
const availableSlots = MAX_FILES - existingCount
if (availableSlots <= 0) {
errors.push(formatMessage(dict.errors.maxFiles, { max: MAX_FILES }))
return { validFiles, errors }
}
for (const file of newFiles) {
if (validFiles.length >= availableSlots) {
errors.push(
formatMessage(dict.errors.onlyMoreAllowed, {
slots: availableSlots,
}),
)
break
}
if (!isValidFileType(file)) {
errors.push(
formatMessage(dict.errors.unsupportedType, { name: file.name }),
)
continue
}
// Only check size for images (PDFs/text files are extracted client-side, so file size doesn't matter)
const isExtractedFile = isPdfFile(file) || isTextFile(file)
if (!isExtractedFile && file.size > MAX_IMAGE_SIZE) {
const maxSizeMB = MAX_IMAGE_SIZE / 1024 / 1024
errors.push(
formatMessage(dict.errors.fileExceeds, {
name: file.name,
size: formatFileSize(file.size),
max: maxSizeMB,
}),
)
} else {
validFiles.push(file)
}
}
return { validFiles, errors }
}
function showValidationErrors(errors: string[], dict: any) {
if (errors.length === 0) return
if (errors.length === 1) {
showErrorToast(
<span className="text-muted-foreground">{errors[0]}</span>,
)
} else {
showErrorToast(
<div className="flex flex-col gap-1">
<span className="font-medium">
{formatMessage(dict.errors.filesRejected, {
count: errors.length,
})}
</span>
<ul className="text-muted-foreground text-xs list-disc list-inside">
{errors.slice(0, 3).map((err) => (
<li key={err}>{err}</li>
))}
{errors.length > 3 && (
<li>
{formatMessage(dict.errors.andMore, {
count: errors.length - 3,
})}
</li>
)}
</ul>
</div>,
)
}
}
export interface ChatInputRef {
focus: () => void
}
interface ChatInputProps {
input: string
status: "submitted" | "streaming" | "ready" | "error"
onSubmit: (e: React.FormEvent<HTMLFormElement>) => void
onChange: (e: React.ChangeEvent<HTMLTextAreaElement>) => void
onStop?: () => void
files?: File[]
onFileChange?: (files: File[]) => void
pdfData?: Map<
File,
{ text: string; charCount: number; isExtracting: boolean }
>
urlData?: Map<string, UrlData>
onUrlChange?: Dispatch<SetStateAction<Map<string, UrlData>>>
sessionId?: string
error?: Error | null
// Model selector props
models?: FlattenedModel[]
selectedModelId?: string
onModelSelect?: (modelId: string | undefined) => void
onConfigureModels?: () => void
showUnvalidatedModels?: boolean
// Focus control props
shouldFocus?: boolean
onFocused?: () => void
}
export const ChatInput = forwardRef<ChatInputRef, ChatInputProps>(
function ChatInput(
{
input,
status,
onSubmit,
onChange,
onStop,
files = [],
onFileChange = () => {},
pdfData = new Map(),
urlData,
onUrlChange,
sessionId,
error = null,
models = [],
selectedModelId,
onModelSelect = () => {},
onConfigureModels,
showUnvalidatedModels = false,
shouldFocus = false,
onFocused,
},
ref,
) {
const dict = useDictionary()
const {
chartXML,
diagramHistory,
saveDiagramToFile,
showSaveDialog,
setShowSaveDialog,
} = useDiagram()
const textareaRef = useRef<HTMLTextAreaElement>(null)
const fileInputRef = useRef<HTMLInputElement>(null)
const [isDragging, setIsDragging] = useState(false)
// Expose focus method via ref
useImperativeHandle(ref, () => ({
focus: () => {
textareaRef.current?.focus()
},
}))
// Focus the textarea when shouldFocus becomes true
// Use setTimeout to ensure focus happens after drawio iframe settles
useEffect(() => {
if (shouldFocus) {
const timer = setTimeout(() => {
textareaRef.current?.focus()
onFocused?.()
}, 150)
return () => clearTimeout(timer)
}
}, [shouldFocus, onFocused])
const [showHistory, setShowHistory] = useState(false)
const [showUrlDialog, setShowUrlDialog] = useState(false)
const [showSaveAsTemplate, setShowSaveAsTemplate] = useState(false)
const [isExtractingUrl, setIsExtractingUrl] = useState(false)
const [sendShortcut, setSendShortcut] = useState("ctrl-enter")
// Allow retry when there's an error (even if status is still "streaming" or "submitted")
const isDisabled =
(status === "streaming" || status === "submitted") && !error
// Block sending until attached files and URLs have their text, otherwise
// their content would be silently dropped
const isExtractingAttachments =
files.some((file) => pdfData.get(file)?.isExtracting) ||
Array.from(urlData?.values() ?? []).some((d) => d.isExtracting)
const adjustTextareaHeight = useCallback(() => {
const textarea = textareaRef.current
if (textarea) {
textarea.style.height = "auto"
textarea.style.height = `${Math.min(textarea.scrollHeight, 200)}px`
}
}, [])
// Handle programmatic input changes (e.g., setInput("") after form submission)
useEffect(() => {
adjustTextareaHeight()
}, [input, adjustTextareaHeight])
// Load send shortcut preference from localStorage and listen for changes
useEffect(() => {
const stored = localStorage.getItem(STORAGE_KEYS.sendShortcut)
if (stored) setSendShortcut(stored)
const handleChange = (e: CustomEvent<string>) =>
setSendShortcut(e.detail)
window.addEventListener(
"sendShortcutChange",
handleChange as EventListener,
)
return () =>
window.removeEventListener(
"sendShortcutChange",
handleChange as EventListener,
)
}, [])
const handleChange = (e: React.ChangeEvent<HTMLTextAreaElement>) => {
onChange(e)
adjustTextareaHeight()
}
const handleKeyDown = (e: React.KeyboardEvent) => {
// Enter that confirms an IME candidate must not send the message
if (e.nativeEvent.isComposing || e.keyCode === 229) return
const shouldSend =
sendShortcut === "enter"
? e.key === "Enter" &&
!e.shiftKey &&
!e.ctrlKey &&
!e.metaKey
: (e.metaKey || e.ctrlKey) && e.key === "Enter"
if (shouldSend) {
e.preventDefault()
const form = e.currentTarget.closest("form")
if (
form &&
input.trim() &&
!isDisabled &&
!isExtractingAttachments
) {
form.requestSubmit()
}
}
}
const handlePaste = async (e: React.ClipboardEvent) => {
if (isDisabled) return
const items = e.clipboardData.items
const imageItems = Array.from(items).filter((item) =>
item.type.startsWith("image/"),
)
if (imageItems.length > 0) {
const imageFiles = (
await Promise.all(
imageItems.map(async (item, index) => {
const file = item.getAsFile()
if (!file) return null
return new File(
[file],
`pasted-image-${Date.now()}-${index}.${file.type.split("/")[1]}`,
{ type: file.type },
)
}),
)
).filter((f): f is File => f !== null)
const { validFiles, errors } = validateFiles(
imageFiles,
files.length,
dict,
)
showValidationErrors(errors, dict)
if (validFiles.length > 0) {
onFileChange([...files, ...validFiles])
}
}
}
const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const newFiles = Array.from(e.target.files || [])
const { validFiles, errors } = validateFiles(
newFiles,
files.length,
dict,
)
showValidationErrors(errors, dict)
if (validFiles.length > 0) {
onFileChange([...files, ...validFiles])
}
if (fileInputRef.current) {
fileInputRef.current.value = ""
}
}
const handleRemoveFile = (fileToRemove: File) => {
onFileChange(files.filter((file) => file !== fileToRemove))
if (fileInputRef.current) {
fileInputRef.current.value = ""
}
}
const triggerFileInput = () => {
fileInputRef.current?.click()
}
const handleDragOver = (e: React.DragEvent<HTMLFormElement>) => {
e.preventDefault()
e.stopPropagation()
setIsDragging(true)
}
const handleDragLeave = (e: React.DragEvent<HTMLFormElement>) => {
e.preventDefault()
e.stopPropagation()
setIsDragging(false)
}
const handleDrop = (e: React.DragEvent<HTMLFormElement>) => {
e.preventDefault()
e.stopPropagation()
setIsDragging(false)
if (isDisabled) return
// Let validateFiles show a toast for unsupported types
const { validFiles, errors } = validateFiles(
Array.from(e.dataTransfer.files),
files.length,
dict,
)
showValidationErrors(errors, dict)
if (validFiles.length > 0) {
onFileChange([...files, ...validFiles])
}
}
const handleUrlExtract = async (url: string) => {
if (!onUrlChange) return
setIsExtractingUrl(true)
// Use functional updates so a removal or send made while extracting
// is not overwritten when the request finishes
try {
onUrlChange((prev) =>
new Map(prev).set(url, {
url,
title: url,
content: "",
charCount: 0,
isExtracting: true,
}),
)
const data = await extractUrlContent(url)
// Skip if the URL was removed while extracting
onUrlChange((prev) =>
prev.has(url) ? new Map(prev).set(url, data) : prev,
)
setShowUrlDialog(false)
} catch (error) {
// Remove the URL from the data map on error
onUrlChange((prev) => {
const next = new Map(prev)
next.delete(url)
return next
})
showErrorToast(
<span className="text-muted-foreground">
{error instanceof Error
? error.message
: "Failed to extract URL content"}
</span>,
)
} finally {
setIsExtractingUrl(false)
}
}
return (
<form
id="chat-form"
onSubmit={onSubmit}
className={`w-full transition-all duration-200 ${
isDragging
? "ring-2 ring-primary ring-offset-2 rounded-2xl"
: ""
}`}
onDragOver={handleDragOver}
onDragLeave={handleDragLeave}
onDrop={handleDrop}
>
{/* File & URL previews */}
{(files.length > 0 || (urlData && urlData.size > 0)) && (
<div className="mb-3">
<FilePreviewList
files={files}
onRemoveFile={handleRemoveFile}
pdfData={pdfData}
urlData={urlData}
onRemoveUrl={
onUrlChange
? (url) =>
onUrlChange((prev) => {
const next = new Map(prev)
next.delete(url)
return next
})
: undefined
}
/>
</div>
)}
<div className="relative rounded-2xl border border-border bg-background shadow-sm focus-within:ring-2 focus-within:ring-primary/20 focus-within:border-primary/50 transition-all duration-200">
<Textarea
ref={textareaRef}
value={input}
onChange={handleChange}
onKeyDown={handleKeyDown}
onPaste={handlePaste}
placeholder={dict.chat.placeholder}
disabled={isDisabled}
aria-label="Chat input"
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 scrollbar-thin"
/>
<div className="flex items-center justify-end gap-1 px-3 py-2 border-t border-border/50">
<div className="flex items-center gap-1 overflow-x-hidden">
<ButtonWithTooltip
type="button"
variant="ghost"
size="sm"
onClick={() => setShowHistory(true)}
disabled={
isDisabled || diagramHistory.length === 0
}
tooltipContent={dict.chat.diagramHistory}
className="h-8 w-8 p-0 text-muted-foreground hover:text-foreground"
>
<History className="h-4 w-4" />
</ButtonWithTooltip>
<ButtonWithTooltip
type="button"
variant="ghost"
size="sm"
onClick={() => setShowSaveDialog(true)}
disabled={
isDisabled || !isRealDiagram(chartXML)
}
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>
{onUrlChange && (
<ButtonWithTooltip
type="button"
variant="ghost"
size="sm"
onClick={() => setShowUrlDialog(true)}
disabled={isDisabled}
tooltipContent={dict.chat.ExtractURL}
className="h-8 w-8 p-0 text-muted-foreground hover:text-foreground"
>
<Link className="h-4 w-4" />
</ButtonWithTooltip>
)}
<ButtonWithTooltip
type="button"
variant="ghost"
size="sm"
onClick={() => setShowSaveAsTemplate(true)}
disabled={isDisabled || !input.trim()}
tooltipContent={dict.templates.saveAsTemplate}
className="h-8 w-8 p-0 text-muted-foreground hover:text-foreground"
>
<BookmarkPlus className="h-4 w-4" />
</ButtonWithTooltip>
<input
type="file"
ref={fileInputRef}
className="hidden"
onChange={handleFileChange}
accept="image/png,image/jpeg,image/gif,image/webp,.svg,.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" />
{(status === "streaming" || status === "submitted") &&
onStop ? (
<Button
type="button"
onClick={onStop}
size="sm"
variant="destructive"
className="h-8 w-8 p-0 rounded-xl shadow-sm"
aria-label={dict.chat.stopGeneration}
>
<Square className="h-4 w-4" />
</Button>
) : (
<Button
type="submit"
disabled={
isDisabled ||
isExtractingAttachments ||
!input.trim()
}
size="sm"
className="h-8 px-4 rounded-xl font-medium shadow-sm"
aria-label={dict.chat.send}
>
<Send className="h-4 w-4 mr-1.5" />
{dict.chat.send}
</Button>
)}
</div>
</div>
<HistoryDialog
showHistory={showHistory}
onToggleHistory={setShowHistory}
/>
<SaveDialog
open={showSaveDialog}
onOpenChange={setShowSaveDialog}
onSave={(filename, format) =>
saveDiagramToFile(
filename,
format,
sessionId,
dict.save.savedSuccessfully,
)
}
// Local date as YYYY-MM-DD (toISOString would give UTC)
defaultFilename={`diagram-${new Date().toLocaleDateString("sv-SE")}`}
/>
{onUrlChange && (
<UrlInputDialog
open={showUrlDialog}
onOpenChange={setShowUrlDialog}
onSubmit={handleUrlExtract}
isExtracting={isExtractingUrl}
/>
)}
<TemplateCreateDialog
open={showSaveAsTemplate}
onOpenChange={setShowSaveAsTemplate}
onSuccess={() => {
setShowSaveAsTemplate(false)
// Let the template list in the lobby reload
window.dispatchEvent(new Event("templatesChanged"))
}}
initialPrompt={input.trim()}
/>
</form>
)
},
)
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+360
View File
@@ -0,0 +1,360 @@
"use client"
import {
ChevronDown,
ChevronUp,
MessageSquare,
Search,
Trash2,
X,
} from "lucide-react"
import { useEffect, useState } from "react"
import { TemplatePanel } from "@/components/chat/TemplatePanel"
import ExamplePanel from "@/components/chat-example-panel"
import Image from "@/components/image-with-basepath"
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from "@/components/ui/alert-dialog"
import { STORAGE_KEYS } from "@/lib/storage"
import type { Template } from "@/lib/template-storage"
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
onSendTemplate?: (template: Template) => void
currentInput?: string
dict: {
sessionHistory?: {
recentChats?: string
searchPlaceholder?: string
noResults?: string
justNow?: string
deleteTitle?: string
deleteDescription?: string
}
templates?: {
title?: string
myTemplates?: 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",
})
}
function getPanelVisibility() {
if (typeof window === "undefined")
return { recentChats: true, myTemplates: true, quickExamples: true }
return {
recentChats:
localStorage.getItem(STORAGE_KEYS.showRecentChats) !== "false",
myTemplates:
localStorage.getItem(STORAGE_KEYS.showMyTemplates) !== "false",
quickExamples:
localStorage.getItem(STORAGE_KEYS.showQuickExamples) !== "false",
}
}
export function ChatLobby({
sessions,
onSelectSession,
onDeleteSession,
setInput,
setFiles,
onSendTemplate,
currentInput = "",
dict,
}: ChatLobbyProps) {
const [templatesExpanded, setTemplatesExpanded] = useState(true)
const [examplesExpanded, setExamplesExpanded] = useState(true)
const [panelVisibility, setPanelVisibility] = useState(getPanelVisibility)
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false)
const [sessionToDelete, setSessionToDelete] = useState<string | null>(null)
const [searchQuery, setSearchQuery] = useState("")
// Listen for panel visibility changes from settings
useEffect(() => {
const handler = () => setPanelVisibility(getPanelVisibility())
window.addEventListener("panelVisibilityChange", handler)
return () =>
window.removeEventListener("panelVisibilityChange", handler)
}, [])
const hasHistory = sessions.length > 0
if (!hasHistory) {
if (!panelVisibility.myTemplates && !panelVisibility.quickExamples) {
return null
}
return (
<div className="animate-fade-in">
{panelVisibility.myTemplates && (
<TemplatePanel
setInput={setInput}
onSendTemplate={onSendTemplate}
currentInput={currentInput}
/>
)}
{panelVisibility.quickExamples && (
<div className={panelVisibility.myTemplates ? "mt-6" : ""}>
<ExamplePanel setInput={setInput} setFiles={setFiles} />
</div>
)}
</div>
)
}
// Show history + collapsible examples when there are sessions
return (
<div className="py-6 px-2 animate-fade-in">
{/* Recent Chats Section */}
{panelVisibility.recentChats && (
<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) => {
// Ignore keys bubbling up from the delete button
if (e.target !== e.currentTarget) return
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 My Templates Section */}
{panelVisibility.myTemplates && (
<div className="border-t border-border/50 pt-4">
<button
type="button"
onClick={() => setTemplatesExpanded(!templatesExpanded)}
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.templates?.myTemplates || "My Templates"}
</span>
{templatesExpanded ? (
<ChevronUp className="w-4 h-4" />
) : (
<ChevronDown className="w-4 h-4" />
)}
</button>
{templatesExpanded && (
<div className="mt-2">
<TemplatePanel
setInput={setInput}
onSendTemplate={onSendTemplate}
currentInput={currentInput}
/>
</div>
)}
</div>
)}
{/* Collapsible Quick Examples Section */}
{panelVisibility.quickExamples && (
<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>
)
}
+13 -17
View File
@@ -36,7 +36,6 @@ import {
updateTemplate,
validateImportData,
} from "@/lib/template-storage"
import { useUiStore } from "@/stores/ui-store"
import { TemplateCreateDialog } from "./TemplateCreateDialog"
import { TemplateEditDialog } from "./TemplateEditDialog"
@@ -44,8 +43,6 @@ interface TemplatePanelProps {
setInput: (input: string) => void
onSendTemplate?: (template: Template) => void
currentInput?: string
/** Skip the title block (the surrounding dialog already has one) */
hideHeader?: boolean
}
function formatLastUsed(timestamp: number, neverUsedText: string): string {
@@ -80,10 +77,8 @@ export function TemplatePanel({
setInput,
onSendTemplate,
currentInput = "",
hideHeader = false,
}: TemplatePanelProps) {
const dict = useDictionary()
const templatesVersion = useUiStore((s) => s.templatesVersion)
const [templates, setTemplates] = useState<Template[]>([])
const [loading, setLoading] = useState(true)
const [createDialogOpen, setCreateDialogOpen] = useState(false)
@@ -113,10 +108,13 @@ export function TemplatePanel({
? searchTemplates(templates, searchQuery)
: templates
// Reload when a template is saved elsewhere, e.g. from the chat input
useEffect(() => {
loadTemplates()
}, [loadTemplates, templatesVersion])
// Reload when a template is saved elsewhere, e.g. from the chat input
window.addEventListener("templatesChanged", loadTemplates)
return () =>
window.removeEventListener("templatesChanged", loadTemplates)
}, [loadTemplates])
const handleCreateSuccess = () => {
loadTemplates()
@@ -335,16 +333,14 @@ export function TemplatePanel({
if (!loading && templates.length === 0) {
return (
<div className="py-6 px-2 animate-fade-in">
{!hideHeader && (
<div className="text-center mb-6">
<h2 className="text-lg font-semibold text-foreground mb-2">
{dict.templates.title}
</h2>
<p className="text-sm text-muted-foreground max-w-xs mx-auto">
{dict.templates.subtitle}
</p>
</div>
)}
<div className="text-center mb-6">
<h2 className="text-lg font-semibold text-foreground mb-2">
{dict.templates.title}
</h2>
<p className="text-sm text-muted-foreground max-w-xs mx-auto">
{dict.templates.subtitle}
</p>
</div>
<div className="flex flex-col items-center justify-center py-8 px-4">
<div className="w-16 h-16 rounded-2xl bg-primary/10 flex items-center justify-center mb-4">
<FileText className="w-8 h-8 text-primary/60" />
+267
View File
@@ -0,0 +1,267 @@
"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[] }) {
// Streamed or invalid input can hold anything: show only what React can
// render (an object in place of a string would crash the whole chat)
const shown = operations.filter(
(op) => typeof (op as { operation?: unknown })?.operation === "string",
)
const text = (value: unknown) => (typeof value === "string" ? value : "")
return (
<div className="space-y-3">
{shown.map((op, index) => (
<div
key={`${op.operation}-${text(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: {text(op.cell_id)}
</span>
</div>
{text(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">
{text(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, errorText } = part
// Default to expanded for all states (user can manually collapse if needed)
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
}
}
// Incomplete XML means the output hit the length limit, unless the user
// stopped the generation themselves. Without an input the JSON was
// broken (the server repairs JSON cut short by the limit).
const isTruncated =
state === "output-error" &&
errorText !== "Stopped by user" &&
(toolName === "display_diagram" || toolName === "append_diagram") &&
typeof input?.xml === "string" &&
!isMxCellXmlComplete(input.xml)
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" &&
(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 ? (
state === "input-streaming" ||
state === "input-available" ? (
<pre
className="text-[11px] leading-relaxed overflow-x-auto overflow-y-auto max-h-48 scrollbar-thin break-all whitespace-pre-wrap"
style={{
fontFamily:
"var(--font-mono), ui-monospace, monospace",
margin: 0,
padding: 0,
}}
>
{input.xml}
</pre>
) : (
<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>
)}
{/* AI SDK stores tool errors in errorText */}
{state === "output-error" && (errorText || output) && (
<div
className={`px-4 py-3 border-t border-border/40 text-sm whitespace-pre-wrap break-words ${isTruncated ? "text-yellow-600" : "text-red-600"}`}
>
{isTruncated
? "Output truncated due to length limits. Try a simpler request or increase Max Output Tokens in Settings."
: (errorText ?? 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>
)
}
-77
View File
@@ -1,77 +0,0 @@
import { MAX_FILES, MAX_FILE_SIZE as MAX_IMAGE_SIZE } from "@/lib/chat-helpers"
import { formatMessage } from "@/lib/i18n/utils"
import { isPdfFile, isTextFile } from "@/lib/pdf-utils"
// Image formats every supported model provider accepts (SVG is read as text)
const SUPPORTED_IMAGE_TYPES = [
"image/png",
"image/jpeg",
"image/gif",
"image/webp",
]
/** Accept list for the attachment picker */
export const ATTACHMENT_ACCEPT =
"image/png,image/jpeg,image/gif,image/webp,.svg,.pdf,application/pdf,text/*,.md,.markdown,.json,.csv,.xml,.yaml,.yml,.toml"
function isValidFileType(file: File): boolean {
return (
SUPPORTED_IMAGE_TYPES.includes(file.type) ||
isPdfFile(file) ||
isTextFile(file)
)
}
function formatFileSize(bytes: number): string {
const mb = bytes / 1024 / 1024
if (mb < 0.01) return `${(bytes / 1024).toFixed(0)}KB`
return `${mb.toFixed(2)}MB`
}
/** Keep the files that can be attached; describe the rest as errors */
export function validateFiles(
newFiles: File[],
existingCount: number,
dict: any,
): { validFiles: File[]; errors: string[] } {
const errors: string[] = []
const validFiles: File[] = []
const availableSlots = MAX_FILES - existingCount
if (availableSlots <= 0) {
errors.push(formatMessage(dict.errors.maxFiles, { max: MAX_FILES }))
return { validFiles, errors }
}
for (const file of newFiles) {
if (validFiles.length >= availableSlots) {
errors.push(
formatMessage(dict.errors.onlyMoreAllowed, {
slots: availableSlots,
}),
)
break
}
if (!isValidFileType(file)) {
errors.push(
formatMessage(dict.errors.unsupportedType, { name: file.name }),
)
continue
}
// PDFs and text files are extracted in the browser, so only images
// have a size limit
const isExtractedFile = isPdfFile(file) || isTextFile(file)
if (!isExtractedFile && file.size > MAX_IMAGE_SIZE) {
errors.push(
formatMessage(dict.errors.fileExceeds, {
name: file.name,
size: formatFileSize(file.size),
max: MAX_IMAGE_SIZE / 1024 / 1024,
}),
)
} else {
validFiles.push(file)
}
}
return { validFiles, errors }
}
File diff suppressed because it is too large Load Diff
-192
View File
@@ -1,192 +0,0 @@
import {
ChevronDown,
Download,
MessageSquareDashed,
PanelRightClose,
Settings2,
SquarePen,
} from "lucide-react"
import { useRef } from "react"
import { DevXmlSimulator } from "@/components/dev-xml-simulator"
import { BrandMark } from "@/components/workspace/brand-mark"
import { IconButton } from "@/components/workspace/icon-button"
import { useDiagram } from "@/contexts/diagram-context"
import { useDictionary } from "@/hooks/use-dictionary"
import { modKey } from "@/lib/platform"
import { cn } from "@/lib/utils"
import { useCanvasStore } from "@/stores/canvas-store"
import {
PANEL_MAX_WIDTH,
PANEL_MIN_WIDTH,
useSettingsStore,
} from "@/stores/settings-store"
import { useUiStore } from "@/stores/ui-store"
import { useChatEngine } from "./chat-engine"
import { Composer } from "./composer"
import { MessageList } from "./message-list"
import { SessionMenu, useSessionTitle } from "./session-menu"
import { VersionStrip } from "./version-strip"
const DEBUG = process.env.NODE_ENV === "development"
/** Drag handle on the panel's left edge (the panel sits on the right) */
function ResizeHandle() {
const panelWidth = useSettingsStore((s) => s.panelWidth)
const setPanelWidth = useSettingsStore((s) => s.setPanelWidth)
const startRef = useRef<{ x: number; width: number } | null>(null)
return (
// biome-ignore lint/a11y/useSemanticElements: a draggable handle, not a divider line
<div
role="separator"
aria-orientation="vertical"
aria-valuenow={panelWidth}
aria-valuemin={PANEL_MIN_WIDTH}
aria-valuemax={PANEL_MAX_WIDTH}
tabIndex={-1}
className="absolute top-4 -left-1.5 bottom-4 z-10 w-3 cursor-col-resize after:absolute after:inset-y-0 after:left-1/2 after:w-0.5 after:-translate-x-1/2 after:rounded-full after:bg-foreground/0 after:transition-colors hover:after:bg-foreground/15"
onPointerDown={(e) => {
e.preventDefault()
const panel = e.currentTarget.parentElement
startRef.current = {
x: e.clientX,
width: panel?.getBoundingClientRect().width ?? 384,
}
e.currentTarget.setPointerCapture(e.pointerId)
}}
onPointerMove={(e) => {
const start = startRef.current
if (!start) return
// Dragging left widens the panel
setPanelWidth(start.width - (e.clientX - start.x))
}}
onPointerUp={(e) => {
startRef.current = null
e.currentTarget.releasePointerCapture(e.pointerId)
}}
/>
)
}
function EmptyThread() {
const dict = useDictionary()
return (
<div className="flex h-full flex-col items-center justify-center gap-2 px-8 text-center">
<MessageSquareDashed className="size-5 text-faint" />
<p className="max-w-60 text-[13px] text-muted-foreground">
{dict.chat.emptyThread}
</p>
</div>
)
}
/** The floating chat panel: header, versions, conversation, message box */
export function ChatPanel({
mobile = false,
onOpenFile,
}: {
mobile?: boolean
onOpenFile: () => void
}) {
const dict = useDictionary()
const engine = useChatEngine()
const { loadDiagram, isDrawioReady } = useDiagram()
// An external draw.io has no app toolbar (and so no Export button there)
const hasEditor = useCanvasStore((s) => s.hasEditor)
const setSaveDialogOpen = useUiStore((s) => s.setSaveDialogOpen)
const title = useSessionTitle()
const panelWidth = useSettingsStore((s) => s.panelWidth)
const openSettings = useUiStore((s) => s.openSettings)
const setPanelOpen = useUiStore((s) => s.setPanelOpen)
return (
<aside
className={cn(
"flex flex-col bg-card",
mobile
? "absolute inset-0"
: "absolute top-3 right-3 bottom-3 z-20 rounded-2xl shadow-float animate-panel-in",
)}
style={mobile ? undefined : { width: panelWidth }}
aria-label={dict.nav.aiChat}
data-testid="chat-panel"
>
<header className="flex h-[52px] shrink-0 items-center gap-1 pr-2 pl-3">
<BrandMark />
<SessionMenu onOpenFile={onOpenFile}>
<button
type="button"
className="ml-1 flex min-w-0 items-center gap-1 rounded-lg px-1.5 py-1 hover:bg-accent"
data-testid="session-title"
>
<span className="truncate text-[15px] font-semibold tracking-[-0.01em]">
{title}
</span>
<ChevronDown className="size-4 shrink-0 text-faint" />
</button>
</SessionMenu>
<div className="ml-auto flex items-center">
{!hasEditor && isDrawioReady && (
<IconButton
label={dict.workspace.export}
onClick={() => setSaveDialogOpen(true)}
data-testid="panel-export"
>
<Download />
</IconButton>
)}
<IconButton
label={dict.nav.newChat}
onClick={engine.newChat}
disabled={engine.isBusy}
data-testid="new-chat-button"
>
<SquarePen />
</IconButton>
<IconButton
label={dict.nav.settings}
onClick={() => openSettings()}
data-testid="settings-button"
>
<Settings2 />
</IconButton>
{!mobile && (
<IconButton
label={dict.nav.hidePanel}
shortcut={`${modKey}B`}
onClick={() => setPanelOpen(false)}
>
<PanelRightClose />
</IconButton>
)}
</div>
</header>
<VersionStrip />
<div className="min-h-0 flex-1 overflow-y-auto scrollbar-thin">
{engine.messages.length > 0 ? <MessageList /> : <EmptyThread />}
</div>
{DEBUG && (
<DevXmlSimulator
setMessages={engine.setMessages as any}
onDisplayChart={(xml) => loadDiagram(xml, true)}
/>
)}
<div className="shrink-0 p-2.5 pt-1">
<Composer
placeholder={
engine.messages.length > 0 &&
engine.chatSelection.length === 0
? dict.chat.placeholderFollowUp
: undefined
}
/>
</div>
{!mobile && <ResizeHandle />}
</aside>
)
}
-175
View File
@@ -1,175 +0,0 @@
import { ChevronLeft, ChevronRight, RotateCcw } from "lucide-react"
import { useEffect, useMemo, useState } from "react"
import { Shimmer } from "@/components/ai-elements/shimmer"
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog"
import { useDiagram } from "@/contexts/diagram-context"
import { useDictionary } from "@/hooks/use-dictionary"
import { diffDiagrams, isSameDocument } from "@/lib/diagram-diff"
import { formatMessage } from "@/lib/i18n/utils"
import { describeChanges } from "@/lib/version-text"
import { useUiStore } from "@/stores/ui-store"
import { useVersionsStore } from "@/stores/versions-store"
import { useChatEngine } from "./chat-engine"
function Sheet({
label,
svg,
loadingText,
emptyText,
}: {
label: string
svg: string | null | undefined
loadingText: string
emptyText: string
}) {
return (
<figure className="flex min-w-0 flex-1 flex-col gap-2">
<figcaption className="text-xs font-medium text-muted-foreground">
{label}
</figcaption>
<div className="sheet-light flex aspect-[16/10] items-center justify-center overflow-hidden rounded-xl border border-border bg-white p-2">
{svg === "" ? (
<span className="text-xs text-faint">{emptyText}</span>
) : svg ? (
// biome-ignore lint/performance/noImgElement: data URL thumbnail
<img
src={svg}
alt=""
className="max-h-full max-w-full object-contain"
/>
) : (
<Shimmer as="span" className="text-xs">
{loadingText}
</Shimmer>
)}
</div>
</figure>
)
}
/** A saved version next to the current canvas, with restore */
export function CompareDialog() {
const dict = useDictionary()
const t = dict.versions
const engine = useChatEngine()
const { getVersionSvg, chartXML } = useDiagram()
const versionId = useUiStore((s) => s.compareVersionId)
const openCompare = useUiStore((s) => s.openCompare)
const closeCompare = useUiStore((s) => s.closeCompare)
const versions = useVersionsStore((s) => s.versions)
const [currentSvg, setCurrentSvg] = useState<string | null>(null)
const index = versions.findIndex((v) => v.id === versionId)
const version = index >= 0 ? versions[index] : null
// Whether the canvas is this version, on every page
const same = useMemo(
() => !version || isSameDocument(version.xml, chartXML),
[version, chartXML],
)
// What the canvas has changed since this version on the first page
// (null: nothing there)
const changes = useMemo(() => {
if (!version || same) return null
const { summary } = diffDiagrams(version.xml, chartXML)
return Object.values(summary).some((n) => n > 0) ? summary : null
}, [version, chartXML, same])
// Picture of the canvas as it is now
useEffect(() => {
if (!versionId) return
let cancelled = false
setCurrentSvg(null)
getVersionSvg().then((svg) => {
if (!cancelled) setCurrentSvg(svg)
})
return () => {
cancelled = true
}
}, [versionId])
return (
<Dialog
open={!!version}
onOpenChange={(open) => !open && closeCompare()}
>
<DialogContent className="gap-5 sm:max-w-4xl">
<DialogHeader>
<DialogTitle>{t.compareTitle}</DialogTitle>
<DialogDescription>
{!version
? ""
: same
? t.isOnCanvas
: changes
? `${formatMessage(t.compareSince, { n: index + 1 })} ${describeChanges(changes, t)}`
: formatMessage(t.differsElsewhere, {
n: index + 1,
})}
</DialogDescription>
</DialogHeader>
{version && (
<div className="flex flex-col gap-4 sm:flex-row">
<Sheet
label={formatMessage(t.versionLabel, {
n: index + 1,
})}
svg={version.svg}
loadingText={t.rendering}
emptyText={t.noPreview}
/>
<Sheet
label={t.currentCanvas}
svg={currentSvg}
loadingText={t.rendering}
emptyText={t.noPreview}
/>
</div>
)}
<div className="flex items-center gap-2">
<button
type="button"
disabled={index <= 0}
onClick={() => openCompare(versions[index - 1].id)}
className="inline-flex size-8 items-center justify-center rounded-lg border border-border text-muted-foreground hover:text-foreground disabled:opacity-30"
aria-label={t.previous}
>
<ChevronLeft className="size-4" />
</button>
<button
type="button"
disabled={index < 0 || index >= versions.length - 1}
onClick={() => openCompare(versions[index + 1].id)}
className="inline-flex size-8 items-center justify-center rounded-lg border border-border text-muted-foreground hover:text-foreground disabled:opacity-30"
aria-label={t.next}
>
<ChevronRight className="size-4" />
</button>
<span className="text-xs text-muted-foreground tabular-nums">
{index + 1} / {versions.length}
</span>
<button
type="button"
disabled={!version || same || engine.isBusy}
onClick={() => {
if (!version) return
engine.restoreVersion(version.id)
closeCompare()
}}
className="ml-auto inline-flex h-9 items-center gap-1.5 rounded-lg bg-primary px-3.5 text-[13px] font-medium text-primary-foreground hover:opacity-90 disabled:opacity-40"
data-testid="compare-restore"
>
<RotateCcw className="size-4" />
{formatMessage(t.restoreVersion, { n: index + 1 })}
</button>
</div>
</DialogContent>
</Dialog>
)
}
-530
View File
@@ -1,530 +0,0 @@
import {
ArrowUp,
BookMarked,
BookmarkPlus,
Globe,
Paperclip,
Plus,
Square,
SquareDashedMousePointer,
X,
} from "lucide-react"
import type React from "react"
import { useCallback, useEffect, useRef, useState } from "react"
import { toast } from "sonner"
import { TemplateCreateDialog } from "@/components/chat/TemplateCreateDialog"
import { TemplatePanel } from "@/components/chat/TemplatePanel"
import { ErrorToast } from "@/components/error-toast"
import { FilePreviewList } from "@/components/file-preview-list"
import { ModelSelector } from "@/components/model-selector"
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog"
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@/components/ui/popover"
import { UrlInputDialog } from "@/components/url-input-dialog"
import { IconButton } from "@/components/workspace/icon-button"
import { useDictionary } from "@/hooks/use-dictionary"
import { formatMessage } from "@/lib/i18n/utils"
import { modKey } from "@/lib/platform"
import { extractUrlContent } from "@/lib/url-utils"
import { cn } from "@/lib/utils"
import { useSettingsStore } from "@/stores/settings-store"
import { useUiStore } from "@/stores/ui-store"
import { ATTACHMENT_ACCEPT, validateFiles } from "./attachments"
import { useChatEngine } from "./chat-engine"
function showErrorToast(message: React.ReactNode) {
toast.custom(
(t) => (
<ErrorToast message={message} onDismiss={() => toast.dismiss(t)} />
),
{ duration: 5000 },
)
}
function showValidationErrors(errors: string[], dict: any) {
if (errors.length === 0) return
if (errors.length === 1) {
showErrorToast(
<span className="text-muted-foreground">{errors[0]}</span>,
)
return
}
showErrorToast(
<div className="flex flex-col gap-1">
<span className="font-medium">
{formatMessage(dict.errors.filesRejected, {
count: errors.length,
})}
</span>
<ul className="list-inside list-disc text-xs text-muted-foreground">
{errors.slice(0, 3).map((err) => (
<li key={err}>{err}</li>
))}
{errors.length > 3 && (
<li>
{formatMessage(dict.errors.andMore, {
count: errors.length - 3,
})}
</li>
)}
</ul>
</div>,
)
}
function MenuItem({
icon,
children,
onClick,
disabled,
disabledHint,
}: {
icon: React.ReactNode
children: React.ReactNode
onClick: () => void
disabled?: boolean
/** Why the item can't be used now, shown under it while disabled */
disabledHint?: string
}) {
return (
<button
type="button"
onClick={onClick}
disabled={disabled}
className="group/item flex w-full items-start gap-2.5 rounded-md px-2.5 py-2 text-left text-[13px] hover:bg-accent disabled:pointer-events-none [&_svg]:mt-px [&_svg]:size-4 [&_svg]:text-muted-foreground"
>
<span className="contents group-disabled/item:[&>svg]:opacity-40">
{icon}
</span>
<span className="min-w-0">
<span className="block group-disabled/item:opacity-40">
{children}
</span>
{disabled && disabledHint && (
<span className="block text-xs text-muted-foreground">
{disabledHint}
</span>
)}
</span>
</button>
)
}
/**
* The message box: text, attachments, the selected shapes, the model picker
* and send/stop. `variant="hero"` is the large box on the start screen.
*/
export function Composer({
variant = "panel",
placeholder,
autoFocus = false,
}: {
variant?: "panel" | "hero"
placeholder?: string
autoFocus?: boolean
}) {
const dict = useDictionary()
const engine = useChatEngine()
const {
input,
setInput,
files,
setFiles,
pdfData,
urlData,
setUrlData,
isBusy,
error,
chatSelection,
dismissSelection,
modelConfig,
} = engine
const sendShortcut = useSettingsStore((s) => s.sendShortcut)
const openSettings = useUiStore((s) => s.openSettings)
const focusToken = useUiStore((s) => s.focusComposerToken)
const bumpTemplates = useUiStore((s) => s.bumpTemplates)
const textareaRef = useRef<HTMLTextAreaElement>(null)
const fileInputRef = useRef<HTMLInputElement>(null)
const [isDragging, setIsDragging] = useState(false)
const [addOpen, setAddOpen] = useState(false)
const [templatesOpen, setTemplatesOpen] = useState(false)
const [showUrlDialog, setShowUrlDialog] = useState(false)
const [isExtractingUrl, setIsExtractingUrl] = useState(false)
const [saveTemplateOpen, setSaveTemplateOpen] = useState(false)
const isHero = variant === "hero"
// Allow a retry when the last request failed, even mid-stream
const isDisabled = isBusy && !error
const canSend =
!isDisabled && !engine.isExtractingAttachments && !!input.trim()
const adjustHeight = useCallback(() => {
const textarea = textareaRef.current
if (!textarea) return
textarea.style.height = "auto"
textarea.style.height = `${Math.min(textarea.scrollHeight, 220)}px`
}, [])
useEffect(() => {
adjustHeight()
}, [input, adjustHeight])
// Focus requests from elsewhere (new chat, "ask AI" button, ⌘/)
useEffect(() => {
if (focusToken === 0 && !autoFocus) return
const timer = setTimeout(() => textareaRef.current?.focus(), 60)
return () => clearTimeout(timer)
}, [focusToken, autoFocus])
const addFiles = (newFiles: File[]) => {
const { validFiles, errors } = validateFiles(
newFiles,
files.length,
dict,
)
showValidationErrors(errors, dict)
if (validFiles.length > 0) setFiles([...files, ...validFiles])
}
const handleKeyDown = (e: React.KeyboardEvent<HTMLTextAreaElement>) => {
// Enter that confirms an IME candidate must not send the message
if (e.nativeEvent.isComposing || e.keyCode === 229) return
const shouldSend =
sendShortcut === "enter"
? e.key === "Enter" && !e.shiftKey && !e.ctrlKey && !e.metaKey
: (e.metaKey || e.ctrlKey) && e.key === "Enter"
if (shouldSend) {
e.preventDefault()
if (canSend) engine.submit()
}
}
const handlePaste = async (e: React.ClipboardEvent) => {
if (isDisabled) return
const imageItems = Array.from(e.clipboardData.items).filter((item) =>
item.type.startsWith("image/"),
)
if (imageItems.length === 0) return
const imageFiles = imageItems
.map((item, index) => {
const file = item.getAsFile()
if (!file) return null
return new File(
[file],
`pasted-image-${Date.now()}-${index}.${file.type.split("/")[1]}`,
{ type: file.type },
)
})
.filter((f): f is File => f !== null)
addFiles(imageFiles)
}
const handleUrlExtract = async (url: string) => {
setIsExtractingUrl(true)
// Functional updates, so a removal or send made while extracting is
// not overwritten when the request finishes
try {
setUrlData((prev) =>
new Map(prev).set(url, {
url,
title: url,
content: "",
charCount: 0,
isExtracting: true,
}),
)
const data = await extractUrlContent(url)
setUrlData((prev) =>
prev.has(url) ? new Map(prev).set(url, data) : prev,
)
setShowUrlDialog(false)
} catch (err) {
setUrlData((prev) => {
const next = new Map(prev)
next.delete(url)
return next
})
showErrorToast(
<span className="text-muted-foreground">
{err instanceof Error ? err.message : dict.url.failed}
</span>,
)
} finally {
setIsExtractingUrl(false)
}
}
const selectionLabel =
chatSelection.length === 1
? formatMessage(dict.selection.one, {
label:
chatSelection[0].label ||
(chatSelection[0].isEdge
? dict.selection.connector
: dict.selection.shape),
})
: formatMessage(dict.selection.many, {
count: chatSelection.length,
})
const hasChips =
chatSelection.length > 0 || files.length > 0 || urlData.size > 0
return (
<form
id={isHero ? "hero-chat-form" : "chat-form"}
onSubmit={(e) => {
e.preventDefault()
if (canSend) engine.submit()
}}
onDragOver={(e) => {
e.preventDefault()
setIsDragging(true)
}}
onDragLeave={(e) => {
e.preventDefault()
setIsDragging(false)
}}
onDrop={(e) => {
e.preventDefault()
setIsDragging(false)
if (!isDisabled) addFiles(Array.from(e.dataTransfer.files))
}}
className={cn(
"group/composer relative w-full rounded-[14px] border border-border bg-card transition-[border-color,box-shadow] duration-150",
"focus-within:border-foreground/20 focus-within:shadow-[0_0_0_4px_var(--accent)]",
isHero && "rounded-[18px] border-transparent shadow-pop",
isDragging &&
"border-foreground/30 shadow-[0_0_0_4px_var(--marker-soft)]",
)}
>
{hasChips && (
<div className="flex flex-wrap items-center gap-1.5 px-2.5 pt-2.5">
{chatSelection.length > 0 && (
<span
className="inline-flex max-w-full items-center gap-1.5 rounded-lg bg-marker-soft py-1 pr-1 pl-2 text-xs font-medium text-marker-ink"
data-testid="selection-chip"
>
<SquareDashedMousePointer className="size-3.5 shrink-0" />
<span className="truncate">{selectionLabel}</span>
<button
type="button"
onClick={dismissSelection}
aria-label={dict.selection.remove}
className="rounded p-0.5 hover:bg-black/5 dark:hover:bg-white/10"
>
<X className="size-3" />
</button>
</span>
)}
{(files.length > 0 || urlData.size > 0) && (
<div className="w-full">
<FilePreviewList
files={files}
onRemoveFile={(file) =>
setFiles(files.filter((f) => f !== file))
}
pdfData={pdfData}
urlData={urlData}
onRemoveUrl={(url) =>
setUrlData((prev) => {
const next = new Map(prev)
next.delete(url)
return next
})
}
/>
</div>
)}
</div>
)}
<textarea
ref={textareaRef}
value={input}
onChange={(e) => setInput(e.target.value)}
onKeyDown={handleKeyDown}
onPaste={handlePaste}
placeholder={
placeholder ??
(chatSelection.length > 0
? dict.selection.placeholder
: dict.chat.placeholder)
}
disabled={isDisabled}
aria-label={dict.chat.inputLabel}
data-testid="chat-input"
rows={isHero ? 3 : 2}
className={cn(
"block w-full resize-none bg-transparent text-foreground outline-none placeholder:text-faint scrollbar-thin disabled:opacity-60",
isHero
? "min-h-[84px] px-[18px] pt-4 pb-1 text-[15px] leading-relaxed"
: "min-h-[52px] px-3.5 pt-3 pb-1 text-sm",
)}
/>
<div
className={cn(
"flex items-center gap-0.5",
isHero ? "px-2.5 pt-1 pb-2.5" : "px-1.5 pt-0.5 pb-1.5",
)}
>
<Popover open={addOpen} onOpenChange={setAddOpen}>
<PopoverTrigger asChild>
<IconButton
label={dict.chat.attach}
tooltipSide="top"
disabled={isDisabled}
data-testid="composer-add"
>
<Plus />
</IconButton>
</PopoverTrigger>
<PopoverContent
side="top"
align="start"
className="w-64 p-1"
>
<MenuItem
icon={<Paperclip />}
onClick={() => {
setAddOpen(false)
fileInputRef.current?.click()
}}
>
{dict.chat.uploadFile}
</MenuItem>
<MenuItem
icon={<Globe />}
onClick={() => {
setAddOpen(false)
setShowUrlDialog(true)
}}
>
{dict.chat.ExtractURL}
</MenuItem>
<MenuItem
icon={<BookmarkPlus />}
disabled={!input.trim()}
disabledHint={dict.templates.saveDisabledHint}
onClick={() => {
setAddOpen(false)
setSaveTemplateOpen(true)
}}
>
{dict.templates.saveAsTemplate}
</MenuItem>
</PopoverContent>
</Popover>
<IconButton
label={dict.templates.myTemplates}
tooltipSide="top"
disabled={isDisabled}
onClick={() => setTemplatesOpen(true)}
data-testid="composer-templates"
>
<BookMarked />
</IconButton>
<div className="ml-auto flex min-w-0 flex-1 items-center justify-end gap-1">
<ModelSelector
models={modelConfig.models}
selectedModelId={modelConfig.selectedModelId}
onSelect={modelConfig.setSelectedModelId}
onConfigure={() => openSettings("models")}
disabled={isDisabled}
showUnvalidatedModels={
modelConfig.showUnvalidatedModels
}
/>
{!isBusy && input.trim() && (
<kbd
className="hidden shrink-0 font-sans text-[11px] text-faint sm:inline"
aria-hidden
>
{sendShortcut === "enter" ? "↵" : `${modKey}↵`}
</kbd>
)}
{isBusy ? (
<button
type="button"
onClick={engine.stop}
aria-label={dict.chat.stopGeneration}
className="inline-flex size-8 shrink-0 items-center justify-center rounded-[10px] bg-foreground text-background transition-opacity hover:opacity-85"
>
<Square className="size-3 fill-current" />
</button>
) : (
<button
type="submit"
disabled={!canSend}
aria-label={dict.chat.send}
className="inline-flex size-8 shrink-0 items-center justify-center rounded-[10px] bg-primary text-primary-foreground transition-[opacity,background-color] hover:opacity-90 disabled:bg-muted disabled:text-faint"
>
<ArrowUp className="size-4 stroke-[2.25]" />
</button>
)}
</div>
</div>
<input
ref={fileInputRef}
type="file"
className="hidden"
accept={ATTACHMENT_ACCEPT}
data-testid="attachment-input"
multiple
disabled={isDisabled}
onChange={(e) => {
addFiles(Array.from(e.target.files || []))
e.target.value = ""
}}
/>
<UrlInputDialog
open={showUrlDialog}
onOpenChange={setShowUrlDialog}
onSubmit={handleUrlExtract}
isExtracting={isExtractingUrl}
/>
<TemplateCreateDialog
open={saveTemplateOpen}
onOpenChange={setSaveTemplateOpen}
onSuccess={() => {
setSaveTemplateOpen(false)
bumpTemplates()
}}
initialPrompt={input.trim()}
/>
<Dialog open={templatesOpen} onOpenChange={setTemplatesOpen}>
<DialogContent className="flex max-h-[80vh] flex-col gap-0 p-0 sm:max-w-lg">
<DialogHeader className="px-5 pt-5 pb-2">
<DialogTitle>{dict.templates.myTemplates}</DialogTitle>
</DialogHeader>
<div className="min-h-0 flex-1 overflow-y-auto px-3 pb-3 scrollbar-thin">
<TemplatePanel
hideHeader
setInput={(value) => {
setInput(value)
setTemplatesOpen(false)
}}
currentInput={input}
onSendTemplate={(template) => {
setTemplatesOpen(false)
engine.sendTemplate(template.prompt)
}}
/>
</div>
</DialogContent>
</Dialog>
</form>
)
}
-285
View File
@@ -1,285 +0,0 @@
import { FileText, FileUp, History, Pencil } from "lucide-react"
import type React from "react"
import { useDictionary } from "@/hooks/use-dictionary"
import { getAssetUrl } from "@/lib/base-path"
import { cn } from "@/lib/utils"
import { useChatEngine } from "./chat-engine"
import { Composer } from "./composer"
import { SessionMenu } from "./session-menu"
interface Example {
key: string
title: string
description: string
/** Picture of the result (or of the input, for "replicate" examples) */
image?: string
prompt: string
/** Bundled file sent with the prompt; matches the cached example */
file?: { path: string; name: string; type: string }
}
async function loadExampleFile(file: NonNullable<Example["file"]>) {
const response = await fetch(getAssetUrl(file.path))
const blob = await response.blob()
return new File([blob], file.name, { type: file.type })
}
/** One start-screen choice: a small picture, a title and one line */
function ExampleCard({
onClick,
title,
description,
picture,
plain,
testId,
}: {
onClick: () => void
title: string
description: string
picture: React.ReactNode
/** An icon on a tinted tile instead of a diagram on paper */
plain?: boolean
testId?: string
}) {
return (
<button
type="button"
onClick={onClick}
className="group/example flex min-w-0 items-center gap-2.5 rounded-xl border border-border bg-card p-1.5 pr-2.5 text-left transition-colors hover:border-foreground/25"
data-testid={testId}
>
<span
className={cn(
"flex h-12 w-16 shrink-0 items-center justify-center overflow-hidden rounded-lg",
plain ? "bg-muted" : "sheet-light bg-white",
)}
>
{picture}
</span>
<span className="min-w-0 flex-1">
<span className="line-clamp-2 text-[13px] leading-snug font-medium text-foreground">
{title}
</span>
<span className="mt-0.5 block truncate text-xs text-muted-foreground">
{description}
</span>
</span>
</button>
)
}
/**
* Start screen over an empty canvas: one large message box and the cached
* examples (they answer instantly without calling a model).
*/
export function LobbyHero({
onDrawYourself,
onOpenFile,
compact = false,
}: {
onDrawYourself: () => void
onOpenFile: () => void
/** Phone layout: no example pictures, tighter spacing */
compact?: boolean
}) {
const dict = useDictionary()
const t = dict.lobby
const engine = useChatEngine()
// Prompts and file names must stay as they are: cached responses match them
const examples: Example[] = [
{
key: "paper",
title: dict.examples.paperToDiagram,
description: dict.examples.paperDescription,
prompt: "Summarize this paper as a diagram",
file: {
path: "/chain-of-thought.txt",
name: "chain-of-thought.txt",
type: "text/plain",
},
},
{
key: "animated",
title: dict.examples.animatedDiagram,
description: dict.examples.animatedDescription,
image: "/animated_connectors.svg",
prompt: "Give me a **animated connector** diagram of transformer's architecture",
},
{
key: "aws",
title: dict.examples.awsArchitecture,
description: dict.examples.awsDescription,
image: "/aws_demo.svg",
prompt: "Replicate this in aws style",
file: {
path: "/architecture.png",
name: "architecture.png",
type: "image/png",
},
},
{
key: "flowchart",
title: dict.examples.replicateFlowchart,
description: dict.examples.replicateDescription,
image: "/example.png",
prompt: "Replicate this flowchart.",
file: {
path: "/example.png",
name: "example.png",
type: "image/png",
},
},
{
key: "cat",
title: dict.examples.creativeDrawing,
description: dict.examples.creativeDescription,
image: "/cat_demo.svg",
prompt: "Draw a cat for me",
},
]
const pickExample = async (example: Example) => {
engine.setInput(example.prompt)
if (!example.file) {
engine.setFiles([])
return
}
try {
engine.setFiles([await loadExampleFile(example.file)])
} catch (error) {
console.error(dict.errors.failedToLoadExample, error)
}
}
return (
<div
className={cn(
"mx-auto w-full",
compact ? "max-w-xl px-4 py-8" : "max-w-[720px] px-6 py-10",
)}
data-testid="lobby-hero"
>
<h1
className={cn(
"text-center font-semibold tracking-[-0.025em] text-foreground",
compact
? "text-[26px] leading-tight"
: "text-[34px] leading-[1.15]",
)}
>
{t.title}
</h1>
<p
className={cn(
"mx-auto mt-2.5 max-w-[34em] text-center text-muted-foreground",
compact ? "mb-5 text-[13px]" : "mb-6 text-[14.5px]",
)}
>
{t.lede}
</p>
<Composer
variant="hero"
autoFocus={!compact}
placeholder={t.placeholder}
/>
<div
className={cn(
"mt-5 grid gap-2",
compact ? "grid-cols-1" : "grid-cols-3",
)}
>
{examples.map((example) =>
compact ? (
<button
key={example.key}
type="button"
onClick={() => pickExample(example)}
className="flex items-center gap-3 rounded-xl border border-border bg-card px-3 py-2.5 text-left hover:border-foreground/25"
>
<span className="min-w-0 flex-1">
<span className="block truncate text-[13px] font-medium">
{example.title}
</span>
<span className="block truncate text-xs text-muted-foreground">
{example.description}
</span>
</span>
</button>
) : (
<ExampleCard
key={example.key}
onClick={() => pickExample(example)}
title={example.title}
description={example.description}
testId={`example-${example.key}`}
picture={
example.image ? (
// biome-ignore lint/performance/noImgElement: static preview
<img
src={getAssetUrl(example.image)}
alt=""
className="max-h-10 max-w-[56px] object-contain transition-transform duration-300 group-hover/example:scale-[1.06]"
/>
) : (
<FileText className="size-5 text-muted-foreground" />
)
}
plain={!example.image}
/>
),
)}
{!compact && (
<ExampleCard
onClick={onOpenFile}
title={dict.workspace.openFile}
description={t.openFileHint}
testId="lobby-open-file"
picture={
<FileUp className="size-5 text-muted-foreground" />
}
plain
/>
)}
</div>
<p className="mt-3 text-center text-xs text-faint">
{dict.examples.cachedNote}
</p>
<div className="mt-6 flex flex-wrap items-center justify-center gap-x-5 gap-y-2 text-[13px] text-muted-foreground">
{engine.sessions.length > 0 && (
<SessionMenu align="center" onOpenFile={onOpenFile}>
<button
type="button"
className="inline-flex items-center gap-1.5 hover:text-foreground"
>
<History className="size-4" />
{t.recent}
</button>
</SessionMenu>
)}
{compact && (
<button
type="button"
onClick={onOpenFile}
className="inline-flex items-center gap-1.5 hover:text-foreground"
>
<FileUp className="size-4" />
{dict.workspace.openFile}
</button>
)}
<button
type="button"
onClick={onDrawYourself}
className="inline-flex items-center gap-1.5 hover:text-foreground"
data-testid="draw-yourself"
>
<Pencil className="size-4" />
{t.drawYourself}
</button>
</div>
</div>
)
}
-659
View File
@@ -1,659 +0,0 @@
import type { UIMessage } from "ai"
import {
AlertCircle,
BookmarkPlus,
Check,
ChevronDown,
Copy,
FileCode,
FileText,
Link,
Pencil,
RotateCcw,
ThumbsDown,
ThumbsUp,
} from "lucide-react"
import { useEffect, useRef, useState } from "react"
import ReactMarkdown from "react-markdown"
import { toast } from "sonner"
import {
Reasoning,
ReasoningContent,
ReasoningTrigger,
} from "@/components/ai-elements/reasoning"
import { Shimmer } from "@/components/ai-elements/shimmer"
import { TemplateCreateDialog } from "@/components/chat/TemplateCreateDialog"
import type { ToolPartLike } from "@/components/chat/types"
import { ValidationCard } from "@/components/chat/ValidationCard"
import Image from "@/components/image-with-basepath"
import { useDictionary } from "@/hooks/use-dictionary"
import { getApiEndpoint } from "@/lib/base-path"
import { copyText } from "@/lib/clipboard"
import { cn } from "@/lib/utils"
import { useUiStore } from "@/stores/ui-store"
import { useChatEngine } from "./chat-engine"
import {
getMessageTextContent,
getUserOriginalText,
splitTextIntoFileSections,
type TextSection,
} from "./message-text"
import { ToolActivity } from "./tool-activity"
function ActionButton({
label,
onClick,
active,
children,
}: {
label: string
onClick: () => void
active?: boolean
children: React.ReactNode
}) {
return (
<button
type="button"
onClick={onClick}
title={label}
aria-label={label}
className={cn(
"inline-flex size-7 items-center justify-center rounded-lg text-faint transition-colors hover:bg-accent hover:text-foreground [&_svg]:size-3.5",
active && "text-foreground",
)}
>
{children}
</button>
)
}
function AttachedSection({ section }: { section: TextSection }) {
const [open, setOpen] = useState(false)
const dict = useDictionary()
const Icon =
section.fileType === "pdf"
? FileText
: section.fileType === "url"
? Link
: FileCode
const chars =
section.charCount && section.charCount >= 1000
? `${(section.charCount / 1000).toFixed(1)}k`
: section.charCount
return (
<div className="overflow-hidden rounded-lg border border-border bg-card">
<button
type="button"
onClick={(e) => {
e.stopPropagation()
setOpen((v) => !v)
}}
className="flex w-full items-center gap-2 px-2.5 py-1.5 text-left hover:bg-accent"
>
<Icon className="size-4 shrink-0 text-muted-foreground" />
<span className="min-w-0 flex-1 truncate text-xs font-medium text-foreground">
{section.filename}
</span>
<span className="shrink-0 text-[11px] text-faint">
{chars} {dict.file.chars}
</span>
<ChevronDown
className={cn(
"size-3.5 shrink-0 text-muted-foreground transition-transform",
open && "rotate-180",
)}
/>
</button>
{open && (
<pre className="max-h-48 overflow-y-auto whitespace-pre-wrap border-t border-border px-2.5 py-2 text-xs text-muted-foreground scrollbar-thin">
{section.content}
</pre>
)}
</div>
)
}
function MessageText({ text, isUser }: { text: string; isUser: boolean }) {
const sections = splitTextIntoFileSections(text)
return (
<div className="space-y-2">
{sections.map((section, index) =>
section.type === "text" ? (
<div
key={index}
className={cn(
"prose prose-sm max-w-none break-words text-[14px] leading-relaxed text-foreground [&>*:first-child]:mt-0 [&>*:last-child]:mb-0",
"prose-p:my-1.5 prose-pre:bg-surface-2 prose-pre:text-foreground prose-code:before:content-none prose-code:after:content-none",
!isUser && "dark:prose-invert",
isUser && "dark:prose-invert",
)}
>
<ReactMarkdown>{section.content}</ReactMarkdown>
</div>
) : (
<AttachedSection key={index} section={section} />
),
)}
</div>
)
}
function UserMessage({
message,
index,
isLast,
}: {
message: UIMessage
index: number
isLast: boolean
}) {
const dict = useDictionary()
const engine = useChatEngine()
const [editing, setEditing] = useState(false)
const [editText, setEditText] = useState("")
const [copied, setCopied] = useState(false)
const [saveTemplate, setSaveTemplate] = useState(false)
const bumpTemplates = useUiStore((s) => s.bumpTemplates)
const textareaRef = useRef<HTMLTextAreaElement>(null)
const text = getMessageTextContent(message)
const canEdit = isLast && !engine.isBusy
useEffect(() => {
if (editing) textareaRef.current?.focus()
}, [editing])
const startEdit = () => {
if (!canEdit) return
setEditText(getUserOriginalText(message))
setEditing(true)
}
const submitEdit = () => {
if (!editText.trim()) return
engine.editMessage(index, editText.trim())
setEditing(false)
}
if (editing) {
return (
<div className="ml-6 flex flex-col gap-2">
<textarea
ref={textareaRef}
value={editText}
onChange={(e) => setEditText(e.target.value)}
rows={Math.min(editText.split("\n").length + 1, 6)}
onKeyDown={(e) => {
if (e.key === "Escape") setEditing(false)
else if (
e.key === "Enter" &&
(e.metaKey || e.ctrlKey)
) {
e.preventDefault()
submitEdit()
}
}}
className="w-full resize-none rounded-xl border border-foreground/20 bg-card px-3 py-2.5 text-sm outline-none focus:shadow-[0_0_0_4px_var(--accent)]"
/>
<div className="flex justify-end gap-2">
<button
type="button"
onClick={() => setEditing(false)}
className="h-7 rounded-lg px-3 text-xs text-muted-foreground hover:bg-accent hover:text-foreground"
>
{dict.common.cancel}
</button>
<button
type="button"
onClick={submitEdit}
disabled={!editText.trim()}
className="h-7 rounded-lg bg-primary px-3 text-xs font-medium text-primary-foreground disabled:opacity-40"
>
{dict.chat.saveAndSubmit}
</button>
</div>
</div>
)
}
const images = (message.parts ?? []).filter((p) => p.type === "file") as {
url: string
}[]
return (
<div className="group/message flex items-start justify-end gap-1">
<div className="mt-1 flex shrink-0 items-center transition-opacity [@media(hover:hover)]:opacity-0 group-hover/message:opacity-100 focus-within:opacity-100">
{canEdit && (
<ActionButton
label={dict.chat.editMessage}
onClick={startEdit}
>
<Pencil />
</ActionButton>
)}
<ActionButton
label={copied ? dict.chat.copied : dict.chat.copyResponse}
onClick={async () => {
const ok = await copyText(text)
if (!ok) toast.error(dict.chat.failedToCopyDetail)
setCopied(ok)
setTimeout(() => setCopied(false), 1500)
}}
>
{copied ? <Check /> : <Copy />}
</ActionButton>
<ActionButton
label={dict.templates.saveAsTemplate}
onClick={() => setSaveTemplate(true)}
>
<BookmarkPlus />
</ActionButton>
</div>
<div
className={cn(
"min-w-0 max-w-[85%] rounded-2xl rounded-br-md bg-secondary px-3.5 py-2.5",
canEdit &&
"cursor-pointer transition-colors hover:bg-interactive-active",
)}
role={canEdit ? "button" : undefined}
tabIndex={canEdit ? 0 : undefined}
onClick={startEdit}
onKeyDown={(e) => {
if (canEdit && (e.key === "Enter" || e.key === " ")) {
e.preventDefault()
startEdit()
}
}}
title={canEdit ? dict.chat.clickToEdit : undefined}
>
{images.length > 0 && (
<div className="mb-2 flex flex-wrap gap-1.5">
{images.map((image, i) => (
<Image
key={i}
src={image.url}
width={96}
height={72}
alt={dict.chat.attachedImage}
className="h-[72px] w-24 rounded-lg border border-border bg-white object-cover"
/>
))}
</div>
)}
{text && <MessageText text={text} isUser />}
</div>
{saveTemplate && (
<TemplateCreateDialog
open
onOpenChange={(open) => !open && setSaveTemplate(false)}
onSuccess={() => {
setSaveTemplate(false)
bumpTemplates()
}}
initialPrompt={getUserOriginalText(message)}
/>
)}
</div>
)
}
function AssistantMessage({
message,
index,
isLastMessage,
isLastAssistant,
}: {
message: UIMessage
index: number
isLastMessage: boolean
isLastAssistant: boolean
}) {
const dict = useDictionary()
// The thinking header in the page language
const thinkingMessage = (isStreaming: boolean, duration?: number) => {
if (isStreaming || duration === 0) {
return <Shimmer duration={1}>{dict.reasoning.thinking}</Shimmer>
}
if (duration === undefined) return <p>{dict.reasoning.thoughtBrief}</p>
return (
<p>
{duration === 1
? dict.reasoning.thoughtForOne
: dict.reasoning.thoughtFor.replace(
"{duration}",
String(duration),
)}
</p>
)
}
const engine = useChatEngine()
const [copied, setCopied] = useState(false)
const [feedback, setFeedback] = useState<"good" | "bad" | null>(null)
const isRestored = engine.loadedMessageIdsRef.current.has(message.id)
const parts = message.parts ?? []
const isCachedExample = parts.some((p: any) =>
p.toolCallId?.startsWith("cached-"),
)
const text = getMessageTextContent(message)
const submitFeedback = async (value: "good" | "bad") => {
if (feedback === value) {
setFeedback(null)
return
}
setFeedback(value)
try {
await fetch(getApiEndpoint("/api/log-feedback"), {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
messageId: message.id,
feedback: value,
sessionId: engine.langfuseSessionId,
}),
})
} catch (error) {
console.error("Failed to log feedback:", error)
toast.error(dict.errors.failedToRecordFeedback)
setFeedback(null)
}
}
// Consecutive text parts render together; tools render in place
const blocks: { kind: "text" | "tool" | "reasoning"; index: number }[] = []
parts.forEach((part, partIndex) => {
if (part.type === "reasoning")
blocks.push({ kind: "reasoning", index: partIndex })
else if (part.type?.startsWith("tool-"))
blocks.push({ kind: "tool", index: partIndex })
else if (part.type === "text") {
const last = blocks[blocks.length - 1]
if (last?.kind !== "text")
blocks.push({ kind: "text", index: partIndex })
}
})
const textRun = (start: number) => {
const out: string[] = []
for (let i = start; i < parts.length && parts[i].type === "text"; i++) {
out.push((parts[i] as { text: string }).text)
}
return out.join("\n")
}
return (
<div className="group/message space-y-2.5">
{blocks.map((block) => {
const part = parts[block.index]
if (block.kind === "reasoning") {
const isStreamingReasoning =
engine.status === "streaming" &&
isLastMessage &&
block.index === parts.length - 1
return (
<Reasoning
key={`r-${block.index}`}
className="w-full"
isStreaming={isStreamingReasoning}
defaultOpen={!isRestored}
>
<ReasoningTrigger
getThinkingMessage={thinkingMessage}
/>
<ReasoningContent>
{(part as { text: string }).text}
</ReasoningContent>
</Reasoning>
)
}
if (block.kind === "tool") {
const toolPart = part as unknown as ToolPartLike
const validationState =
engine.validationStates[toolPart.toolCallId]
return (
<div key={`t-${block.index}`} className="space-y-2">
<ToolActivity
part={toolPart}
isLastMessage={isLastMessage}
/>
{toolPart.type === "tool-display_diagram" &&
validationState && (
<ValidationCard
state={validationState}
onImproveWithSuggestions={
engine.improveWithSuggestions
}
/>
)}
</div>
)
}
const run = textRun(block.index)
if (!run.trim()) return null
return (
<MessageText
key={`x-${block.index}`}
text={run}
isUser={false}
/>
)
})}
{!(engine.isBusy && isLastMessage) && (
<div className="-ml-1.5 flex items-center transition-opacity [@media(hover:hover)]:opacity-0 group-hover/message:opacity-100 focus-within:opacity-100">
{text && (
<ActionButton
label={
copied
? dict.chat.copied
: dict.chat.copyResponse
}
onClick={async () => {
const ok = await copyText(text)
if (!ok)
toast.error(dict.chat.failedToCopyDetail)
setCopied(ok)
setTimeout(() => setCopied(false), 1500)
}}
>
{copied ? <Check /> : <Copy />}
</ActionButton>
)}
{isLastAssistant && !isCachedExample && (
<ActionButton
label={dict.chat.regenerate}
onClick={() => engine.regenerate(index)}
>
<RotateCcw />
</ActionButton>
)}
<ActionButton
label={dict.chat.goodResponse}
active={feedback === "good"}
onClick={() => submitFeedback("good")}
>
<ThumbsUp
className={cn(
feedback === "good" && "fill-current",
)}
/>
</ActionButton>
<ActionButton
label={dict.chat.badResponse}
active={feedback === "bad"}
onClick={() => submitFeedback("bad")}
>
<ThumbsDown
className={cn(feedback === "bad" && "fill-current")}
/>
</ActionButton>
</div>
)}
</div>
)
}
/**
* A failed request: what to do in the text color, the provider's own words
* below it, and the action that can fix it first
*/
function ErrorNotice({
text,
openModelConfig,
onOpenModelConfig,
onRetry,
}: {
text: string
openModelConfig: boolean
onOpenModelConfig: () => void
onRetry?: () => void
}) {
const dict = useDictionary()
// Provider errors are "hint\n\nprovider message"
const split = text.indexOf("\n\n")
const headline = split >= 0 ? text.slice(0, split) : text
const detail = split >= 0 ? text.slice(split + 2).trim() : ""
const button =
"inline-flex h-7 items-center gap-1 rounded-lg px-2.5 text-xs font-medium"
return (
<div
role="alert"
className="rounded-xl border border-destructive/25 bg-destructive/5 px-3.5 py-3 text-[13px]"
>
<div className="flex items-start gap-2.5">
<AlertCircle className="mt-0.5 size-4 shrink-0 text-destructive" />
<div className="min-w-0 flex-1">
<p className="whitespace-pre-line break-words font-medium text-foreground">
{headline}
</p>
{detail && (
<p className="mt-1 whitespace-pre-line break-words text-xs text-muted-foreground">
{detail}
</p>
)}
{(openModelConfig || onRetry) && (
<div className="mt-2.5 flex flex-wrap gap-2">
{openModelConfig && (
<button
type="button"
onClick={onOpenModelConfig}
className={cn(
button,
"bg-primary text-primary-foreground hover:opacity-90",
)}
>
{dict.errors.llm.openModelSettings}
</button>
)}
{onRetry && (
<button
type="button"
onClick={onRetry}
className={cn(
button,
"border border-border bg-card text-foreground hover:bg-accent",
)}
data-testid="retry-button"
>
<RotateCcw className="size-3.5" />
{dict.chat.retry}
</button>
)}
</div>
)}
</div>
</div>
</div>
)
}
export function MessageList() {
const dict = useDictionary()
const openSettings = useUiStore((s) => s.openSettings)
const engine = useChatEngine()
const { messages } = engine
const endRef = useRef<HTMLDivElement>(null)
const prevCountRef = useRef(0)
const throttleRef = useRef<ReturnType<typeof setTimeout> | null>(null)
// Keep the newest message in view; jump instantly on bulk loads
useEffect(() => {
if (!endRef.current || messages.length === 0) return
const prev = prevCountRef.current
prevCountRef.current = messages.length
if (prev === 0 || messages.length - prev > 1) {
endRef.current.scrollIntoView({ behavior: "instant", block: "end" })
return
}
if (!throttleRef.current) {
endRef.current.scrollIntoView({ behavior: "smooth", block: "end" })
throttleRef.current = setTimeout(() => {
throttleRef.current = null
endRef.current?.scrollIntoView({
behavior: "smooth",
block: "end",
})
}, 150)
}
}, [messages])
const lastAssistantIndex = messages
.map((m) => m.role)
.lastIndexOf("assistant")
const lastUserIndex = messages.map((m) => m.role).lastIndexOf("user")
return (
<div className="space-y-4 px-4 pt-4 pb-2">
{messages.map((message, index) => {
const isRestored = engine.loadedMessageIdsRef.current.has(
message.id,
)
return (
<div
key={message.id}
className={cn(!isRestored && "animate-message-in")}
>
{message.role === "user" ? (
<UserMessage
message={message}
index={index}
isLast={index === lastUserIndex}
/>
) : message.role === "assistant" ? (
<AssistantMessage
message={message}
index={index}
isLastMessage={index === messages.length - 1}
isLastAssistant={index === lastAssistantIndex}
/>
) : (
<ErrorNotice
text={getMessageTextContent(message)}
openModelConfig={
!!(
message.metadata as
| { openModelConfig?: boolean }
| undefined
)?.openModelConfig
}
onOpenModelConfig={() => openSettings("models")}
onRetry={
index === messages.length - 1 &&
!engine.isBusy &&
lastUserIndex >= 0
? engine.retryLastMessage
: undefined
}
/>
)}
</div>
)
})}
{engine.status === "submitted" &&
messages[messages.length - 1]?.role === "user" && (
<div className="px-1 text-[13px]" aria-live="polite">
<Shimmer as="span">{dict.reasoning.thinking}</Shimmer>
</div>
)}
<div ref={endRef} />
</div>
)
}
-64
View File
@@ -1,64 +0,0 @@
import type { UIMessage } from "ai"
/** A run of plain text, or an attached file / URL whose text was inlined */
export interface TextSection {
type: "text" | "file" | "url"
content: string
filename?: string
charCount?: number
fileType?: "pdf" | "text" | "url"
}
// Matches the [PDF: ...], [File: ...] and [URL: ...] sections appended to the user's text
export const APPENDED_FILE_SECTIONS_PATTERN =
/\n\n\[(PDF|File|URL):\s*[^\]]+\]\n[\s\S]*$/
export function splitTextIntoFileSections(text: string): TextSection[] {
const sections: TextSection[] = []
const filePattern =
/\[(PDF|File|URL):\s*([^\]]+)\]\n([\s\S]*?)(?=\n\n\[(PDF|File|URL):|$)/g
let lastIndex = 0
let match: RegExpExecArray | null
while ((match = filePattern.exec(text)) !== null) {
const beforeText = text.slice(lastIndex, match.index).trim()
if (beforeText) sections.push({ type: "text", content: beforeText })
const sectionType = match[1].toLowerCase()
const fileType =
sectionType === "pdf"
? "pdf"
: sectionType === "url"
? "url"
: "text"
const content = match[3].trim()
sections.push({
type: sectionType === "url" ? "url" : "file",
content,
filename: match[2].trim(),
charCount: content.length,
fileType,
})
lastIndex = match.index + match[0].length
}
const remainingText = text.slice(lastIndex).trim()
if (remainingText) sections.push({ type: "text", content: remainingText })
if (sections.length === 0) sections.push({ type: "text", content: text })
return sections
}
export function getMessageTextContent(message: UIMessage): string {
if (!message.parts) return ""
return message.parts
.filter((part) => part.type === "text")
.map((part) => (part as { text: string }).text)
.join("\n")
}
/** Only the user's typed text, without appended file content */
export function getUserOriginalText(message: UIMessage): string {
return getMessageTextContent(message)
.replace(APPENDED_FILE_SECTIONS_PATTERN, "")
.trim()
}
-292
View File
@@ -1,292 +0,0 @@
import { FileUp, Pencil, Plus, Search, Trash2 } from "lucide-react"
import { useParams } from "next/navigation"
import { useEffect, useRef, useState } from "react"
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from "@/components/ui/alert-dialog"
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@/components/ui/popover"
import { useDictionary } from "@/hooks/use-dictionary"
import { formatRelativeTime } from "@/lib/relative-time"
import { cn } from "@/lib/utils"
import { useUiStore } from "@/stores/ui-store"
import { useChatEngine } from "./chat-engine"
/** Title shown for a session, with the default "New Chat" localized */
export function useSessionTitle(): string {
const dict = useDictionary()
const { currentTitle } = useChatEngine()
return !currentTitle || currentTitle === "New Chat"
? dict.workspace.untitled
: currentTitle
}
/**
* Popover listing saved diagrams: rename the current one, switch, delete,
* start a new one or open a file.
*/
export function SessionMenu({
children,
align = "start",
onOpenFile,
}: {
children: React.ReactNode
align?: "start" | "center" | "end"
onOpenFile: () => void
}) {
const dict = useDictionary()
const t = dict.sessionHistory
const params = useParams<{ lang: string }>()
const engine = useChatEngine()
const open = useUiStore((s) => s.sessionsOpen)
const setOpen = useUiStore((s) => s.setSessionsOpen)
const title = useSessionTitle()
const [query, setQuery] = useState("")
const [renaming, setRenaming] = useState(false)
const [draftTitle, setDraftTitle] = useState("")
const [toDelete, setToDelete] = useState<string | null>(null)
const renameRef = useRef<HTMLInputElement>(null)
useEffect(() => {
if (!open) {
setRenaming(false)
setQuery("")
}
}, [open])
useEffect(() => {
if (renaming) renameRef.current?.select()
}, [renaming])
const sessions = engine.sessions.filter((s) =>
s.title.toLowerCase().includes(query.trim().toLowerCase()),
)
const commitRename = async () => {
const next = draftTitle.trim()
setRenaming(false)
if (next && next !== title) await engine.renameSession(next)
}
return (
<>
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>{children}</PopoverTrigger>
<PopoverContent
align={align}
className="flex max-h-[min(560px,80vh)] w-[340px] flex-col overflow-hidden rounded-xl p-0 shadow-pop"
>
<div className="border-b border-border p-3">
{renaming ? (
<input
ref={renameRef}
value={draftTitle}
onChange={(e) => setDraftTitle(e.target.value)}
onBlur={commitRename}
onKeyDown={(e) => {
// Enter that picks an IME candidate
if (e.nativeEvent.isComposing) return
if (e.key === "Enter") commitRename()
if (e.key === "Escape") setRenaming(false)
}}
maxLength={100}
aria-label={t.rename}
className="h-8 w-full rounded-lg border border-foreground/20 bg-card px-2.5 text-[13px] font-medium outline-none"
/>
) : (
<button
type="button"
onClick={() => {
setDraftTitle(title)
setRenaming(true)
}}
className="group/rename flex h-8 w-full items-center gap-2 rounded-lg px-2.5 text-left hover:bg-accent"
data-testid="rename-session"
>
<span className="min-w-0 flex-1 truncate text-[13px] font-medium">
{title}
</span>
<Pencil className="size-3.5 shrink-0 text-muted-foreground" />
</button>
)}
<div className="mt-2 flex gap-1.5">
<button
type="button"
disabled={engine.isBusy}
onClick={() => {
setOpen(false)
engine.newChat()
}}
className="flex h-8 flex-1 items-center justify-center gap-1.5 rounded-lg bg-primary text-xs font-medium text-primary-foreground hover:opacity-90 disabled:opacity-40"
>
<Plus className="size-3.5" />
{dict.workspace.newDiagram}
</button>
<button
type="button"
disabled={engine.isBusy}
onClick={() => {
setOpen(false)
onOpenFile()
}}
className="flex h-8 flex-1 items-center justify-center gap-1.5 rounded-lg border border-border text-xs text-muted-foreground hover:text-foreground disabled:opacity-40"
>
<FileUp className="size-3.5" />
{dict.workspace.openFile}
</button>
</div>
</div>
{engine.sessions.length > 0 && (
<div className="px-3 pt-3">
<div className="relative">
<Search className="pointer-events-none absolute top-1/2 left-2.5 size-3.5 -translate-y-1/2 text-faint" />
<input
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder={t.searchPlaceholder}
className="h-8 w-full rounded-lg bg-muted pr-2.5 pl-8 text-[13px] outline-none placeholder:text-faint"
/>
</div>
</div>
)}
<div className="min-h-0 flex-1 overflow-y-auto p-1.5 scrollbar-thin">
{engine.sessions.length === 0 ? (
<p className="px-3 py-6 text-center text-xs text-muted-foreground">
{t.empty}
</p>
) : sessions.length === 0 ? (
<p className="px-3 py-6 text-center text-xs text-muted-foreground">
{t.noResults}
</p>
) : (
sessions.map((session) => {
const isCurrent =
session.id === engine.currentSessionId
// The answer being written must stay in its
// own chat
const locked = engine.isBusy && !isCurrent
return (
// biome-ignore lint/a11y/useSemanticElements: contains the delete button
<div
key={session.id}
role="button"
tabIndex={locked ? -1 : 0}
aria-disabled={locked || undefined}
title={locked ? t.busyHint : undefined}
onClick={() => {
if (locked) return
setOpen(false)
if (!isCurrent)
engine.selectSession(session.id)
}}
onKeyDown={(e) => {
if (e.target !== e.currentTarget)
return
if (
e.key === "Enter" ||
e.key === " "
) {
e.preventDefault()
setOpen(false)
if (!isCurrent)
engine.selectSession(
session.id,
)
}
}}
className={cn(
"group/session flex cursor-pointer items-center gap-2.5 rounded-lg p-1.5 hover:bg-accent",
isCurrent && "bg-accent",
locked &&
"cursor-not-allowed opacity-50 hover:bg-transparent",
)}
>
<span className="sheet-light flex h-9 w-12 shrink-0 items-center justify-center overflow-hidden rounded-md border border-border bg-white">
{session.thumbnailDataUrl && (
// biome-ignore lint/performance/noImgElement: data URL thumbnail
<img
src={
session.thumbnailDataUrl
}
alt=""
className="max-h-8 max-w-11 object-contain"
/>
)}
</span>
<span className="min-w-0 flex-1">
<span className="block truncate text-[13px] text-foreground">
{session.title === "New Chat"
? dict.workspace.untitled
: session.title}
</span>
<span className="block text-[11px] text-faint">
{formatRelativeTime(
session.updatedAt,
params.lang,
t.justNow,
)}
</span>
</span>
<button
type="button"
disabled={
isCurrent && engine.isBusy
}
onClick={(e) => {
e.stopPropagation()
setToDelete(session.id)
}}
aria-label={t.deleteTitle}
className="inline-flex size-7 shrink-0 items-center justify-center rounded-md text-faint [@media(hover:hover)]:opacity-0 hover:bg-destructive/10 hover:text-destructive group-hover/session:opacity-100 focus-visible:opacity-100"
>
<Trash2 className="size-3.5" />
</button>
</div>
)
})
)}
</div>
</PopoverContent>
</Popover>
<AlertDialog
open={!!toDelete}
onOpenChange={(o) => !o && setToDelete(null)}
>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>{t.deleteTitle}</AlertDialogTitle>
<AlertDialogDescription>
{t.deleteDescription}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>
{dict.common.cancel}
</AlertDialogCancel>
<AlertDialogAction
className="bg-destructive text-white hover:bg-destructive/90"
onClick={() => {
if (toDelete) engine.deleteSession(toDelete)
setToDelete(null)
}}
>
{dict.common.delete}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</>
)
}
-490
View File
@@ -1,490 +0,0 @@
import {
AlertCircle,
Check,
ChevronDown,
Code2,
Copy,
Library,
PenTool,
Redo2,
RotateCcw,
Undo2,
} from "lucide-react"
import { useState } from "react"
import { Shimmer } from "@/components/ai-elements/shimmer"
import { CodeBlock } from "@/components/code-block"
import { useDictionary } from "@/hooks/use-dictionary"
import { copyText } from "@/lib/clipboard"
import { formatMessage } from "@/lib/i18n/utils"
import { cn, isMxCellXmlComplete } from "@/lib/utils"
import { describeChanges } from "@/lib/version-text"
import { useUiStore } from "@/stores/ui-store"
import { type DiagramVersion, useVersionsStore } from "@/stores/versions-store"
import { useChatEngine } from "./chat-engine"
import type { DiagramOperation, ToolPartLike } from "./types"
export function ToolInputDetails({ part }: { part: ToolPartLike }) {
const { input, state } = part
if (!input || typeof input !== "object") return null
if (typeof input.xml === "string" && input.xml) {
// Plain text while streaming; highlighting re-runs on every chunk
return state === "input-streaming" || state === "input-available" ? (
<pre className="max-h-48 overflow-auto whitespace-pre-wrap break-all font-mono text-[11px] leading-relaxed text-muted-foreground scrollbar-thin">
{input.xml}
</pre>
) : (
<CodeBlock code={input.xml} language="xml" />
)
}
if (Array.isArray(input.operations)) {
// Streamed or invalid input can hold anything: show only what React
// can render (an object in place of a string would crash the chat)
const shown = (input.operations as DiagramOperation[]).filter(
(op) =>
typeof (op as { operation?: unknown })?.operation === "string",
)
const text = (value: unknown) =>
typeof value === "string" ? value : ""
return (
<div className="space-y-2">
{shown.map((op, index) => (
<div
key={`${op.operation}-${text(op.cell_id)}-${index}`}
className="overflow-hidden rounded-lg border border-border bg-card"
>
<div className="flex items-center gap-2 border-b border-border px-2.5 py-1 text-[11px]">
<span className="font-medium text-foreground">
{op.operation}
</span>
<span className="text-muted-foreground">
{text(op.cell_id)}
</span>
</div>
{text(op.new_xml) && (
<pre className="overflow-x-auto whitespace-pre-wrap break-all px-2.5 py-1.5 font-mono text-[11px] text-muted-foreground">
{text(op.new_xml)}
</pre>
)}
</div>
))}
</div>
)
}
if (Object.keys(input).length === 0) return null
return <CodeBlock code={JSON.stringify(input, null, 2)} language="json" />
}
function toolText(part: ToolPartLike): string {
const { input } = part
if (!input || typeof input !== "object") return ""
if (typeof input.xml === "string" && input.xml) return input.xml
if (Array.isArray(input.operations)) {
return JSON.stringify(input.operations, null, 2)
}
return JSON.stringify(input, null, 2)
}
/** Thumbnail of a version, or what stands in for it */
function VersionThumb({
version,
dimmed,
className,
}: {
version: DiagramVersion
dimmed?: boolean
className?: string
}) {
const t = useDictionary().versions
if (version.svg) {
return (
// biome-ignore lint/performance/noImgElement: data URL thumbnail
<img
src={version.svg}
alt=""
className={cn(
"object-contain transition-opacity",
dimmed && "opacity-40",
className,
)}
/>
)
}
if (version.svg === "") {
return <span className="text-xs text-faint">{t.noPreview}</span>
}
return (
<Shimmer as="span" className="text-xs">
{t.rendering}
</Shimmer>
)
}
/**
* Card for one diagram version produced by a tool call. The latest shows
* its picture; older ones fold into one row (the picture opens Compare).
*/
function VersionCard({
part,
version,
index,
isLatest,
}: {
part: ToolPartLike
version: DiagramVersion
index: number
isLatest: boolean
}) {
const dict = useDictionary()
const t = dict.versions
const engine = useChatEngine()
const undoneVersionId = useVersionsStore((s) => s.undoneVersionId)
const onCanvasVersionId = useVersionsStore((s) => s.onCanvasVersionId)
const openCompare = useUiStore((s) => s.openCompare)
const [showCode, setShowCode] = useState(false)
const [copied, setCopied] = useState(false)
const isUndone = undoneVersionId === version.id
const isOnCanvas = onCanvasVersionId === version.id
const isEdit = part.type === "tool-edit_diagram"
// Undo/redo belongs to the newest change while it is (or was) on the
// canvas; after an older version was restored, this one is restored too
const canUndo =
isLatest && version.beforeXml !== undefined && (isOnCanvas || isUndone)
const title = isUndone ? t.undone : isEdit ? t.edited : t.created
const summary = version.fromScratch
? formatMessage(t.totals, {
shapes: version.summary.shapesAdded,
edges: version.summary.edgesAdded,
})
: describeChanges(version.summary, t)
const badge = (
<span
className={cn(
"shrink-0 rounded-md px-1.5 py-0.5 text-[11px] font-semibold tabular-nums",
isOnCanvas
? "bg-marker-soft text-marker-ink"
: "bg-muted text-muted-foreground",
)}
>
v{index + 1}
</span>
)
const actionClass =
"inline-flex h-7 shrink-0 items-center gap-1 rounded-lg px-2 text-xs text-muted-foreground hover:bg-accent hover:text-foreground disabled:opacity-40"
const restoreButton = (
<button
type="button"
disabled={engine.isBusy || isOnCanvas}
onClick={() => engine.restoreVersion(version.id)}
className={actionClass}
data-testid="version-restore"
>
<RotateCcw className="size-3.5" />
{formatMessage(t.restoreVersion, { n: index + 1 })}
</button>
)
if (!isLatest) {
return (
<div
className="flex items-center gap-2.5 rounded-xl border border-border bg-card p-1.5 pr-2"
data-testid="version-card"
data-tool-state={part.state}
>
<button
type="button"
onClick={() => openCompare(version.id)}
className="sheet-light flex h-9 w-14 shrink-0 items-center justify-center overflow-hidden rounded-md border border-border bg-white hover:border-foreground/30"
aria-label={t.compare}
title={t.compare}
data-testid="version-thumb-small"
>
<VersionThumb
version={version}
className="max-h-8 max-w-[52px]"
/>
</button>
{badge}
<div className="min-w-0 flex-1" title={summary}>
<div className="truncate text-[13px] text-foreground">
{title}
</div>
<div className="truncate text-xs text-muted-foreground">
{summary}
</div>
</div>
{restoreButton}
</div>
)
}
return (
<div
className={cn(
"overflow-hidden rounded-xl border border-border bg-card",
isOnCanvas && "border-foreground/15",
)}
data-testid="version-card"
data-tool-state={part.state}
>
<button
type="button"
onClick={() => openCompare(version.id)}
className="group/thumb sheet-light relative flex h-24 w-full items-center justify-center border-b border-border bg-white"
aria-label={t.compare}
>
<VersionThumb
version={version}
dimmed={isUndone}
className="max-h-20 max-w-[88%]"
/>
<span className="absolute right-2 bottom-2 rounded-md bg-black/60 px-1.5 py-0.5 text-[11px] text-white opacity-0 transition-opacity group-hover/thumb:opacity-100">
{t.compare}
</span>
</button>
<div className="flex items-center gap-2.5 px-3 py-2.5">
{badge}
<div className="min-w-0 flex-1" title={summary}>
<div className="truncate text-[13px] font-medium text-foreground">
{title}
</div>
<div className="line-clamp-2 text-xs text-muted-foreground">
{summary}
</div>
</div>
<button
type="button"
onClick={() => setShowCode((v) => !v)}
aria-label={showCode ? t.hideCode : t.showCode}
title={showCode ? t.hideCode : t.showCode}
className="inline-flex size-7 shrink-0 items-center justify-center rounded-lg text-muted-foreground hover:bg-accent hover:text-foreground"
>
<Code2 className="size-4" />
</button>
{canUndo ? (
<button
type="button"
disabled={engine.isBusy}
onClick={() => engine.undoVersion(version.id)}
className={actionClass}
data-testid="version-undo"
>
{isUndone ? (
<Redo2 className="size-3.5" />
) : (
<Undo2 className="size-3.5" />
)}
{isUndone ? t.redo : t.undo}
</button>
) : (
restoreButton
)}
</div>
{showCode && (
<div className="relative border-t border-border bg-surface-1 px-3 py-2.5">
<button
type="button"
onClick={async () => {
setCopied(await copyText(toolText(part)))
setTimeout(() => setCopied(false), 1500)
}}
className="absolute top-2 right-2 z-10 inline-flex size-7 items-center justify-center rounded-lg bg-card text-muted-foreground shadow-float hover:text-foreground"
aria-label={dict.chat.copyResponse}
>
{copied ? (
<Check className="size-3.5" />
) : (
<Copy className="size-3.5" />
)}
</button>
<ToolInputDetails part={part} />
</div>
)}
</div>
)
}
/** Compact row for tool calls that are running, failed or produced no version */
function ToolRow({
part,
icon,
label,
tone = "default",
detail,
}: {
part: ToolPartLike
icon: React.ReactNode
label: React.ReactNode
tone?: "default" | "error" | "warning"
detail?: string
}) {
const [open, setOpen] = useState(false)
const hasInput = !!part.input && Object.keys(part.input).length > 0
return (
<div
className={cn(
"overflow-hidden rounded-xl border border-border bg-card",
tone === "error" && "border-destructive/25",
)}
data-testid="tool-row"
data-tool-name={part.type?.replace("tool-", "")}
data-tool-state={part.state}
>
<button
type="button"
onClick={() => setOpen((v) => !v)}
disabled={!hasInput && !detail}
className="flex w-full items-center gap-2.5 px-3 py-2.5 text-left"
>
<span
className={cn(
"inline-flex size-7 shrink-0 items-center justify-center rounded-lg bg-muted text-muted-foreground [&_svg]:size-4",
tone === "error" &&
"bg-destructive/10 text-destructive",
tone === "warning" && "bg-marker-soft text-marker-ink",
)}
>
{icon}
</span>
<span className="min-w-0 flex-1 truncate text-[13px] text-foreground">
{label}
</span>
{(hasInput || detail) && (
<ChevronDown
className={cn(
"size-4 shrink-0 text-muted-foreground transition-transform",
open && "rotate-180",
)}
/>
)}
</button>
{open && (
<div className="space-y-2 border-t border-border bg-surface-1 px-3 py-2.5">
{detail && (
<p
className={cn(
"whitespace-pre-wrap break-words text-xs",
tone === "error"
? "text-destructive"
: "text-muted-foreground",
)}
>
{detail}
</p>
)}
{hasInput && <ToolInputDetails part={part} />}
</div>
)}
</div>
)
}
export function ToolActivity({
part,
isLastMessage,
}: {
part: ToolPartLike
isLastMessage: boolean
}) {
const dict = useDictionary()
const t = dict.versions
const engine = useChatEngine()
const versions = useVersionsStore((s) => s.versions)
const toolName = part.type?.replace("tool-", "")
const { state, errorText, output } = part
const versionIndex = versions.findIndex(
(v) => v.toolCallId === part.toolCallId,
)
if (state === "output-available" && versionIndex >= 0) {
return (
<VersionCard
part={part}
version={versions[versionIndex]}
index={versionIndex}
isLatest={versionIndex === versions.length - 1}
/>
)
}
if (toolName === "get_shape_library" && state !== "output-error") {
const isDone = state === "output-available"
return (
<ToolRow
part={part}
icon={<Library />}
label={
isDone ? (
t.libraryLoaded
) : (
<Shimmer as="span">{t.loadingLibrary}</Shimmer>
)
}
detail={
isDone && typeof output === "string"
? `${output.slice(0, 600)}${output.length > 600 ? "…" : ""}`
: undefined
}
/>
)
}
if (state === "input-streaming" || state === "input-available") {
return (
<ToolRow
part={part}
icon={<PenTool />}
label={
<Shimmer as="span">
{toolName === "edit_diagram" ? t.editing : t.drawing}
</Shimmer>
}
/>
)
}
if (state === "output-error") {
const stopped = errorText === "Stopped by user"
// Incomplete XML means the output hit the length limit. Without an
// input the JSON was broken (the server repairs JSON cut short by
// the limit).
const truncated =
!stopped &&
(toolName === "display_diagram" || toolName === "append_diagram") &&
typeof part.input?.xml === "string" &&
!isMxCellXmlComplete(part.input.xml)
return (
<ToolRow
part={part}
icon={<AlertCircle />}
tone={stopped ? "default" : truncated ? "warning" : "error"}
label={
stopped
? t.stopped
: truncated
? dict.tools.truncated
: isLastMessage && engine.isBusy
? t.failedRetrying
: t.failed
}
detail={
stopped
? undefined
: truncated
? t.truncatedHint
: (errorText ??
(typeof output === "string" ? output : ""))
}
/>
)
}
// Finished without a version (sessions saved before versions existed)
return (
<ToolRow
part={part}
icon={<PenTool />}
label={toolName === "edit_diagram" ? t.edited : t.created}
/>
)
}
-223
View File
@@ -1,223 +0,0 @@
"use client"
import type { UIMessage } from "ai"
import type { RefObject } from "react"
import { useCallback, useEffect, useRef } from "react"
import type { DiagramOperation, ToolPartLike } from "@/components/chat/types"
import { useDiagram } from "@/contexts/diagram-context"
import {
convertToLegalXml,
extractCompleteMxCells,
replaceNodes,
} from "@/lib/utils"
import { applyDiagramOperations } from "@/packages/mcp-server/src/diagram-operations.ts"
import { BLANK_MXFILE } from "@/packages/mcp-server/src/pages.ts"
// Helper to extract complete operations from streaming input
function getCompleteOperations(
operations: DiagramOperation[] | undefined,
): DiagramOperation[] {
if (!operations || !Array.isArray(operations)) return []
return operations.filter(
(op) =>
op &&
typeof op.operation === "string" &&
["update", "add", "delete"].includes(op.operation) &&
typeof op.cell_id === "string" &&
op.cell_id.length > 0 &&
(op.operation === "delete" || typeof op.new_xml === "string"),
)
}
interface UseDiagramStreamingParams {
messages: UIMessage[]
processedToolCallsRef: RefObject<Set<string>>
editDiagramOriginalXmlRef: RefObject<Map<string, string>>
loadedMessageIdsRef: RefObject<Set<string>>
}
/**
* Draws AI tool output onto the canvas while it streams, as a preview that
* is not recorded in undo history.
*
* display_diagram: the complete mxCells written so far. edit_diagram: the
* complete operations applied to the diagram from before the call. The tool
* handler validates and commits the final result. Runs in the chat engine
* so streaming continues while the chat panel is collapsed.
*/
export function useDiagramStreaming({
messages,
processedToolCallsRef,
editDiagramOriginalXmlRef,
loadedMessageIdsRef,
}: UseDiagramStreamingParams) {
const { chartXML, chartXMLRef, loadDiagram } = useDiagram()
const previousXML = useRef<string>("")
// Last processed XML per toolCallId, to skip redundant work while streaming
const lastProcessedXmlRef = useRef<Map<string, string>>(new Map())
// Reset when messages become empty (new chat or session switch), so
// cached examples work again after starting a new session
useEffect(() => {
if (messages.length === 0) {
previousXML.current = ""
lastProcessedXmlRef.current.clear()
processedToolCallsRef.current.clear()
editDiagramOriginalXmlRef.current.clear()
}
}, [messages.length, processedToolCallsRef, editDiagramOriginalXmlRef])
// Streaming preview of display_diagram: draw the complete cells written
// so far. The tool handler validates and loads the final diagram.
const handleDisplayChart = useCallback(
(xml: string) => {
const completeCells = extractCompleteMxCells(xml || "")
if (!completeCells) return
const convertedXml = convertToLegalXml(completeCells)
if (convertedXml === previousXML.current) return
// Skip this update while the cells written so far don't parse
const testDoc = new DOMParser().parseFromString(
`<root>${convertedXml}</root>`,
"text/xml",
)
if (testDoc.querySelector("parsererror")) return
try {
// Replace the first page's cells so other pages stay intact.
// An empty canvas gets a default mxfile to put the cells in.
const baseXML = chartXML || BLANK_MXFILE
const replacedXML = replaceNodes(baseXML, convertedXml)
previousXML.current = convertedXml
loadDiagram(replacedXML, true, "preview")
} catch (error) {
console.error("Error processing XML:", error)
}
},
[chartXML, loadDiagram],
)
useEffect(() => {
// Only the last message can still be streaming
const message = messages[messages.length - 1]
if (!message?.parts) return
// Messages restored from a saved session were applied before it was
// saved; the saved diagram is authoritative, so don't replay them
if (loadedMessageIdsRef.current.has(message.id)) return
// The diagram without streamed previews, as loaded last: the tool
// handler's result of an earlier edit is there before the chartXML
// state catches up. Undoing a failed edit's preview below changes it
// too, and an edit streaming right after must start from the undone
// diagram.
let baseXml = chartXMLRef.current
for (const part of message.parts) {
if (
part.type !== "tool-display_diagram" &&
part.type !== "tool-edit_diagram"
) {
continue
}
const { toolCallId, state, input } = part as ToolPartLike
// Failed or stopped: if the original XML is still stored, the
// tool handler never ran (invalid JSON, or the user pressed
// stop), so undo the streamed preview here. Invalid JSON leaves
// no input, so check this first.
if (state === "output-error") {
const originalXml =
editDiagramOriginalXmlRef.current.get(toolCallId)
if (originalXml) {
editDiagramOriginalXmlRef.current.delete(toolCallId)
loadDiagram(originalXml, true, "revert")
baseXml = originalXml
}
continue
}
// Input complete, or the tool handler, a stop or an error took
// the call already: the tool handler loads the checked diagram.
// The messages update at most every 150 ms (useChat throttle in
// the chat engine), so they can still show the call streaming
// after that.
if (
state !== "input-streaming" ||
processedToolCallsRef.current.has(toolCallId)
) {
processedToolCallsRef.current.add(toolCallId)
lastProcessedXmlRef.current.delete(toolCallId)
lastProcessedXmlRef.current.delete(`${toolCallId}-opCount`)
continue
}
if (part.type === "tool-display_diagram") {
const xml = input?.xml as string | undefined
// Skip if XML hasn't changed since last processing
if (!xml || lastProcessedXmlRef.current.get(toolCallId) === xml)
continue
// Keep the diagram from before the preview, to undo it on a
// stop or an error
if (!editDiagramOriginalXmlRef.current.has(toolCallId)) {
editDiagramOriginalXmlRef.current.set(
toolCallId,
baseXml || BLANK_MXFILE,
)
}
handleDisplayChart(xml)
lastProcessedXmlRef.current.set(toolCallId, xml)
continue
}
// edit_diagram: apply operations incrementally for a preview.
// editDiagramOriginalXmlRef is shared with the tool handler.
if (!input?.operations) continue
const completeOps = getCompleteOperations(
input.operations as DiagramOperation[],
)
if (completeOps.length === 0) continue
// Capture the original XML when streaming starts
if (!editDiagramOriginalXmlRef.current.has(toolCallId)) {
if (!baseXml) {
console.warn(
"[edit_diagram streaming] No chart XML available",
)
continue
}
editDiagramOriginalXmlRef.current.set(toolCallId, baseXml)
}
const originalXml =
editDiagramOriginalXmlRef.current.get(toolCallId)
if (!originalXml) continue
// Skip if no change from last processed state
const countKey = `${toolCallId}-opCount`
const opCount = String(completeOps.length)
if (lastProcessedXmlRef.current.get(countKey) === opCount) continue
try {
const { result } = applyDiagramOperations(
originalXml,
completeOps,
)
// Load the full document so other pages stay intact
loadDiagram(result, true, "preview")
lastProcessedXmlRef.current.set(countKey, opCount)
} catch (e) {
console.warn(
"[edit_diagram streaming] Operation failed:",
e instanceof Error ? e.message : e,
)
}
}
}, [
messages,
handleDisplayChart,
chartXMLRef,
loadDiagram,
processedToolCallsRef,
editDiagramOriginalXmlRef,
loadedMessageIdsRef,
])
}
-154
View File
@@ -1,154 +0,0 @@
import type { RefObject } from "react"
import { useEffect, useRef } from "react"
import { toast } from "sonner"
import type { DiagramCommit, LoadMode } from "@/contexts/diagram-context"
import { useDictionary } from "@/hooks/use-dictionary"
import { countCells, diffDiagrams } from "@/lib/diagram-diff"
import { clearHighlights, highlightCells } from "@/lib/drawio/editor-bridge"
import { formatMessage } from "@/lib/i18n/utils"
import { BLANK_MXFILE } from "@/packages/mcp-server/src/pages.ts"
import { useVersionsStore } from "@/stores/versions-store"
interface UseVersionsParams {
loadDiagram: (
xml: string,
skipValidation?: boolean,
mode?: LoadMode,
) => string | null
getVersionSvg: () => Promise<string | null>
setCommitHandler: (
handler: ((commit: DiagramCommit) => void) | null,
) => void
/** Index of the user message whose turn is running */
currentTurnRef: RefObject<number>
/** The document on the canvas now */
chartXMLRef: RefObject<string>
isBusy: boolean
/** Live busy state, for the restore toast's Undo that runs later */
isTurnActive: () => boolean
/** Changes when another chat comes on screen */
getChatGeneration: () => number
}
/**
* Turns every committed AI change into a version (card in the chat), marks
* the changed shapes on the canvas, and restores or undoes versions.
*/
export function useVersions({
loadDiagram,
getVersionSvg,
setCommitHandler,
currentTurnRef,
chartXMLRef,
isBusy,
isTurnActive,
getChatGeneration,
}: UseVersionsParams) {
const dict = useDictionary()
// A restore commits too; that path must not add a version
const restoringRef = useRef(false)
// Registered once: re-registering on every render would leave a gap
// (effect cleanups run before the streaming hook commits). The handler
// only reads refs and stable functions.
useEffect(() => {
setCommitHandler(({ beforeXml, afterXml, toolCallId }) => {
if (restoringRef.current) return
const { summary, touchedIds } = diffDiagrams(beforeXml, afterXml)
const before = countCells(beforeXml)
const fromScratch = before.shapes + before.edges === 0
const id = useVersionsStore.getState().addVersion({
xml: afterXml,
beforeXml,
turnIndex: currentTurnRef.current,
toolCallId,
summary,
fromScratch,
})
// Mark what changed, unless the whole diagram is new
if (!fromScratch) {
setTimeout(() => {
const marker = getComputedStyle(document.documentElement)
.getPropertyValue("--marker")
.trim()
highlightCells(touchedIds, marker || "#ffd84d")
}, 60)
}
// The picture is of the canvas: take it right away, before a
// newer version can be on it; once one is, this one gets none
// rather than the newer one's
const isNewest = () =>
useVersionsStore.getState().versions.at(-1)?.id === id
setTimeout(async () => {
const svg = isNewest() ? await getVersionSvg() : null
useVersionsStore
.getState()
.updateVersion(id, { svg: svg && isNewest() ? svg : "" })
}, 0)
})
return () => setCommitHandler(null)
}, [])
const apply = (xml: string) => {
restoringRef.current = true
try {
loadDiagram(xml, true, "commit")
} finally {
restoringRef.current = false
}
clearHighlights()
}
const restoreVersion = (versionId: string) => {
if (isBusy) return
const store = useVersionsStore.getState()
const index = store.versions.findIndex((v) => v.id === versionId)
if (index < 0) return
// The canvas before the restore, to go back to it: a multi-page
// document is loaded in full, which Ctrl+Z cannot undo
const before = chartXMLRef.current
const wasOnCanvas = store.onCanvasVersionId
const wasUndone = store.undoneVersionId
const generation = getChatGeneration()
apply(store.versions[index].xml)
store.setOnCanvas(versionId)
toast.success(
formatMessage(dict.versions.restoredVersion, { n: index + 1 }),
{
id: "version-restored",
duration: 8000,
action: {
label: dict.versions.undoRestore,
onClick: () => {
// Another chat is on screen, or an answer is
// changing the canvas: the old canvas is not theirs
if (!before || isTurnActive()) return
if (getChatGeneration() !== generation) return
apply(before)
const now = useVersionsStore.getState()
if (wasUndone) now.setUndone(wasUndone)
else if (wasOnCanvas) now.setOnCanvas(wasOnCanvas)
},
},
},
)
}
/** Undo the change of the latest version, or redo it when undone */
const undoVersion = (versionId: string) => {
if (isBusy) return
const store = useVersionsStore.getState()
const version = store.versions.find((v) => v.id === versionId)
if (!version) return
if (store.undoneVersionId === versionId) {
apply(version.xml)
store.setOnCanvas(versionId)
return
}
if (version.beforeXml === undefined) return
apply(version.beforeXml || BLANK_MXFILE)
store.setUndone(versionId)
}
return { restoreVersion, undoVersion }
}
-88
View File
@@ -1,88 +0,0 @@
import { useEffect, useRef } from "react"
import { useDictionary } from "@/hooks/use-dictionary"
import { cn } from "@/lib/utils"
import { useUiStore } from "@/stores/ui-store"
import { useVersionsStore } from "@/stores/versions-store"
import { useChatEngine } from "./chat-engine"
/** Row of version thumbnails under the panel header; click to compare */
export function VersionStrip() {
const dict = useDictionary()
const versions = useVersionsStore((s) => s.versions)
const onCanvasVersionId = useVersionsStore((s) => s.onCanvasVersionId)
const openCompare = useUiStore((s) => s.openCompare)
const engine = useChatEngine()
const scrollRef = useRef<HTMLDivElement>(null)
// Keep the newest version in view
useEffect(() => {
const el = scrollRef.current
if (el) el.scrollTo({ left: el.scrollWidth, behavior: "smooth" })
}, [versions.length])
// Versions with no card in the chat: saved by older app versions, or
// from a turn that was retried
const hasCardless = versions.some(
(v) =>
!v.toolCallId ||
!engine.messages.some((m) =>
m.parts?.some(
(p) =>
(p as { toolCallId?: string }).toolCallId ===
v.toolCallId,
),
),
)
// One version with a card: the strip adds nothing to it
if (versions.length < 2 && !hasCardless) return null
return (
<div className="flex items-center gap-2 border-b border-border px-3.5 pt-1 pb-3">
<span className="shrink-0 text-xs text-muted-foreground">
{dict.versions.strip}
</span>
<div
ref={scrollRef}
className="flex min-w-0 flex-1 gap-1.5 overflow-x-auto p-1 scrollbar-thin"
>
{versions.map((version, index) => {
const isCurrent = version.id === onCanvasVersionId
return (
<button
key={version.id}
type="button"
onClick={() => openCompare(version.id)}
title={`v${index + 1}${version.id === onCanvasVersionId ? ` · ${dict.versions.currentCanvas}` : ""}`}
className={cn(
"sheet-light relative flex h-12 w-[72px] shrink-0 items-center justify-center overflow-hidden rounded-lg border border-border bg-white transition-colors hover:border-foreground/30",
// An outline with a gap reads on the white
// picture in both themes
isCurrent &&
"outline-2 outline-offset-2 outline-foreground",
)}
data-testid="version-thumb"
>
{version.svg && (
// biome-ignore lint/performance/noImgElement: data URL thumbnail
<img
src={version.svg}
alt=""
className="max-h-9 max-w-[62px] object-contain"
/>
)}
<span
className={cn(
"absolute bottom-0.5 left-1 text-[10px] font-medium tabular-nums text-neutral-500",
isCurrent &&
"font-semibold text-neutral-900",
)}
>
v{index + 1}
</span>
</button>
)
})}
</div>
</div>
)
}
+114
View File
@@ -0,0 +1,114 @@
"use client"
import { useState } from "react"
import Image from "@/components/image-with-basepath"
import { Button } from "@/components/ui/button"
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog"
import { useDiagram } from "@/contexts/diagram-context"
import { useDictionary } from "@/hooks/use-dictionary"
import { formatMessage } from "@/lib/i18n/utils"
interface HistoryDialogProps {
showHistory: boolean
onToggleHistory: (show: boolean) => void
}
export function HistoryDialog({
showHistory,
onToggleHistory,
}: HistoryDialogProps) {
const dict = useDictionary()
const { loadDiagram: onDisplayChart, diagramHistory } = useDiagram()
const [selectedIndex, setSelectedIndex] = useState<number | null>(null)
const handleClose = () => {
setSelectedIndex(null)
onToggleHistory(false)
}
const handleConfirmRestore = () => {
if (selectedIndex !== null) {
// Skip validation for trusted history snapshots
onDisplayChart(diagramHistory[selectedIndex].xml, true)
handleClose()
}
}
return (
<Dialog open={showHistory} onOpenChange={onToggleHistory}>
<DialogContent className="max-w-3xl max-h-[80vh] overflow-y-auto scrollbar-thin">
<DialogHeader>
<DialogTitle>{dict.history.title}</DialogTitle>
<DialogDescription>
{dict.history.description}
</DialogDescription>
</DialogHeader>
{diagramHistory.length === 0 ? (
<div className="text-center p-4 text-gray-500">
{dict.history.noHistory}
</div>
) : (
<div className="grid grid-cols-2 md:grid-cols-3 gap-4 py-4">
{diagramHistory.map((item, index) => (
<div
key={index}
className={`border rounded-md p-2 cursor-pointer hover:border-primary transition-colors ${
selectedIndex === index
? "border-primary ring-2 ring-primary"
: ""
}`}
onClick={() => setSelectedIndex(index)}
>
<div className="aspect-video bg-white rounded overflow-hidden flex items-center justify-center">
<Image
src={item.svg}
alt={`${dict.history.version} ${index + 1}`}
width={200}
height={100}
className="object-contain w-full h-full p-1"
/>
</div>
<div className="text-xs text-center mt-1 text-gray-500">
{dict.history.version} {index + 1}
</div>
</div>
))}
</div>
)}
<DialogFooter>
{selectedIndex !== null ? (
<>
<div className="flex-1 text-sm text-muted-foreground">
{formatMessage(dict.history.restoreTo, {
version: selectedIndex + 1,
})}
</div>
<Button
variant="outline"
onClick={() => setSelectedIndex(null)}
>
{dict.common.cancel}
</Button>
<Button onClick={handleConfirmRestore}>
{dict.common.confirm}
</Button>
</>
) : (
<Button variant="outline" onClick={handleClose}>
{dict.common.close}
</Button>
)}
</DialogFooter>
</DialogContent>
</Dialog>
)
}
File diff suppressed because it is too large Load Diff
+4 -29
View File
@@ -26,7 +26,6 @@ import {
ModelSelectorTrigger,
} from "@/components/ai-elements/model-selector"
import { ButtonWithTooltip } from "@/components/button-with-tooltip"
import { ProviderLogo } from "@/components/provider-logo"
import { useDictionary } from "@/hooks/use-dictionary"
import {
type FlattenedModel,
@@ -67,22 +66,6 @@ function groupModelsByProvider(
return groups
}
/**
* The model id without the provider or region prefix that the logo already
* shows: "nvidia/nemotron-3-ultra" and "global.anthropic.claude-opus-5-5"
* become "nemotron-3-ultra" and "claude-opus-5-5". The full id is in the
* tooltip and the list.
*/
export function shortModelName(id: string): string {
const name = id.slice(id.lastIndexOf("/") + 1)
return (
name.replace(
/^(?:(?:global|us|eu|apac|jp|au|ca|us-gov)\.)?[a-z][a-z0-9-]*\.(?=[a-z])/i,
"",
) || name
)
}
export function ModelSelector({
models,
selectedModelId,
@@ -175,7 +158,7 @@ export function ModelSelector({
}, [])
return (
<div ref={wrapperRef} className="min-w-0 max-w-44">
<div ref={wrapperRef} className="min-w-0 max-w-48">
<ModelSelectorRoot open={open} onOpenChange={setOpen}>
<ModelSelectorTrigger asChild>
<ButtonWithTooltip
@@ -184,26 +167,18 @@ export function ModelSelector({
size="sm"
disabled={disabled}
className={cn(
"h-8 min-w-0 max-w-full shrink overflow-hidden gap-1.5 px-2 font-normal text-muted-foreground transition-[padding,background-color] duration-150 ease-in-out hover:bg-accent hover:text-foreground",
"h-8 min-w-0 max-w-full shrink overflow-hidden gap-1.5 px-2 transition-[padding,background-color] duration-150 ease-in-out hover:bg-accent",
!showLabel && "px-1.5 justify-center",
)}
// accessibility: expose label to screen readers
aria-label={tooltipContent}
data-testid="model-selector"
>
{selectedModel ? (
<ProviderLogo
provider={selectedModel.provider}
className="size-3.5 flex-shrink-0 opacity-80"
/>
) : (
<Bot className="h-4 w-4 flex-shrink-0" />
)}
<Bot className="h-4 w-4 flex-shrink-0 text-muted-foreground" />
{/* show/hide visible label based on measured width */}
{showLabel ? (
<span className="min-w-0 truncate text-xs">
{selectedModel
? shortModelName(selectedModel.modelId)
? selectedModel.modelId
: dict.modelConfig.default}
</span>
) : (
+50
View File
@@ -0,0 +1,50 @@
"use client"
import { Button } from "@/components/ui/button"
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog"
import { useDictionary } from "@/hooks/use-dictionary"
interface ResetWarningModalProps {
open: boolean
onOpenChange: (open: boolean) => void
onClear: () => void
}
export function ResetWarningModal({
open,
onOpenChange,
onClear,
}: ResetWarningModalProps) {
const dict = useDictionary()
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent>
<DialogHeader>
<DialogTitle>{dict.dialogs.clearTitle}</DialogTitle>
<DialogDescription>
{dict.dialogs.clearDescription}
</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button
variant="outline"
onClick={() => onOpenChange(false)}
>
{dict.common.cancel}
</Button>
<Button variant="destructive" onClick={onClear}>
{dict.dialogs.clearEverything}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
)
}
+37 -46
View File
@@ -10,8 +10,15 @@ import {
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog"
import { Input } from "@/components/ui/input"
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select"
import { useDictionary } from "@/hooks/use-dictionary"
import { cn } from "@/lib/utils"
export type ExportFormat = "drawio" | "png" | "svg" | "xmlsvg"
@@ -55,25 +62,21 @@ export function SaveDialog({
{
value: "drawio" as const,
label: dict.save.formats.drawio,
hint: dict.save.hints.drawio,
extension: ".drawio",
},
{
value: "png" as const,
label: dict.save.formats.png,
hint: dict.save.hints.png,
extension: ".png",
},
{
value: "svg" as const,
label: dict.save.formats.svg,
hint: dict.save.hints.svg,
extension: ".svg",
},
{
value: "xmlsvg" as const,
label: dict.save.formats.xmlsvg,
hint: dict.save.hints.xmlsvg,
extension: ".drawio.svg",
},
]
@@ -82,7 +85,7 @@ export function SaveDialog({
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-lg">
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle>{dict.save.title}</DialogTitle>
<DialogDescription>
@@ -90,54 +93,44 @@ export function SaveDialog({
</DialogDescription>
</DialogHeader>
<div className="space-y-4">
<div
role="radiogroup"
aria-label={dict.save.format}
className="grid grid-cols-2 gap-2"
>
{FORMAT_OPTIONS.map((opt) => (
// biome-ignore lint/a11y/useSemanticElements: styled radio tile
<button
key={opt.value}
type="button"
role="radio"
aria-checked={format === opt.value}
onClick={() => setFormat(opt.value)}
className={cn(
"rounded-lg border border-border p-3 text-left transition-colors hover:border-foreground/30",
format === opt.value &&
"border-foreground bg-accent",
)}
>
<span className="block text-[13px] font-medium text-foreground">
{opt.label}
</span>
<span className="mt-0.5 block text-xs text-muted-foreground">
{opt.hint}
</span>
</button>
))}
<div className="space-y-2">
<label className="text-sm font-medium">
{dict.save.format}
</label>
<Select
value={format}
onValueChange={(v) => setFormat(v as ExportFormat)}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
{FORMAT_OPTIONS.map((opt) => (
<SelectItem
key={opt.value}
value={opt.value}
>
{opt.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<label
htmlFor="export-filename"
className="text-sm font-medium"
>
<label className="text-sm font-medium">
{dict.save.filename}
</label>
{/* One field: the name, then its extension */}
<div className="flex h-9 items-center overflow-hidden rounded-md border border-input focus-within:ring-2 focus-within:ring-ring/40">
<input
id="export-filename"
<div className="flex items-stretch">
<Input
value={filename}
onChange={(e) => setFilename(e.target.value)}
onKeyDown={handleKeyDown}
placeholder={dict.save.filenamePlaceholder}
autoFocus
onFocus={(e) => e.target.select()}
className="h-full min-w-0 flex-1 bg-transparent px-3 text-sm outline-none"
className="rounded-r-none border-r-0 focus-visible:z-10"
/>
<span className="shrink-0 pr-3 text-sm text-muted-foreground">
<span className="inline-flex items-center px-3 rounded-r-md border border-l-0 border-input bg-muted text-sm text-muted-foreground font-mono">
{currentFormat?.extension || ".drawio"}
</span>
</div>
@@ -150,9 +143,7 @@ export function SaveDialog({
>
{dict.common.cancel}
</Button>
<Button onClick={handleSave}>
{dict.save.exportButton}
</Button>
<Button onClick={handleSave}>{dict.common.save}</Button>
</DialogFooter>
</DialogContent>
</Dialog>
+757
View File
@@ -0,0 +1,757 @@
"use client"
import { ChevronRight, Github, Info, Moon, Sun, Tag } from "lucide-react"
import { usePathname, useRouter, useSearchParams } from "next/navigation"
import { Suspense, useCallback, useEffect, useState } from "react"
import { toast } from "sonner"
import { Button } from "@/components/ui/button"
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog"
import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label"
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select"
import { Switch } from "@/components/ui/switch"
import { Textarea } from "@/components/ui/textarea"
import { useDictionary } from "@/hooks/use-dictionary"
import { getApiEndpoint } from "@/lib/base-path"
import type { DrawioTheme } from "@/lib/drawio-themes"
import { i18n, type Locale } from "@/lib/i18n/config"
import { STORAGE_KEYS } from "@/lib/storage"
// Reusable setting item component for consistent layout
function SettingItem({
label,
description,
children,
}: {
label: string
description?: string
children: React.ReactNode
}) {
return (
<div className="flex items-center justify-between py-4 first:pt-0 last:pb-0">
<div className="space-y-0.5 pr-4">
<Label className="text-sm font-medium">{label}</Label>
{description && (
<p className="text-xs text-muted-foreground max-w-[260px]">
{description}
</p>
)}
</div>
<div className="shrink-0">{children}</div>
</div>
)
}
const LANGUAGE_LABELS: Record<Locale, string> = {
en: "English",
zh: "中文",
ja: "日本語",
"zh-Hant": "繁體中文",
}
interface SettingsDialogProps {
open: boolean
onOpenChange: (open: boolean) => void
drawioUi: DrawioTheme
onDrawioUiChange: (theme: DrawioTheme) => void
darkMode: boolean
onToggleDarkMode: () => void
minimalStyle?: boolean
onMinimalStyleChange?: (value: boolean) => void
vlmValidationEnabled?: boolean
onVlmValidationChange?: (value: boolean) => void
onOpenModelConfig?: () => void
customSystemMessage?: string
onCustomSystemMessageChange?: (value: string) => void
maxOutputTokens?: string
onMaxOutputTokensChange?: (value: string) => void
}
export const STORAGE_ACCESS_CODE_KEY = "next-ai-draw-io-access-code"
const STORAGE_ACCESS_CODE_REQUIRED_KEY = "next-ai-draw-io-access-code-required"
function getStoredAccessCodeRequired(): boolean | null {
if (typeof window === "undefined") return null
const stored = localStorage.getItem(STORAGE_ACCESS_CODE_REQUIRED_KEY)
if (stored === null) return null
return stored === "true"
}
function SettingsContent({
open,
onOpenChange,
drawioUi,
onDrawioUiChange,
darkMode,
onToggleDarkMode,
minimalStyle = false,
onMinimalStyleChange = () => {},
vlmValidationEnabled = false,
onVlmValidationChange = () => {},
onOpenModelConfig,
customSystemMessage = "",
onCustomSystemMessageChange = () => {},
maxOutputTokens = "",
onMaxOutputTokensChange = () => {},
}: SettingsDialogProps) {
const dict = useDictionary()
const router = useRouter()
const pathname = usePathname() || "/"
const search = useSearchParams()
const [accessCode, setAccessCode] = useState("")
const [isVerifying, setIsVerifying] = useState(false)
const [error, setError] = useState("")
const [accessCodeRequired, setAccessCodeRequired] = useState(
() => getStoredAccessCodeRequired() ?? false,
)
const [currentLang, setCurrentLang] = useState("en")
const [sendShortcut, setSendShortcut] = useState("ctrl-enter")
// Panel visibility state
const [showRecentChats, setShowRecentChats] = useState(true)
const [showMyTemplates, setShowMyTemplates] = useState(true)
const [showQuickExamples, setShowQuickExamples] = useState(true)
const handlePanelToggle = useCallback(
(key: string, value: boolean, setter: (v: boolean) => void) => {
setter(value)
localStorage.setItem(key, String(value))
window.dispatchEvent(new CustomEvent("panelVisibilityChange"))
},
[],
)
// Proxy settings state (Electron only)
const [httpProxy, setHttpProxy] = useState("")
const [httpsProxy, setHttpsProxy] = useState("")
const [isApplyingProxy, setIsApplyingProxy] = useState(false)
useEffect(() => {
// Re-fetch config whenever the dialog opens to ensure we always show
// the access code input if the server requires it. This fixes the case
// where a stale localStorage cache (from before ACCESS_CODE_LIST was
// configured) would hide the access code input.
if (!open) return
fetch(getApiEndpoint("/api/config"))
.then((res) => {
if (!res.ok) throw new Error(`HTTP ${res.status}`)
return res.json()
})
.then((data) => {
const required = data?.accessCodeRequired === true
localStorage.setItem(
STORAGE_ACCESS_CODE_REQUIRED_KEY,
String(required),
)
setAccessCodeRequired(required)
})
.catch(() => {
// Keep existing cached value on error
})
}, [open])
// Detect current language from pathname
useEffect(() => {
const seg = pathname.split("/").filter(Boolean)
const first = seg[0]
if (first && i18n.locales.includes(first as Locale)) {
setCurrentLang(first)
} else {
setCurrentLang(i18n.defaultLocale)
}
}, [pathname])
useEffect(() => {
if (open) {
const storedCode =
localStorage.getItem(STORAGE_ACCESS_CODE_KEY) || ""
setAccessCode(storedCode)
const storedSendShortcut = localStorage.getItem(
STORAGE_KEYS.sendShortcut,
)
setSendShortcut(storedSendShortcut || "ctrl-enter")
setShowRecentChats(
localStorage.getItem(STORAGE_KEYS.showRecentChats) !== "false",
)
setShowMyTemplates(
localStorage.getItem(STORAGE_KEYS.showMyTemplates) !== "false",
)
setShowQuickExamples(
localStorage.getItem(STORAGE_KEYS.showQuickExamples) !==
"false",
)
setError("")
// Load proxy settings (Electron only)
if (window.electronAPI?.getProxy) {
window.electronAPI.getProxy().then((config) => {
setHttpProxy(config.httpProxy || "")
setHttpsProxy(config.httpsProxy || "")
})
}
}
}, [open])
const changeLanguage = (lang: string) => {
// Save locale to localStorage for persistence across restarts
localStorage.setItem("next-ai-draw-io-locale", lang)
// Notify Electron main process to update its menu language
if (window.electronAPI?.setUserLocale) {
window.electronAPI.setUserLocale(lang).catch((error) => {
console.error("Failed to sync locale with Electron:", error)
})
}
const parts = pathname.split("/")
if (parts.length > 1 && i18n.locales.includes(parts[1] as Locale)) {
parts[1] = lang
} else {
parts.splice(1, 0, lang)
}
const newPath = parts.join("/") || "/"
const searchStr = search?.toString() ? `?${search.toString()}` : ""
router.push(newPath + searchStr)
}
const handleSave = async () => {
if (!accessCodeRequired) return
setError("")
setIsVerifying(true)
try {
const response = await fetch(
getApiEndpoint("/api/verify-access-code"),
{
method: "POST",
headers: {
"x-access-code": accessCode.trim(),
},
},
)
const data = await response.json()
if (!data.valid) {
setError(data.message || dict.errors.invalidAccessCode)
return
}
localStorage.setItem(STORAGE_ACCESS_CODE_KEY, accessCode.trim())
onOpenChange(false)
} catch {
setError(dict.errors.networkError)
} finally {
setIsVerifying(false)
}
}
const handleKeyDown = (e: React.KeyboardEvent) => {
if (e.key === "Enter") {
e.preventDefault()
handleSave()
}
}
const handleApplyProxy = async () => {
if (!window.electronAPI?.setProxy) return
// Validate proxy URLs (must start with http:// or https://)
const validateProxyUrl = (url: string): boolean => {
if (!url) return true // Empty is OK
return url.startsWith("http://") || url.startsWith("https://")
}
const trimmedHttp = httpProxy.trim()
const trimmedHttps = httpsProxy.trim()
if (trimmedHttp && !validateProxyUrl(trimmedHttp)) {
toast.error("HTTP Proxy must start with http:// or https://")
return
}
if (trimmedHttps && !validateProxyUrl(trimmedHttps)) {
toast.error("HTTPS Proxy must start with http:// or https://")
return
}
setIsApplyingProxy(true)
try {
const result = await window.electronAPI.setProxy({
httpProxy: trimmedHttp || undefined,
httpsProxy: trimmedHttps || undefined,
})
if (result.success) {
toast.success(dict.settings.proxyApplied)
} else {
toast.error(result.error || "Failed to apply proxy settings")
}
} catch {
toast.error("Failed to apply proxy settings")
} finally {
setIsApplyingProxy(false)
}
}
return (
<DialogContent className="sm:max-w-lg p-0 gap-0 max-h-[90vh] flex flex-col overflow-hidden">
{/* Header */}
<DialogHeader className="px-6 pt-6 pb-4">
<DialogTitle>{dict.settings.title}</DialogTitle>
<DialogDescription className="mt-1">
{dict.settings.description}
</DialogDescription>
</DialogHeader>
{/* Content */}
<div className="px-6 pb-6 overflow-y-auto flex-1 scrollbar-thin">
<div className="divide-y divide-border-subtle">
{/* API Keys & Models */}
{onOpenModelConfig && (
<SettingItem
label={dict.settings.apiKeysModels}
description={dict.settings.apiKeysModelsDescription}
>
<Button
variant="ghost"
size="sm"
className="h-9 w-9 p-0"
onClick={() => {
onOpenChange(false)
onOpenModelConfig()
}}
aria-label={dict.settings.apiKeysModels}
>
<ChevronRight className="h-4 w-4" />
</Button>
</SettingItem>
)}
{/* Access Code (conditional) */}
{accessCodeRequired && (
<div className="py-4 first:pt-0 space-y-3">
<div className="space-y-0.5">
<Label
htmlFor="access-code"
className="text-sm font-medium"
>
{dict.settings.accessCode}
</Label>
<p className="text-xs text-muted-foreground">
{dict.settings.accessCodeDescription}
</p>
</div>
<div className="flex gap-2">
<Input
id="access-code"
type="password"
value={accessCode}
onChange={(e) =>
setAccessCode(e.target.value)
}
onKeyDown={handleKeyDown}
placeholder={
dict.settings.accessCodePlaceholder
}
autoComplete="off"
className="h-9"
/>
<Button
onClick={handleSave}
disabled={isVerifying || !accessCode.trim()}
className="h-9 px-4 rounded-xl"
>
{isVerifying ? "..." : dict.common.save}
</Button>
</div>
{error && (
<p className="text-xs text-destructive">
{error}
</p>
)}
</div>
)}
{/* Language */}
<SettingItem
label={dict.settings.language}
description={dict.settings.languageDescription}
>
<Select
value={currentLang}
onValueChange={changeLanguage}
>
<SelectTrigger
id="language-select"
className="w-[120px] h-9 rounded-xl"
>
<SelectValue />
</SelectTrigger>
<SelectContent>
{i18n.locales.map((locale) => (
<SelectItem key={locale} value={locale}>
{LANGUAGE_LABELS[locale]}
</SelectItem>
))}
</SelectContent>
</Select>
</SettingItem>
{/* Theme */}
<SettingItem
label={dict.settings.theme}
description={dict.settings.themeDescription}
>
<Button
id="theme-toggle"
variant="outline"
size="icon"
onClick={onToggleDarkMode}
className="h-9 w-9 rounded-xl border-border-subtle hover:bg-interactive-hover"
>
{darkMode ? (
<Sun className="h-4 w-4" />
) : (
<Moon className="h-4 w-4" />
)}
</Button>
</SettingItem>
{/* Draw.io Style */}
<SettingItem
label={dict.settings.drawioStyle}
description={dict.settings.drawioStyleDescription}
>
<Select
value={drawioUi}
onValueChange={(v) =>
onDrawioUiChange(v as DrawioTheme)
}
>
<SelectTrigger
id="drawio-ui-select"
aria-label={dict.settings.drawioStyle}
className="w-[120px] h-9 rounded-xl"
>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="kennedy">
{dict.settings.themeDefault}
</SelectItem>
<SelectItem value="atlas">Atlas</SelectItem>
<SelectItem value="dark">
{dict.settings.themeDark}
</SelectItem>
<SelectItem value="min">
{dict.settings.themeMinimal}
</SelectItem>
<SelectItem value="sketch">
{dict.settings.themeSketch}
</SelectItem>
<SelectItem value="simple">
{dict.settings.themeSimple}
</SelectItem>
</SelectContent>
</Select>
</SettingItem>
{/* Diagram Style */}
<SettingItem
label={dict.settings.diagramStyle}
description={dict.settings.diagramStyleDescription}
>
<div className="flex items-center gap-2">
<Switch
id="minimal-style"
checked={minimalStyle}
onCheckedChange={onMinimalStyleChange}
/>
<span className="text-sm text-muted-foreground">
{minimalStyle
? dict.chat.minimalStyle
: dict.chat.styledMode}
</span>
</div>
</SettingItem>
{/* Panel Visibility */}
<SettingItem
label={dict.settings.panelVisibility}
description={dict.settings.panelVisibilityDescription}
>
<div className="flex flex-col gap-2">
<label className="flex items-center gap-2 cursor-pointer">
<Switch
id="show-recent-chats"
checked={showRecentChats}
onCheckedChange={(v) =>
handlePanelToggle(
STORAGE_KEYS.showRecentChats,
v,
setShowRecentChats,
)
}
/>
<span className="text-xs text-muted-foreground">
{dict.settings.showRecentChats}
</span>
</label>
<label className="flex items-center gap-2 cursor-pointer">
<Switch
id="show-my-templates"
checked={showMyTemplates}
onCheckedChange={(v) =>
handlePanelToggle(
STORAGE_KEYS.showMyTemplates,
v,
setShowMyTemplates,
)
}
/>
<span className="text-xs text-muted-foreground">
{dict.settings.showMyTemplates}
</span>
</label>
<label className="flex items-center gap-2 cursor-pointer">
<Switch
id="show-quick-examples"
checked={showQuickExamples}
onCheckedChange={(v) =>
handlePanelToggle(
STORAGE_KEYS.showQuickExamples,
v,
setShowQuickExamples,
)
}
/>
<span className="text-xs text-muted-foreground">
{dict.settings.showQuickExamples}
</span>
</label>
</div>
</SettingItem>
{/* VLM Diagram Validation */}
<SettingItem
label={dict.settings.diagramValidation}
description={dict.settings.diagramValidationDescription}
>
<div className="flex items-center gap-2">
<Switch
id="vlm-validation"
checked={vlmValidationEnabled}
onCheckedChange={onVlmValidationChange}
/>
<span className="text-sm text-muted-foreground">
{vlmValidationEnabled
? dict.settings.enabled
: dict.settings.disabled}
</span>
</div>
</SettingItem>
{/* Custom System Message */}
<div className="py-4 space-y-3">
<div className="space-y-0.5">
<Label
htmlFor="custom-system-message"
className="text-sm font-medium"
>
{dict.settings.customSystemMessage}
</Label>
<p className="text-xs text-muted-foreground">
{dict.settings.customSystemMessageDescription}
</p>
</div>
<Textarea
id="custom-system-message"
value={customSystemMessage}
onChange={(e) =>
onCustomSystemMessageChange(e.target.value)
}
placeholder={
dict.settings.customSystemMessagePlaceholder
}
className="min-h-[80px] max-h-[160px] text-sm"
maxLength={5000}
/>
</div>
{/* Max Output Tokens */}
<SettingItem
label={dict.settings.maxOutputTokens}
description={dict.settings.maxOutputTokensDescription}
>
<Input
id="max-output-tokens"
type="text"
inputMode="numeric"
value={maxOutputTokens}
onChange={(e) =>
onMaxOutputTokensChange(e.target.value)
}
placeholder="64000"
className="h-9 w-28 text-sm"
/>
</SettingItem>
{/* Send Shortcut */}
<SettingItem
label={dict.settings.sendShortcut}
description={dict.settings.sendShortcutDescription}
>
<Select
value={sendShortcut}
onValueChange={(value) => {
setSendShortcut(value)
localStorage.setItem(
STORAGE_KEYS.sendShortcut,
value,
)
window.dispatchEvent(
new CustomEvent("sendShortcutChange", {
detail: value,
}),
)
}}
>
<SelectTrigger
id="send-shortcut-select"
className="w-auto h-9 rounded-xl"
>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="enter">
{dict.settings.enterToSend}
</SelectItem>
<SelectItem value="ctrl-enter">
{dict.settings.ctrlEnterToSend}
</SelectItem>
</SelectContent>
</Select>
</SettingItem>
{/* Proxy Settings - Electron only */}
{typeof window !== "undefined" &&
window.electronAPI?.isElectron && (
<div className="py-4 space-y-3">
<div className="space-y-0.5">
<Label className="text-sm font-medium">
{dict.settings.proxy}
</Label>
<p className="text-xs text-muted-foreground">
{dict.settings.proxyDescription}
</p>
</div>
<div className="space-y-2">
<Input
id="http-proxy"
type="text"
value={httpProxy}
onChange={(e) =>
setHttpProxy(e.target.value)
}
placeholder={`${dict.settings.httpProxy}: http://proxy:8080`}
className="h-9"
/>
<Input
id="https-proxy"
type="text"
value={httpsProxy}
onChange={(e) =>
setHttpsProxy(e.target.value)
}
placeholder={`${dict.settings.httpsProxy}: http://proxy:8080`}
className="h-9"
/>
</div>
<Button
onClick={handleApplyProxy}
disabled={isApplyingProxy}
className="h-9 px-4 rounded-xl w-full"
>
{isApplyingProxy
? "..."
: dict.settings.applyProxy}
</Button>
</div>
)}
</div>
</div>
{/* Footer */}
<div className="px-6 py-4 border-t border-border-subtle bg-surface-1/50 rounded-b-2xl">
<div className="flex items-center justify-center gap-3">
<span className="text-xs text-muted-foreground flex items-center gap-1">
<Tag className="h-3 w-3" />
{process.env.APP_VERSION}
</span>
<span className="text-muted-foreground">·</span>
<a
href="https://github.com/DayuanJiang/next-ai-draw-io"
target="_blank"
rel="noopener noreferrer"
className="text-xs text-muted-foreground hover:text-foreground transition-colors flex items-center gap-1"
>
<Github className="h-3 w-3" />
GitHub
</a>
{process.env.NEXT_PUBLIC_SHOW_ABOUT_AND_NOTICE ===
"true" && (
<>
<span className="text-muted-foreground">·</span>
<a
href={`/${currentLang}/about${currentLang === "zh" ? "/cn" : currentLang === "ja" ? "/ja" : ""}`}
target="_blank"
rel="noopener noreferrer"
className="text-xs text-muted-foreground hover:text-foreground transition-colors flex items-center gap-1"
>
<Info className="h-3 w-3" />
{dict.nav.about}
</a>
</>
)}
</div>
</div>
</DialogContent>
)
}
export function SettingsDialog(props: SettingsDialogProps) {
return (
<Dialog open={props.open} onOpenChange={props.onOpenChange}>
<Suspense
fallback={
<DialogContent className="sm:max-w-lg p-0">
<div className="h-80 flex items-center justify-center">
<div className="animate-spin h-6 w-6 border-2 border-primary border-t-transparent rounded-full" />
</div>
</DialogContent>
}
>
<SettingsContent {...props} />
</Suspense>
</Dialog>
)
}
-584
View File
@@ -1,584 +0,0 @@
import {
Github,
Info,
KeyRound,
Monitor,
Moon,
Palette,
SlidersHorizontal,
Sun,
Terminal,
} from "lucide-react"
import { usePathname, useRouter, useSearchParams } from "next/navigation"
import { useEffect, useState } from "react"
import { toast } from "sonner"
import { useChatEngine } from "@/components/chat/chat-engine"
import { ModelConfigDialog } from "@/components/model-config-dialog"
import { Button } from "@/components/ui/button"
import {
Dialog,
DialogContent,
DialogDescription,
DialogTitle,
} from "@/components/ui/dialog"
import { Input } from "@/components/ui/input"
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select"
import { Switch } from "@/components/ui/switch"
import { Textarea } from "@/components/ui/textarea"
import { BrandMark } from "@/components/workspace/brand-mark"
import { useDictionary } from "@/hooks/use-dictionary"
import { getApiEndpoint } from "@/lib/base-path"
import { i18n, type Locale } from "@/lib/i18n/config"
import { STORAGE_KEYS } from "@/lib/storage"
import { cn } from "@/lib/utils"
import {
type SendShortcut,
type ThemePreference,
useSettingsStore,
} from "@/stores/settings-store"
import { type SettingsTab, useUiStore } from "@/stores/ui-store"
const LANGUAGE_LABELS: Record<Locale, string> = {
en: "English",
zh: "中文",
ja: "日本語",
"zh-Hant": "繁體中文",
}
function Row({
label,
description,
htmlFor,
children,
stacked = false,
}: {
label: string
description?: string
htmlFor?: string
children: React.ReactNode
stacked?: boolean
}) {
return (
<div
className={cn(
"py-4 first:pt-1",
stacked
? "space-y-3"
: "flex items-center justify-between gap-6",
)}
>
<div className="min-w-0 space-y-0.5">
<label
htmlFor={htmlFor}
className="block text-[13px] font-medium text-foreground"
>
{label}
</label>
{description && (
<p className="max-w-[34em] text-xs text-muted-foreground">
{description}
</p>
)}
</div>
<div className={cn(!stacked && "shrink-0")}>{children}</div>
</div>
)
}
function Segmented<T extends string>({
value,
options,
onChange,
label,
}: {
value: T
options: { value: T; label: string; icon?: React.ReactNode }[]
onChange: (value: T) => void
label: string
}) {
return (
<div
role="radiogroup"
aria-label={label}
className="inline-flex rounded-lg bg-muted p-0.5"
>
{options.map((option) => (
// biome-ignore lint/a11y/useSemanticElements: styled segmented control
<button
key={option.value}
type="button"
role="radio"
aria-checked={value === option.value}
onClick={() => onChange(option.value)}
className={cn(
"inline-flex h-7 items-center gap-1.5 rounded-md px-2.5 text-xs text-muted-foreground transition-colors [&_svg]:size-3.5",
value === option.value &&
"bg-card font-medium text-foreground shadow-float",
)}
>
{option.icon}
{option.label}
</button>
))}
</div>
)
}
function AppearanceTab() {
const dict = useDictionary()
const t = dict.settings
const router = useRouter()
const pathname = usePathname() || "/"
const search = useSearchParams()
const theme = useSettingsStore((s) => s.theme)
const setTheme = useSettingsStore((s) => s.setTheme)
const minimalStyle = useSettingsStore((s) => s.minimalStyle)
const setMinimalStyle = useSettingsStore((s) => s.setMinimalStyle)
const currentLang =
(pathname.split("/").filter(Boolean)[0] as Locale) || i18n.defaultLocale
const changeLanguage = (lang: string) => {
localStorage.setItem(STORAGE_KEYS.locale, lang)
// The page reloads in the new language; close settings as before
useUiStore.getState().setSettingsOpen(false)
// Keep the desktop app's menu language in sync
window.electronAPI?.setUserLocale?.(lang).catch((error: unknown) => {
console.error("Failed to sync locale with Electron:", error)
})
const parts = pathname.split("/")
if (parts.length > 1 && i18n.locales.includes(parts[1] as Locale)) {
parts[1] = lang
} else {
parts.splice(1, 0, lang)
}
const query = search?.toString() ? `?${search.toString()}` : ""
router.push((parts.join("/") || "/") + query)
}
return (
<div className="divide-y divide-border">
<Row label={t.theme} description={t.themeDescription}>
<Segmented<ThemePreference>
label={t.theme}
value={theme}
onChange={setTheme}
options={[
{ value: "light", label: t.themeLight, icon: <Sun /> },
{
value: "dark",
label: t.themeDarkMode,
icon: <Moon />,
},
{
value: "system",
label: t.themeSystem,
icon: <Monitor />,
},
]}
/>
</Row>
<Row label={t.language} description={t.languageDescription}>
<Select value={currentLang} onValueChange={changeLanguage}>
<SelectTrigger
id="language-select"
className="h-8 w-[132px] rounded-lg"
>
<SelectValue />
</SelectTrigger>
<SelectContent>
{i18n.locales.map((locale) => (
<SelectItem key={locale} value={locale}>
{LANGUAGE_LABELS[locale]}
</SelectItem>
))}
</SelectContent>
</Select>
</Row>
<Row label={t.diagramStyle} description={t.diagramStyleDescription}>
<Segmented<"styled" | "minimal">
label={t.diagramStyle}
value={minimalStyle ? "minimal" : "styled"}
onChange={(value) => setMinimalStyle(value === "minimal")}
options={[
{ value: "styled", label: dict.chat.styledMode },
{ value: "minimal", label: dict.chat.minimalStyle },
]}
/>
</Row>
</div>
)
}
function AdvancedTab({ open }: { open: boolean }) {
const dict = useDictionary()
const t = dict.settings
const settings = useSettingsStore()
const [accessCode, setAccessCode] = useState("")
const [accessCodeRequired, setAccessCodeRequired] = useState(
() => localStorage.getItem(STORAGE_KEYS.accessCodeRequired) === "true",
)
const [isVerifying, setIsVerifying] = useState(false)
const [accessError, setAccessError] = useState("")
const [httpProxy, setHttpProxy] = useState("")
const [httpsProxy, setHttpsProxy] = useState("")
const [isApplyingProxy, setIsApplyingProxy] = useState(false)
const isElectron =
typeof window !== "undefined" && !!window.electronAPI?.isElectron
useEffect(() => {
if (!open) return
setAccessCode(localStorage.getItem(STORAGE_KEYS.accessCode) || "")
setAccessError("")
// Re-check on every open: a stale cached value would hide the field
fetch(getApiEndpoint("/api/config"))
.then((res) => {
if (!res.ok) throw new Error(`HTTP ${res.status}`)
return res.json()
})
.then((data) => {
const required = data?.accessCodeRequired === true
localStorage.setItem(
STORAGE_KEYS.accessCodeRequired,
String(required),
)
setAccessCodeRequired(required)
})
.catch(() => {})
window.electronAPI?.getProxy?.().then((config: any) => {
setHttpProxy(config.httpProxy || "")
setHttpsProxy(config.httpsProxy || "")
})
}, [open])
const saveAccessCode = async () => {
setAccessError("")
setIsVerifying(true)
try {
const response = await fetch(
getApiEndpoint("/api/verify-access-code"),
{
method: "POST",
headers: { "x-access-code": accessCode.trim() },
},
)
const data = await response.json()
if (!data.valid) {
setAccessError(data.message || dict.errors.invalidAccessCode)
return
}
localStorage.setItem(STORAGE_KEYS.accessCode, accessCode.trim())
toast.success(t.accessCodeSaved)
} catch {
setAccessError(dict.errors.networkError)
} finally {
setIsVerifying(false)
}
}
const applyProxy = async () => {
if (!window.electronAPI?.setProxy) return
const isValid = (url: string) =>
!url || url.startsWith("http://") || url.startsWith("https://")
const http = httpProxy.trim()
const https = httpsProxy.trim()
if (!isValid(http) || !isValid(https)) {
toast.error(t.proxyInvalid)
return
}
setIsApplyingProxy(true)
try {
const result = await window.electronAPI.setProxy({
httpProxy: http || undefined,
httpsProxy: https || undefined,
})
if (result.success) toast.success(t.proxyApplied)
else toast.error(result.error || t.proxyFailed)
} catch {
toast.error(t.proxyFailed)
} finally {
setIsApplyingProxy(false)
}
}
return (
<div className="divide-y divide-border">
{accessCodeRequired && (
<Row
label={t.accessCode}
description={t.accessCodeDescription}
htmlFor="access-code"
stacked
>
<div className="flex gap-2">
<Input
id="access-code"
type="password"
value={accessCode}
onChange={(e) => setAccessCode(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter") {
e.preventDefault()
saveAccessCode()
}
}}
placeholder={t.accessCodePlaceholder}
autoComplete="off"
className="h-9"
/>
<Button
onClick={saveAccessCode}
disabled={isVerifying || !accessCode.trim()}
className="h-9 rounded-lg px-4"
>
{isVerifying ? "…" : dict.common.save}
</Button>
</div>
{accessError && (
<p className="text-xs text-destructive">
{accessError}
</p>
)}
</Row>
)}
<Row label={t.sendShortcut} description={t.sendShortcutDescription}>
<Select
value={settings.sendShortcut}
onValueChange={(value) =>
settings.setSendShortcut(value as SendShortcut)
}
>
<SelectTrigger
id="send-shortcut-select"
className="h-8 w-auto rounded-lg"
>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="enter">{t.enterToSend}</SelectItem>
<SelectItem value="ctrl-enter">
{t.ctrlEnterToSend}
</SelectItem>
</SelectContent>
</Select>
</Row>
<Row
label={t.diagramValidation}
description={t.diagramValidationDescription}
htmlFor="vlm-validation"
>
<Switch
id="vlm-validation"
checked={settings.vlmValidationEnabled}
onCheckedChange={settings.setVlmValidationEnabled}
/>
</Row>
<Row
label={t.maxOutputTokens}
description={t.maxOutputTokensDescription}
htmlFor="max-output-tokens"
>
<Input
id="max-output-tokens"
type="text"
inputMode="numeric"
value={settings.maxOutputTokens}
onChange={(e) =>
settings.setMaxOutputTokens(e.target.value)
}
placeholder="64000"
className="h-8 w-28 text-sm"
/>
</Row>
<Row
label={t.customSystemMessage}
description={t.customSystemMessageDescription}
htmlFor="custom-system-message"
stacked
>
<Textarea
id="custom-system-message"
value={settings.customSystemMessage}
onChange={(e) =>
settings.setCustomSystemMessage(e.target.value)
}
placeholder={t.customSystemMessagePlaceholder}
className="max-h-[180px] min-h-[96px] text-sm"
maxLength={5000}
/>
</Row>
{isElectron && (
<Row label={t.proxy} description={t.proxyDescription} stacked>
<div className="space-y-2">
<Input
id="http-proxy"
value={httpProxy}
onChange={(e) => setHttpProxy(e.target.value)}
placeholder={`${t.httpProxy}: http://proxy:8080`}
className="h-9"
/>
<Input
id="https-proxy"
value={httpsProxy}
onChange={(e) => setHttpsProxy(e.target.value)}
placeholder={`${t.httpsProxy}: http://proxy:8080`}
className="h-9"
/>
<Button
onClick={applyProxy}
disabled={isApplyingProxy}
className="h-9 w-full rounded-lg"
>
{isApplyingProxy ? "…" : t.applyProxy}
</Button>
</div>
</Row>
)}
</div>
)
}
function AboutTab() {
const dict = useDictionary()
const pathname = usePathname() || "/"
const lang = pathname.split("/").filter(Boolean)[0] || i18n.defaultLocale
const showAbout = process.env.NEXT_PUBLIC_SHOW_ABOUT_AND_NOTICE === "true"
const isSelfHosted = process.env.NEXT_PUBLIC_SELFHOSTED === "true"
const aboutPath = `/${lang}/about${lang === "zh" ? "/cn" : lang === "ja" ? "/ja" : ""}`
return (
<div className="space-y-5 pt-1">
<div className="flex items-center gap-3">
<BrandMark className="size-10 rounded-xl" />
<div>
<div className="text-[15px] font-semibold">
Next AI Draw.io
</div>
<div className="text-xs text-muted-foreground">
{dict.settings.appVersion} {process.env.APP_VERSION}
</div>
</div>
</div>
{/* MCP server card, hidden on self-hosted deployments */}
{!isSelfHosted && (
<a
href="https://github.com/DayuanJiang/next-ai-draw-io/tree/main/packages/mcp-server"
target="_blank"
rel="noopener noreferrer"
className="flex items-center gap-3 rounded-xl border border-border p-3.5 transition-colors hover:border-foreground/25"
>
<span className="inline-flex size-9 shrink-0 items-center justify-center rounded-lg bg-muted">
<Terminal className="size-4 text-muted-foreground" />
</span>
<span className="min-w-0">
<span className="block text-[13px] font-medium">
{dict.examples.mcpServer}
</span>
<span className="block text-xs text-muted-foreground">
{dict.examples.mcpDescription}
</span>
</span>
</a>
)}
<div className="flex flex-wrap gap-2">
<a
href="https://github.com/DayuanJiang/next-ai-draw-io"
target="_blank"
rel="noopener noreferrer"
className="inline-flex h-8 items-center gap-1.5 rounded-lg border border-border px-3 text-xs text-muted-foreground hover:text-foreground"
>
<Github className="size-3.5" />
GitHub
</a>
{showAbout && (
<a
href={aboutPath}
target="_blank"
rel="noopener noreferrer"
className="inline-flex h-8 items-center gap-1.5 rounded-lg border border-border px-3 text-xs text-muted-foreground hover:text-foreground"
>
<Info className="size-3.5" />
{dict.settings.projectWebsite}
</a>
)}
</div>
</div>
)
}
export function SettingsDialog() {
const dict = useDictionary()
const t = dict.settings
const engine = useChatEngine()
const open = useUiStore((s) => s.settingsOpen)
const setOpen = useUiStore((s) => s.setSettingsOpen)
const tab = useUiStore((s) => s.settingsTab)
const setTab = useUiStore((s) => s.setSettingsTab)
const tabs: { id: SettingsTab; label: string; icon: React.ReactNode }[] = [
{ id: "models", label: t.tabModels, icon: <KeyRound /> },
{ id: "appearance", label: t.tabAppearance, icon: <Palette /> },
{ id: "advanced", label: t.tabAdvanced, icon: <SlidersHorizontal /> },
{ id: "about", label: t.tabAbout, icon: <Info /> },
]
const current = tabs.find((item) => item.id === tab) ?? tabs[0]
return (
<Dialog open={open} onOpenChange={setOpen}>
<DialogContent className="flex h-[min(640px,88vh)] max-w-[calc(100%-1.5rem)] flex-col gap-0 overflow-hidden p-0 sm:max-w-4xl lg:flex-row">
{/* On top below 1024 px: beside the model settings' own
provider list it would leave too little width */}
<nav className="flex shrink-0 gap-1 overflow-x-auto border-b border-border bg-surface-1 p-2 lg:w-52 lg:flex-col lg:overflow-visible lg:border-r lg:border-b-0 lg:p-3">
<DialogTitle className="hidden px-2 pt-1 pb-3 text-[15px] lg:block">
{t.title}
</DialogTitle>
{tabs.map((item) => (
<button
key={item.id}
type="button"
onClick={() => setTab(item.id)}
className={cn(
"flex h-8 shrink-0 items-center gap-2 rounded-lg px-2.5 text-[13px] text-muted-foreground hover:text-foreground [&_svg]:size-4",
tab === item.id &&
"bg-card font-medium text-foreground shadow-float",
)}
data-testid={`settings-tab-${item.id}`}
>
{item.icon}
{item.label}
</button>
))}
</nav>
<section className="flex min-h-0 min-w-0 flex-1 flex-col">
<DialogDescription className="sr-only">
{t.description}
</DialogDescription>
{tab === "models" ? (
<div className="flex min-h-0 flex-1 flex-col">
<ModelConfigDialog
embedded
open={open}
onOpenChange={() => {}}
modelConfig={engine.modelConfig}
/>
</div>
) : (
<div className="min-h-0 flex-1 overflow-y-auto px-6 pt-5 pb-6 scrollbar-thin">
<h2 className="mb-2 text-[15px] font-semibold">
{current.label}
</h2>
{tab === "appearance" && <AppearanceTab />}
{tab === "advanced" && <AdvancedTab open={open} />}
{tab === "about" && <AboutTab />}
</div>
)}
</section>
</DialogContent>
</Dialog>
)
}
+56
View File
@@ -0,0 +1,56 @@
"use client"
import * as React from "react"
import { GripVerticalIcon } from "lucide-react"
import * as ResizablePrimitive from "react-resizable-panels"
import { cn } from "@/lib/utils"
function ResizablePanelGroup({
className,
...props
}: React.ComponentProps<typeof ResizablePrimitive.PanelGroup>) {
return (
<ResizablePrimitive.PanelGroup
data-slot="resizable-panel-group"
className={cn(
"flex h-full w-full data-[panel-group-direction=vertical]:flex-col",
className
)}
{...props}
/>
)
}
function ResizablePanel({
...props
}: React.ComponentProps<typeof ResizablePrimitive.Panel>) {
return <ResizablePrimitive.Panel data-slot="resizable-panel" {...props} />
}
function ResizableHandle({
withHandle,
className,
...props
}: React.ComponentProps<typeof ResizablePrimitive.PanelResizeHandle> & {
withHandle?: boolean
}) {
return (
<ResizablePrimitive.PanelResizeHandle
data-slot="resizable-handle"
className={cn(
"bg-border focus-visible:ring-ring relative flex w-px items-center justify-center after:absolute after:inset-y-0 after:left-1/2 after:w-1 after:-translate-x-1/2 focus-visible:ring-1 focus-visible:ring-offset-1 focus-visible:outline-hidden data-[panel-group-direction=vertical]:h-px data-[panel-group-direction=vertical]:w-full data-[panel-group-direction=vertical]:after:left-0 data-[panel-group-direction=vertical]:after:h-1 data-[panel-group-direction=vertical]:after:w-full data-[panel-group-direction=vertical]:after:translate-x-0 data-[panel-group-direction=vertical]:after:-translate-y-1/2 [&[data-panel-group-direction=vertical]>div]:rotate-90",
className
)}
{...props}
>
{withHandle && (
<div className="bg-border z-10 flex h-4 w-3 items-center justify-center rounded-xs border">
<GripVerticalIcon className="size-2.5" />
</div>
)}
</ResizablePrimitive.PanelResizeHandle>
)
}
export { ResizablePanelGroup, ResizablePanel, ResizableHandle }
-28
View File
@@ -1,28 +0,0 @@
"use client"
import { Toaster } from "sonner"
import { useSettingsStore } from "@/stores/settings-store"
/** The single toast container, mounted once at the root */
export function AppToaster() {
const isDark = useSettingsStore((s) => s.isDark)
return (
<Toaster
position="bottom-center"
// Show stacked toasts in full, so a later one never covers the
// button of an earlier one (e.g. "Continue without saving")
expand
theme={isDark ? "dark" : "light"}
offset={{ bottom: 20 }}
mobileOffset={{ bottom: 72 }}
toastOptions={{
duration: 2500,
style: { maxWidth: "480px" },
classNames: {
toast: "!rounded-xl !border-border !bg-card !text-foreground !shadow-pop",
description: "!text-muted-foreground",
},
}}
/>
)
}
-29
View File
@@ -1,29 +0,0 @@
"use client"
import { getAssetUrl } from "@/lib/base-path"
import { cn } from "@/lib/utils"
/** The app icon in a rounded tile; the white variant is used in dark mode */
export function BrandMark({ className }: { className?: string }) {
return (
<span
className={cn(
"relative inline-flex size-7 shrink-0 items-center justify-center overflow-hidden rounded-lg bg-card",
className,
)}
>
{/* biome-ignore lint/performance/noImgElement: tiny static icon */}
<img
src={getAssetUrl("/favicon-192x192.png")}
alt=""
className="size-full object-contain dark:hidden"
/>
{/* biome-ignore lint/performance/noImgElement: tiny static icon */}
<img
src={getAssetUrl("/favicon-white.svg")}
alt=""
className="hidden size-5 object-contain dark:block"
/>
</span>
)
}
-80
View File
@@ -1,80 +0,0 @@
"use client"
import type React from "react"
import { forwardRef } from "react"
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from "@/components/ui/tooltip"
import { cn } from "@/lib/utils"
interface IconButtonProps extends React.ComponentProps<"button"> {
label: string
/** Shown next to the label in the tooltip, e.g. "⌘B" */
shortcut?: string
active?: boolean
tooltipSide?: "top" | "bottom" | "left" | "right"
size?: "sm" | "md"
}
/** Square icon button with a tooltip; the tooltip text is also its label */
export const IconButton = forwardRef<HTMLButtonElement, IconButtonProps>(
function IconButton(
{
label,
shortcut,
active = false,
tooltipSide = "bottom",
size = "md",
className,
children,
...props
},
ref,
) {
return (
<Tooltip>
<TooltipTrigger asChild>
<button
ref={ref}
type="button"
aria-label={label}
aria-pressed={active || undefined}
className={cn(
"inline-flex shrink-0 items-center justify-center rounded-lg text-muted-foreground transition-colors",
"hover:bg-accent hover:text-foreground",
"focus-visible:outline-2 focus-visible:outline-offset-1 focus-visible:outline-ring",
"disabled:pointer-events-none disabled:opacity-35",
"[&_svg]:size-4 [&_svg]:stroke-[1.75]",
size === "md" ? "size-8" : "size-7",
// A tinted tile: solid ink is for the main action
active &&
"bg-interactive-active text-foreground hover:bg-interactive-active [&_svg]:stroke-2",
className,
)}
{...props}
>
{children}
</button>
</TooltipTrigger>
<TooltipContent
side={tooltipSide}
className="flex items-center gap-2"
>
{label}
{shortcut && (
<kbd className="font-sans text-[11px] opacity-60">
{shortcut}
</kbd>
)}
</TooltipContent>
</Tooltip>
)
},
)
/** Thin vertical divider between toolbar groups */
export function ToolbarDivider() {
return <span className="mx-1 h-5 w-px shrink-0 bg-border" aria-hidden />
}
-409
View File
@@ -1,409 +0,0 @@
"use client"
import { MessageSquare, PanelRightOpen, Settings2, Shapes } from "lucide-react"
import { usePathname, useRouter } from "next/navigation"
import { useEffect, useRef, useState } from "react"
import {
CanvasStage,
DIAGRAM_FILE_ACCEPT,
} from "@/components/canvas/canvas-stage"
import {
ChatEngineProvider,
useChatEngine,
} from "@/components/chat/chat-engine"
import { ChatPanel } from "@/components/chat/chat-panel"
import { CompareDialog } from "@/components/chat/compare-dialog"
import { LobbyHero } from "@/components/chat/lobby-hero"
import { SaveDialog } from "@/components/save-dialog"
import { SettingsDialog } from "@/components/settings/settings-dialog"
import { TooltipProvider } from "@/components/ui/tooltip"
import { BrandMark } from "@/components/workspace/brand-mark"
import { IconButton } from "@/components/workspace/icon-button"
import { useDiagram } from "@/contexts/diagram-context"
import { useDictionary } from "@/hooks/use-dictionary"
import {
debugCellIds,
debugView,
setAppShortcutHandler,
toggleShapesPanel,
} from "@/lib/drawio/editor-bridge"
import { i18n, type Locale } from "@/lib/i18n/config"
import { modKey } from "@/lib/platform"
import { STORAGE_KEYS } from "@/lib/storage"
import { cn, isRealDiagram } from "@/lib/utils"
import { useCanvasStore } from "@/stores/canvas-store"
import { useSettingsStore } from "@/stores/settings-store"
import { useUiStore } from "@/stores/ui-store"
import { useVersionsStore } from "@/stores/versions-store"
const MOBILE_BREAKPOINT = 768
// Gap between the floating panel and the window edge
const PANEL_GAP = 12
function useIsMobile() {
const [isMobile, setIsMobile] = useState(false)
useEffect(() => {
const query = window.matchMedia(
`(max-width: ${MOBILE_BREAKPOINT - 1}px)`,
)
const update = () => setIsMobile(query.matches)
update()
query.addEventListener("change", update)
return () => query.removeEventListener("change", update)
}, [])
return isMobile
}
/** Restore the language picked last time (the URL may say otherwise) */
function useSavedLocale() {
const router = useRouter()
const pathname = usePathname()
useEffect(() => {
const saved = localStorage.getItem(STORAGE_KEYS.locale)
if (!saved || !i18n.locales.includes(saved as Locale)) return
const parts = pathname.split("/").filter(Boolean)
if (parts[0] === saved) return
parts[0] = saved
const { search, hash } = window.location
router.replace(`/${parts.join("/")}${search}${hash}`)
}, [])
}
// Stores on window in development, for debugging and end-to-end tests
if (typeof window !== "undefined" && process.env.NODE_ENV === "development") {
;(window as any).__naiStores = {
canvas: useCanvasStore,
versions: useVersionsStore,
ui: useUiStore,
settings: useSettingsStore,
cellIds: debugCellIds,
view: debugView,
}
}
/** The diagram's title as a file name, or diagram-YYYY-MM-DD without one */
export function exportFilename(title: string | null): string {
const name = (title && title !== "New Chat" ? title : "")
.replace(/[\\/:*?"<>|]+/g, "-")
.replace(/\s+/g, " ")
.trim()
.slice(0, 80)
// Local date (toISOString would give UTC)
return name || `diagram-${new Date().toLocaleDateString("sv-SE")}`
}
/** The whole editor: canvas, chat panel, dialogs */
export function Workspace() {
const hydrate = useSettingsStore((s) => s.hydrate)
useEffect(() => {
hydrate()
}, [hydrate])
useSavedLocale()
return (
<TooltipProvider delayDuration={350}>
<ChatEngineProvider>
<WorkspaceLayout />
</ChatEngineProvider>
</TooltipProvider>
)
}
function WorkspaceLayout() {
const dict = useDictionary()
const engine = useChatEngine()
const { chartXML, saveDiagramToFile } = useDiagram()
const isMobile = useIsMobile()
const panelWidth = useSettingsStore((s) => s.panelWidth)
const {
panelOpen,
togglePanel,
setPanelOpen,
mobileView,
setMobileView,
openSettings,
focusComposer,
saveDialogOpen,
setSaveDialogOpen,
} = useUiStore()
const [heroDismissed, setHeroDismissed] = useState(false)
const fileInputRef = useRef<HTMLInputElement>(null)
// Start screen: nothing said and nothing drawn yet
const showHero =
engine.isRestored &&
engine.messages.length === 0 &&
!isRealDiagram(chartXML) &&
!heroDismissed
// A new conversation brings the start screen back (a new chat gets a
// new tracing id, also when it was empty before)
const chatId = engine.langfuseSessionId
useEffect(() => {
setHeroDismissed(false)
}, [chatId])
const messageCount = engine.messages.length
const wasEmptyRef = useRef(true)
useEffect(() => {
if (messageCount > 0) wasEmptyRef.current = false
else if (!wasEmptyRef.current) {
wasEmptyRef.current = true
setHeroDismissed(false)
}
}, [messageCount])
// Phones: show the canvas while the AI draws and when a new version
// lands, so the result is visible; the chat tab gets a dot meanwhile
const isBusy = engine.isBusy
const versionCount = useVersionsStore((s) => s.versions.length)
const [chatUnread, setChatUnread] = useState(false)
const lastVersionCountRef = useRef(versionCount)
useEffect(() => {
if (isMobile && isBusy) setMobileView("canvas")
}, [isMobile, isBusy, setMobileView])
useEffect(() => {
const grew = versionCount > lastVersionCountRef.current
lastVersionCountRef.current = versionCount
if (isMobile && grew && engine.isRestored) {
setMobileView("canvas")
setChatUnread(true)
}
}, [versionCount, isMobile, engine.isRestored, setMobileView])
useEffect(() => {
if (mobileView === "chat") setChatUnread(false)
}, [mobileView])
// ⌘B toggles the panel; ⌘/ asks the AI about the selection; ⌘⇧K
// shows the shape library
useEffect(() => {
const handle = (event: KeyboardEvent) => {
if (!(event.metaKey || event.ctrlKey)) return false
if (event.shiftKey && (event.key === "k" || event.key === "K")) {
toggleShapesPanel()
return true
}
if (event.key === "b" || event.key === "B") {
if (isMobile) return false
togglePanel()
return true
}
if (event.key === "/") {
setHeroDismissed(true)
focusComposer()
return true
}
return false
}
const onKeyDown = (event: KeyboardEvent) => {
if (handle(event)) event.preventDefault()
}
// draw.io takes focus when it (re)loads, e.g. after a language
// switch; Escape typed there must still close our dialogs.
// ⌘B stays with draw.io in the canvas (bold, and ⇧ for to back).
const handleInFrame = (event: KeyboardEvent) => {
if (event.key === "b" || event.key === "B") return false
if (event.key === "Escape") {
const ui = useUiStore.getState()
if (
ui.settingsOpen ||
ui.compareVersionId ||
ui.saveDialogOpen
) {
ui.setSettingsOpen(false)
ui.closeCompare()
ui.setSaveDialogOpen(false)
return true
}
}
return handle(event)
}
window.addEventListener("keydown", onKeyDown)
setAppShortcutHandler(handleInFrame)
return () => {
window.removeEventListener("keydown", onKeyDown)
setAppShortcutHandler(null)
}
}, [isMobile, togglePanel, focusComposer])
const openFile = () => fileInputRef.current?.click()
const dismissHero = () => {
setHeroDismissed(true)
setPanelOpen(true)
}
const panelVisible = !isMobile && !showHero && panelOpen
// The panel floats on the right; the canvas ends where it begins
const canvasRight = panelVisible ? panelWidth + PANEL_GAP * 2 : 0
return (
<div
className="fixed inset-0 overflow-hidden bg-canvas"
data-testid="workspace"
>
{/* One canvas for both layouts: a new one would reload draw.io
and lose its undo history */}
<div className="absolute inset-0 flex flex-col">
<div className="relative min-h-0 flex-1">
<CanvasStage
className={
isMobile
? cn(
"inset-0",
mobileView !== "canvas" && "invisible",
)
: "inset-y-0 left-0 transition-[right] duration-300 ease-[var(--ease-out)]"
}
style={isMobile ? undefined : { right: canvasRight }}
compact={isMobile}
hideOverlays={!isMobile && showHero}
/>
{isMobile ? (
mobileView === "chat" &&
(showHero ? (
<div className="absolute inset-0 overflow-y-auto bg-canvas">
<div className="flex items-center gap-2 px-4 pt-3">
<BrandMark className="size-6" />
<span className="mr-auto text-[13px] font-semibold tracking-[-0.01em]">
Next AI Draw.io
</span>
<IconButton
label={dict.nav.settings}
onClick={() => openSettings()}
data-testid="settings-button"
>
<Settings2 />
</IconButton>
</div>
<LobbyHero
compact
onOpenFile={openFile}
onDrawYourself={() => {
setHeroDismissed(true)
setMobileView("canvas")
}}
/>
</div>
) : (
<ChatPanel mobile onOpenFile={openFile} />
))
) : (
<>
{panelVisible && (
<ChatPanel onOpenFile={openFile} />
)}
{!showHero && !panelOpen && (
<div className="absolute top-3 right-3 z-20 flex items-center gap-1 rounded-xl bg-card p-1 shadow-float animate-fade-in">
<BrandMark className="mx-0.5 size-6" />
<IconButton
label={dict.nav.showPanel}
shortcut={`${modKey}B`}
onClick={() => setPanelOpen(true)}
data-testid="show-panel"
>
<PanelRightOpen />
</IconButton>
</div>
)}
{showHero && (
<>
<div className="absolute inset-0 z-10 overflow-y-auto bg-canvas">
<div className="flex min-h-full items-center">
<LobbyHero
onOpenFile={openFile}
onDrawYourself={dismissHero}
/>
</div>
</div>
<div className="absolute top-3 left-3 z-20 flex items-center gap-1.5 rounded-xl bg-card py-1 pr-1 pl-1.5 shadow-float">
<BrandMark className="size-6" />
<span className="pr-1 text-[13px] font-semibold tracking-[-0.01em]">
Next AI Draw.io
</span>
<IconButton
label={dict.nav.settings}
onClick={() => openSettings()}
data-testid="settings-button"
>
<Settings2 />
</IconButton>
</div>
</>
)}
</>
)}
</div>
{isMobile && (
<nav className="flex shrink-0 items-center justify-center gap-1 border-t border-border bg-card px-3 pt-1.5 pb-[max(6px,env(safe-area-inset-bottom))]">
{(
[
{
view: "canvas",
label: dict.workspace.mobileCanvas,
icon: <Shapes />,
},
{
view: "chat",
label: dict.workspace.mobileChat,
icon: <MessageSquare />,
},
] as const
).map((item) => (
<button
key={item.view}
type="button"
onClick={() => setMobileView(item.view)}
aria-pressed={mobileView === item.view}
className={cn(
"flex h-10 flex-1 items-center justify-center gap-2 rounded-xl text-[13px] text-muted-foreground [&_svg]:size-4",
mobileView === item.view &&
"bg-accent font-medium text-foreground",
)}
>
{item.icon}
{item.label}
{item.view === "chat" &&
(isBusy || chatUnread) &&
mobileView !== "chat" && (
<span className="size-1.5 rounded-full bg-marker" />
)}
</button>
))}
</nav>
)}
</div>
<input
ref={fileInputRef}
type="file"
accept={DIAGRAM_FILE_ACCEPT}
className="hidden"
onChange={(event) => {
const file = event.target.files?.[0]
if (file) {
setHeroDismissed(true)
engine.openDiagramFile(file)
}
event.target.value = ""
}}
/>
<SettingsDialog />
<CompareDialog />
<SaveDialog
open={saveDialogOpen}
onOpenChange={setSaveDialogOpen}
onSave={(filename, format) =>
saveDiagramToFile(
filename,
format,
engine.langfuseSessionId,
dict.save.savedSuccessfully,
)
}
defaultFilename={exportFilename(engine.currentTitle)}
/>
</div>
)
}
+86 -123
View File
@@ -2,21 +2,10 @@
import type React from "react"
import { createContext, useCallback, useContext, useRef, useState } from "react"
import type { DrawIoEmbedRef, EventExport } from "react-drawio"
import { toast } from "sonner"
import type {
DrawioExportEvent,
DrawioFrameHandle,
} from "@/components/canvas/drawio-frame"
import type { ExportFormat } from "@/components/save-dialog"
import { getApiEndpoint } from "@/lib/base-path"
import { withPageDefaults } from "@/lib/drawio/drawio-config"
import {
canReplaceDiagram,
commitDiagram,
previewDiagram,
resetPreview,
revertPreview,
} from "@/lib/drawio/editor-bridge"
import {
BLANK_MXFILE,
normalizeToMxfile,
@@ -24,45 +13,24 @@ import {
import { validateAndFixXml } from "@/packages/mcp-server/src/xml-validation.ts"
import { extractDiagramXML, isRealDiagram } from "../lib/utils"
/**
* How a diagram reaches the canvas:
* - load: replace the whole document and reset undo (sessions, new chat)
* - preview: streaming AI output, not recorded in undo history
* - commit: final AI result, one undo step; reported to commit listeners
* - revert: drop the streaming preview and go back to the given diagram
*
* preview/commit/revert go through the draw.io editor when it is reachable
* (same origin); otherwise they fall back to a full load.
*/
export type LoadMode = "load" | "preview" | "commit" | "revert"
export interface DiagramCommit {
beforeXml: string
afterXml: string
toolCallId?: string
}
interface DiagramContextType {
chartXML: string
// chartXML right away, before the re-render (loadDiagram sets both)
chartXMLRef: React.RefObject<string>
chartXMLRef: React.MutableRefObject<string>
latestSvg: string
loadDiagram: (
chart: string,
skipValidation?: boolean,
mode?: LoadMode,
meta?: { toolCallId?: string },
) => string | null
diagramHistory: { svg: string; xml: string }[]
setDiagramHistory: (history: { svg: string; xml: string }[]) => void
loadDiagram: (chart: string, skipValidation?: boolean) => string | null
// Both return the export's tag (empty when draw.io is not there yet)
handleExport: () => string
handleExportWithoutHistory: () => string
// Pending exports by tag; a plain export's resolver gets the first
// page's XML
exportResolversRef: React.RefObject<
// Pending exports by tag; a history or plain export's resolver gets the
// first page's XML
exportResolversRef: React.MutableRefObject<
Record<string, (data: string, xml?: string) => void>
>
drawioRef: React.RefObject<DrawioFrameHandle | null>
handleDiagramExport: (data: DrawioExportEvent) => void
drawioRef: React.MutableRefObject<DrawIoEmbedRef | null>
handleDiagramExport: (data: EventExport) => void
handleDiagramAutoSave: (data: { xml?: string }) => void
clearDiagram: () => void
saveDiagramToFile: (
@@ -72,17 +40,12 @@ interface DiagramContextType {
successMessage?: string,
) => void
getThumbnailSvg: () => Promise<string | null>
getVersionSvg: () => Promise<string | null>
captureValidationPng: () => Promise<string | null>
isDrawioReady: boolean
onDrawioLoad: () => void
resetDrawioReady: () => void
/** Register the handler told about every committed AI change */
setCommitHandler: (
handler: ((commit: DiagramCommit) => void) | null,
) => void
/** A new user turn starts: forget the streaming base */
startTurn: () => void
showSaveDialog: boolean
setShowSaveDialog: (show: boolean) => void
}
const DiagramContext = createContext<DiagramContextType | undefined>(undefined)
@@ -91,33 +54,34 @@ const DiagramContext = createContext<DiagramContextType | undefined>(undefined)
// echoes the request back in the export event, so each result reaches its
// own caller. Tags end in a request number, so a late result never answers
// a newer request.
type ExportTag = "thumbnail" | "validation" | "version"
type ExportTag = "thumbnail" | "validation"
export function DiagramProvider({ children }: { children: React.ReactNode }) {
const [chartXML, setChartXML] = useState<string>("")
const [latestSvg, setLatestSvg] = useState<string>("")
const [diagramHistory, setDiagramHistory] = useState<
{ svg: string; xml: string }[]
>([])
const [isDrawioReady, setIsDrawioReady] = useState(false)
const [showSaveDialog, setShowSaveDialog] = useState(false)
const hasCalledOnLoadRef = useRef(false)
const drawioRef = useRef<DrawioFrameHandle | null>(null)
const drawioRef = useRef<DrawIoEmbedRef | null>(null)
// Pending exports, keyed by their export tag
const exportResolversRef = useRef<
Record<string, (data: string, xml?: string) => void>
>({})
// Pending history exports: the document each one was asked for
const historyXmlRef = useRef(new Map<string, string>())
const exportSeqRef = useRef(0)
// Track latest chartXML for restoration after remount
const chartXMLRef = useRef<string>("")
// Diagram before the current AI change started streaming
const turnBaseRef = useRef<string | null>(null)
const commitHandlerRef = useRef<((commit: DiagramCommit) => void) | null>(
null,
)
const onDrawioLoad = () => {
// Only set ready state once to prevent infinite loops
if (hasCalledOnLoadRef.current) return
hasCalledOnLoadRef.current = true
setIsDrawioReady(true)
// Restore diagram after remount (e.g., language or theme change)
// Restore diagram after remount (e.g., theme/UI change)
if (drawioRef.current && isRealDiagram(chartXMLRef.current)) {
drawioRef.current.load({ xml: chartXMLRef.current })
}
@@ -135,22 +99,36 @@ export function DiagramProvider({ children }: { children: React.ReactNode }) {
setChartXML(xml)
}
// The chat's export of the current diagram (onFetchChart)
const handleExport = () => {
if (!drawioRef.current) return ""
const tag = `fetch-${++exportSeqRef.current}`
drawioRef.current.exportDiagram({ format: "xmlsvg", message: tag })
// Save this export to history, with the document shown now:
// chartXML can change before the result comes back
const tag = `history-${++exportSeqRef.current}`
historyXmlRef.current.set(tag, chartXMLRef.current)
drawioRef.current.exportDiagram({
format: "xmlsvg",
message: tag,
})
return tag
}
const handleExportWithoutHistory = handleExport
const handleExportWithoutHistory = () => {
if (!drawioRef.current) return ""
// Export without saving to history (for edit_diagram fetching current state)
const tag = `fetch-${++exportSeqRef.current}`
drawioRef.current.exportDiagram({
format: "xmlsvg",
message: tag,
})
return tag
}
// Export with a tag in `message` (draw.io echoes it back in the export
// event) and wait for that result. Resolves to null on timeout, which is
// expected occasionally.
// (Reads refs only, so it keeps one identity)
const requestTaggedExport = useCallback(
(tag: ExportTag, format: "xmlsvg" | "svg" | "png", timeoutMs: number) =>
(tag: ExportTag, format: "xmlsvg" | "png", timeoutMs: number) =>
new Promise<string | null>((resolve) => {
const id = `${tag}-${++exportSeqRef.current}`
const finish = (value: string | null) => {
@@ -171,38 +149,33 @@ export function DiagramProvider({ children }: { children: React.ReactNode }) {
// the next save
const getThumbnailSvg = useCallback(async (): Promise<string | null> => {
if (!drawioRef.current) return null
// Don't export if diagram is empty
if (!isRealDiagram(chartXMLRef.current)) return null
// xmlsvg exports return an SVG data URL
const svgData = await requestTaggedExport("thumbnail", "xmlsvg", 3000)
if (svgData?.startsWith("data:image/svg")) {
// Update latestSvg so it's available for future saves
setLatestSvg(svgData)
return svgData
}
return null
}, [requestTaggedExport])
// Plain SVG (no embedded diagram data) for version cards
const getVersionSvg = async (): Promise<string | null> => {
if (!drawioRef.current) return null
const svgData = await requestTaggedExport("version", "svg", 4000)
return svgData?.startsWith("data:image/svg") ? svgData : null
}
// Capture current diagram as PNG for VLM validation
const captureValidationPng = async (): Promise<string | null> => {
if (!drawioRef.current) return null
// Don't export if diagram is empty
if (!isRealDiagram(chartXMLRef.current)) return null
const pngData = await requestTaggedExport("validation", "png", 5000)
// PNG data should be a base64 data URL
return pngData?.startsWith("data:image/png") ? pngData : null
}
const loadDiagram = (
chart: string,
skipValidation?: boolean,
mode: LoadMode = "load",
meta?: { toolCallId?: string },
): string | null => {
let xmlToLoad = chart
@@ -228,56 +201,23 @@ export function DiagramProvider({ children }: { children: React.ReactNode }) {
}
}
xmlToLoad = withPageDefaults(xmlToLoad)
const previousXml = chartXMLRef.current
if (mode === "preview" && turnBaseRef.current === null) {
turnBaseRef.current = previousXml
}
const beforeXml = turnBaseRef.current ?? previousXml
if (mode !== "preview") turnBaseRef.current = null
// Keep chartXML in sync even when diagrams are injected (e.g., display_diagram tool)
updateChartXML(xmlToLoad)
let applied = false
if (mode !== "load" && canReplaceDiagram(xmlToLoad)) {
try {
if (mode === "preview") previewDiagram(xmlToLoad)
else if (mode === "commit") commitDiagram(xmlToLoad)
else revertPreview(xmlToLoad)
applied = true
} catch (error) {
console.warn("[loadDiagram] Editor update failed:", error)
}
}
if (!applied) {
// A full load replaces any preview, and its base is stale now
resetPreview()
drawioRef.current?.load({ xml: xmlToLoad })
}
if (mode === "commit") {
commitHandlerRef.current?.({
beforeXml,
afterXml: xmlToLoad,
toolCallId: meta?.toolCallId,
if (drawioRef.current) {
drawioRef.current.load({
xml: xmlToLoad,
})
}
return null
}
const startTurn = () => {
turnBaseRef.current = null
resetPreview()
}
const handleDiagramExport = (data: DrawioExportEvent) => {
// Thumbnail, version, validation PNG and file save exports go only
// to their own caller
const handleDiagramExport = (data: EventExport) => {
// Thumbnail, validation PNG and file save exports go only to their
// own caller
const tag = data.message?.message
if (/^(thumbnail|validation|version|save)-/.test(tag ?? "")) {
if (/^(thumbnail|validation|save)-/.test(tag ?? "")) {
exportResolversRef.current[tag as string]?.(data.data, data.xml)
return
}
@@ -289,6 +229,29 @@ export function DiagramProvider({ children }: { children: React.ReactNode }) {
const extractedXML = extractDiagramXML(data.data)
setLatestSvg(data.data)
// Only add to history if this was a user-initiated export
// Limit to 20 entries to prevent memory leaks during long sessions
const MAX_HISTORY_SIZE = 20
const askedXml =
tag !== undefined ? historyXmlRef.current.get(tag) : undefined
if (askedXml !== undefined) {
historyXmlRef.current.delete(tag as string)
// Store the full multi-page document (extractedXML is only the
// first page), so restoring a version keeps every page
const historyXml = askedXml || extractedXML
setDiagramHistory((prev) => {
const newHistory = [
...prev,
{
svg: data.data,
xml: historyXml,
},
]
// Keep only the last MAX_HISTORY_SIZE entries (circular buffer)
return newHistory.slice(-MAX_HISTORY_SIZE)
})
}
// The chat's own export (onFetchChart), not another one in flight
const resolve =
tag !== undefined ? exportResolversRef.current[tag] : undefined
@@ -298,7 +261,7 @@ export function DiagramProvider({ children }: { children: React.ReactNode }) {
}
}
// The frame registers this callback once per iframe mount, so it must
// react-drawio registers this callback once per iframe mount, so it must
// read refs: state captured in its closure would stay stale after a remount
const handleDiagramAutoSave = (data: { xml?: string }) => {
if (!data?.xml) return
@@ -311,9 +274,11 @@ export function DiagramProvider({ children }: { children: React.ReactNode }) {
}
const clearDiagram = () => {
const emptyDiagram = BLANK_MXFILE
// Skip validation for trusted internal template (loadDiagram also sets chartXML)
loadDiagram(BLANK_MXFILE, true)
loadDiagram(emptyDiagram, true)
setLatestSvg("")
setDiagramHistory([])
}
const saveDiagramToFile = (
@@ -396,7 +361,10 @@ export function DiagramProvider({ children }: { children: React.ReactNode }) {
// Show success toast after download is initiated
if (successMessage) {
toast.success(successMessage, { duration: 2500 })
toast.success(successMessage, {
position: "bottom-left",
duration: 2500,
})
}
// Delay URL revocation to ensure download completes
@@ -429,18 +397,14 @@ export function DiagramProvider({ children }: { children: React.ReactNode }) {
}
}
const setCommitHandler = (
handler: ((commit: DiagramCommit) => void) | null,
) => {
commitHandlerRef.current = handler
}
return (
<DiagramContext.Provider
value={{
chartXML,
chartXMLRef,
latestSvg,
diagramHistory,
setDiagramHistory,
loadDiagram,
handleExport,
handleExportWithoutHistory,
@@ -451,13 +415,12 @@ export function DiagramProvider({ children }: { children: React.ReactNode }) {
clearDiagram,
saveDiagramToFile,
getThumbnailSvg,
getVersionSvg,
captureValidationPng,
isDrawioReady,
onDrawioLoad,
resetDrawioReady,
setCommitHandler,
startTurn,
showSaveDialog,
setShowSaveDialog,
}}
>
{children}
+8 -4
View File
@@ -1,11 +1,14 @@
services:
drawio:
image: jgraph/drawio:latest
ports: ["8080:8080"]
next-ai-draw-io:
build:
context: .
# draw.io is bundled into the image (public/drawio), no separate container needed
# Uncomment below for subdirectory deployment
# args:
# - NEXT_PUBLIC_BASE_PATH=/nextaidrawio
args:
- NEXT_PUBLIC_DRAWIO_BASE_URL=http://localhost:8080
# Uncomment below for subdirectory deployment
# - NEXT_PUBLIC_BASE_PATH=/nextaidrawio
ports: ["3000:3000"]
env_file: .env
volumes:
@@ -14,3 +17,4 @@ services:
# environment:
# # For subdirectory deployment, uncomment and set your path:
# NEXT_PUBLIC_BASE_PATH: /nextaidrawio
depends_on: [drawio]
+2 -4
View File
@@ -18,11 +18,9 @@
**问题**: 内网环境提示"找不到 embed.diagrams.net 的服务器 IP 地址"
**关键点**: 应用现在自带一份 draw.io,从 `/drawio` 提供,浏览器不再需要访问 `embed.diagrams.net`。如果仍然看到这个错误,说明镜像是用 `NEXT_PUBLIC_DRAWIO_BASE_URL=https://embed.diagrams.net`(旧版 Dockerfile 的默认值)构建的,去掉这个构建参数重新构建即可。详见[离线部署](./offline-deployment.md)。
**关键点**: `NEXT_PUBLIC_*` 环境变量是**构建时**变量,会被打包到 JS 代码中,**运行时设置无效**!
`NEXT_PUBLIC_*` 环境变量是**构建时**变量,会被打包到 JS 代码中,**运行时设置无效**!
**如果仍想使用单独的 draw.io 服务器**,在构建时通过 `args` 传入:
**解决方案**: 必须在构建时通过 `args` 传入:
```yaml
# docker-compose.yml
+2 -3
View File
@@ -93,8 +93,7 @@ https://github.com/user-attachments/assets/b2eef5f3-b335-4e71-a755-dc2e80931979
- **基于图像的图表复制**:上传现有图表或图像,让AI自动复制和增强
- **PDF和文本文件上传**:上传PDF文档和文本文件,提取内容并从现有文档生成图表
- **AI推理过程显示**:查看支持模型的AI思考过程(OpenAI o1/o3、Gemini、Claude等)
- **版本与撤销**:AI 每改一次图,聊天里就多一张带缩略图的版本卡片,可以和画布对比、恢复,也可以撤销这次修改;在画布上按 Ctrl+Z 也能一步撤回 AI 的修改。AI 刚改过的图形会在画布上标出来
- **针对选中图形提问**:在画布上选中图形,让 AI 只改这几个
- **图表历史记录**:全面的版本控制,跟踪所有更改,允许您查看和恢复AI编辑前的图表版本
- **交互式聊天界面**:与AI实时对话来完善您的图表
- **云架构图支持**:专门支持生成云架构图(AWS、GCP、Azure)
- **动画连接器**:在图表元素之间创建动态动画连接器,实现更好的可视化效果
@@ -244,7 +243,7 @@ npm run dev
- **Next.js**:用于前端框架和路由
- **Vercel AI SDK**(`ai` + `@ai-sdk/*`):用于流式AI响应和多提供商支持
- **draw.io**:编辑器使用打包在 `public/drawio` 里的副本(由 `npm run dev` / `npm run build` 自动下载),应用可以直接控制它
- **react-drawio**:用于图表表示和操作
图表以XML格式表示,可在draw.io中渲染。AI处理您的命令并相应地生成或修改此XML。
+1 -3
View File
@@ -46,8 +46,6 @@ AI_MODEL=gpt-4o
OPENAI_BASE_URL=https://your-custom-endpoint/v1
```
LM Studio 等本地 OpenAI 兼容服务也用同样的方式:把 base URL 设为 `http://localhost:1234/v1`(LM Studio 的默认端口),API 密钥填任意非空值即可,例如 `lm-studio`。在上面的环境变量或模型设置里配置都可以。
### AIHubMix
AIHubMix 通过单个 API Key 聚合 Claude、GPT、Gemini、DeepSeek 等模型。
@@ -194,7 +192,7 @@ MODELSCOPE_BASE_URL=https://your-custom-endpoint
可选的自定义 URL:
```bash
OLLAMA_BASE_URL=http://localhost:11434/api
OLLAMA_BASE_URL=http://localhost:11434
```
### Vercel AI Gateway
+7 -17
View File
@@ -1,25 +1,13 @@
# 离线部署
Next AI Draw.io 自带一份 draw.io。`npm run build`(包括 Docker 构建)会把 draw.io 的发布包下载到 `public/drawio`,只下载一次,应用从 `/drawio` 提供它。使用时浏览器不会访问 `embed.diagrams.net`,所以只要构建时能联网,应用就能在离线网络或内网里使用。
通过自托管 draw.io 来替代 `embed.diagrams.net`,从而离线部署 Next AI Draw.io。
**注意:** `NEXT_PUBLIC_DRAWIO_BASE_URL` 是一个**构建时**变量。修改它需要重新构建 Docker 镜像。
## Docker Compose 设置
1. 克隆仓库并在 `.env` 文件中定义 API 密钥。
2. 运行 `docker compose up -d`(使用仓库里的 `docker-compose.yml`)。
3. 打开 `http://localhost:3000`。
不需要单独的 draw.io 容器。
## 构建机器不能联网时
构建会从 [draw.io 发布页](https://github.com/jgraph/drawio/releases) 下载 `draw.war`(约 50 MB)。如果构建机器访问不了 GitHub:
- 在另一台机器上下载 `scripts/fetch-drawio.mjs` 里写明的那个版本的 `draw.war`,解压到 `public/drawio`(删掉 `WEB-INF` 和 `META-INF`),再把版本号(例如 `v32.0.2`)写进 `public/drawio/.version`。构建时就会使用这份副本。
- 或者在能联网的机器上构建镜像,再拷到离线网络里。
## 使用单独的 draw.io 服务器(可选)
仍然可以用构建时变量 `NEXT_PUBLIC_DRAWIO_BASE_URL` 让应用使用别处的 draw.io,比如 `jgraph/drawio` 镜像:
2. 创建 `docker-compose.yml`:
```yaml
services:
@@ -36,7 +24,9 @@ services:
depends_on: [drawio]
```
使用外部 draw.io 时,应用改用 draw.io 自己的工具栏,以下功能不可用:标出 AI 改了哪些图形、针对选中的图形提问、应用自己的画布工具栏、用 Ctrl+Z 撤回 AI 的修改。浏览器不允许页面控制来自其他域名的编辑器。
3. 运行 `docker compose up -d` 并打开 `http://localhost:3000`。
## 配置与重要警告
**`NEXT_PUBLIC_DRAWIO_BASE_URL` 必须是用户浏览器可访问的地址。**
+2 -4
View File
@@ -18,11 +18,9 @@
**Problem**: Intranet environment shows "Cannot find server IP address for embed.diagrams.net"
**Key Point**: The app now ships with its own copy of draw.io, served from `/drawio`, so the browser no longer needs `embed.diagrams.net`. If you still see this error, the image was built with `NEXT_PUBLIC_DRAWIO_BASE_URL=https://embed.diagrams.net` (the old Dockerfile default). Rebuild without that build argument. See [Offline Deployment](./offline-deployment.md).
**Key Point**: `NEXT_PUBLIC_*` environment variables are **build-time** variables, they get bundled into JS code. **Runtime settings don't work!**
`NEXT_PUBLIC_*` environment variables are **build-time** variables, they get bundled into JS code. **Runtime settings don't work!**
**To keep a separate draw.io server instead**, pass it via `args` at build time:
**Solution**: Must pass via `args` at build time:
```yaml
# docker-compose.yml
+1 -3
View File
@@ -61,8 +61,6 @@ Optional custom endpoint (for OpenAI-compatible services):
OPENAI_BASE_URL=https://your-custom-endpoint/v1
```
LM Studio and other local OpenAI-compatible servers work the same way: set the base URL to `http://localhost:1234/v1` (LM Studio's default port) and use any non-empty API key, such as `lm-studio`. This works both in the environment variables above and in the model settings.
### AIHubMix
AIHubMix provides access to Claude, GPT, Gemini, DeepSeek, and other models through a single API key.
@@ -196,7 +194,7 @@ AI_MODEL=llama3.2
Optional custom URL:
```bash
OLLAMA_BASE_URL=http://localhost:11434/api
OLLAMA_BASE_URL=http://localhost:11434
```
### ModelScope
+8 -17
View File
@@ -1,25 +1,13 @@
# Offline Deployment
Next AI Draw.io ships with its own copy of draw.io. `npm run build` (and the Docker build) downloads the draw.io release into `public/drawio` once, and the app serves it from `/drawio`. In use, the browser never contacts `embed.diagrams.net`, so the app works on an offline or intranet network as long as the build had internet access.
Deploy Next AI Draw.io offline by self-hosting draw.io to replace `embed.diagrams.net`.
**Note:** `NEXT_PUBLIC_DRAWIO_BASE_URL` is a **build-time** variable. Changing it requires rebuilding the Docker image.
## Docker Compose Setup
1. Clone the repository and define API keys in `.env`.
2. Run `docker compose up -d` (it uses the `docker-compose.yml` in the repository).
3. Open `http://localhost:3000`.
No separate draw.io container is needed.
## Building Without Internet Access
The build downloads `draw.war` (about 50 MB) from the [draw.io releases](https://github.com/jgraph/drawio/releases). If the build machine cannot reach GitHub:
- On another machine, download `draw.war` for the version in `scripts/fetch-drawio.mjs`, unzip it into `public/drawio` (delete `WEB-INF` and `META-INF`), and write the version, for example `v32.0.2`, into `public/drawio/.version`. The build then uses this copy.
- Or build the image on a machine with internet access and transfer it to the offline network.
## Using a Separate draw.io Server (Optional)
You can still point the app at another draw.io, such as the `jgraph/drawio` image, with the build-time variable `NEXT_PUBLIC_DRAWIO_BASE_URL`:
2. Create `docker-compose.yml`:
```yaml
services:
@@ -36,7 +24,9 @@ services:
depends_on: [drawio]
```
With an external draw.io the app uses draw.io's own toolbar, and these features are off: highlighting what the AI changed, asking about selected shapes, the app's canvas toolbar, and undoing AI changes with Ctrl+Z. Browsers do not let a page control an editor served from a different origin.
3. Run `docker compose up -d` and open `http://localhost:3000`.
## Configuration & Critical Warning
**The `NEXT_PUBLIC_DRAWIO_BASE_URL` must be accessible from the user's browser.**
@@ -46,3 +36,4 @@ With an external draw.io the app uses draw.io's own toolbar, and these features
| Remote/Server | `http://YOUR_SERVER_IP:8080` |
**Do NOT use** internal Docker aliases like `http://drawio:8080`; the browser cannot resolve them.
+2 -4
View File
@@ -18,11 +18,9 @@
**問題**: イントラネット環境で「embed.diagrams.netのサーバーIPアドレスが見つかりません」と表示される
**重要**: アプリには draw.io のコピーが同梱され、`/drawio` から配信されるため、ブラウザは `embed.diagrams.net` にアクセスしなくなりました。それでもこのエラーが出る場合は、イメージが `NEXT_PUBLIC_DRAWIO_BASE_URL=https://embed.diagrams.net`(以前の Dockerfile の既定値)でビルドされています。このビルド引数を外して再ビルドしてください。詳しくは[オフラインデプロイ](./offline-deployment.md)を参照してください。
**重要**: `NEXT_PUBLIC_*` 環境変数は**ビルド時**変数であり、JSコードにバンドルされます。**実行時の設定は無効です!**
`NEXT_PUBLIC_*` 環境変数は**ビルド時**変数であり、JSコードにバンドルされます。**実行時の設定は無効です!**
**別の draw.io サーバーを使い続ける場合**は、ビルド時に `args` で渡します:
**解決策**: ビルド時に `args` で渡す必要があります:
```yaml
# docker-compose.yml
+2 -3
View File
@@ -91,8 +91,7 @@ https://github.com/user-attachments/assets/b2eef5f3-b335-4e71-a755-dc2e80931979
- **画像ベースのダイアグラム複製**:既存のダイアグラムや画像をアップロードし、AIが自動的に複製・強化
- **PDFとテキストファイルのアップロード**:PDFドキュメントやテキストファイルをアップロードして、既存のドキュメントからコンテンツを抽出し、ダイアグラムを生成
- **AI推論プロセス表示**:サポートされているモデル(OpenAI o1/o3、Gemini、Claudeなど)のAIの思考プロセスを表示
- **バージョンと取り消し**:AI が図を変更するたびに、サムネイル付きのバージョンカードがチャットに表示されます。キャンバスと比較したり、復元したり、その変更を取り消したりできます。キャンバスで Ctrl+Z を押しても AI の変更を一度に元に戻せます。AI が変更した図形はキャンバス上で強調表示されます
- **選択した図形について依頼**:キャンバスで図形を選択し、その図形だけを AI に直してもらえます
- **ダイアグラム履歴**:すべての変更を追跡する包括的なバージョン管理。AI編集前のダイアグラムの以前のバージョンを表示・復元可能
- **インタラクティブなチャットインターフェース**:AIとリアルタイムでコミュニケーションしてダイアグラムを改善
- **クラウドアーキテクチャダイアグラムサポート**:クラウドアーキテクチャダイアグラムの生成を専門的にサポート(AWS、GCP、Azure)
- **アニメーションコネクタ**:より良い可視化のためにダイアグラム要素間に動的でアニメーション化されたコネクタを作成
@@ -243,7 +242,7 @@ AWS BedrockとOpenRouter以外のすべてのプロバイダーはカスタム
- **Next.js**:フロントエンドフレームワークとルーティング
- **Vercel AI SDK**(`ai` + `@ai-sdk/*`):ストリーミングAIレスポンスとマルチプロバイダーサポート
- **draw.io**:エディターは `public/drawio` に同梱したコピー(`npm run dev` / `npm run build` で自動ダウンロード)で動作し、アプリから直接操作できます
- **react-drawio**:ダイアグラムの表現と操作
ダイアグラムはdraw.ioでレンダリングできるXMLとして表現されます。AIがコマンドを処理し、それに応じてこのXMLを生成または変更します。
+1 -3
View File
@@ -46,8 +46,6 @@ AI_MODEL=gpt-4o
OPENAI_BASE_URL=https://your-custom-endpoint/v1
```
LM Studio などのローカルな OpenAI 互換サーバーも同じ方法で使えます。ベース URL を `http://localhost:1234/v1`(LM Studio の既定ポート)に設定し、API キーには `lm-studio` など空でない任意の値を入力してください。上の環境変数でも、モデル設定画面でも設定できます。
### AIHubMix
AIHubMix は、単一の API キーで Claude、GPT、Gemini、DeepSeek などのモデルへのアクセスを提供します。
@@ -181,7 +179,7 @@ AI_MODEL=llama3.2
任意のカスタム URL:
```bash
OLLAMA_BASE_URL=http://localhost:11434/api
OLLAMA_BASE_URL=http://localhost:11434
```
### ModelScope
+8 -18
View File
@@ -1,25 +1,13 @@
# オフラインデプロイ
Next AI Draw.io には draw.io のコピーが同梱されています。`npm run build`(Docker ビルドを含む)が draw.io のリリースを一度だけ `public/drawio` にダウンロードし、アプリは `/drawio` からそれを配信します。利用中のブラウザは `embed.diagrams.net` にアクセスしないため、ビルド時にインターネットに接続できれば、オフライン環境やイントラネットでも動作します。
`embed.diagrams.net` の代わりに draw.io をセルフホストすることで、Next AI Draw.io をオフライン環境にデプロイできます。
**注:** `NEXT_PUBLIC_DRAWIO_BASE_URL` は**ビルド時**の変数です。これを変更する場合は、Docker イメージの再ビルドが必要です。
## Docker Compose のセットアップ
1. リポジトリをクローンし、`.env` ファイルに API キーを定義します。
2. `docker compose up -d` を実行します(リポジトリの `docker-compose.yml` を使います)。
3. `http://localhost:3000` を開きます。
draw.io 用の別コンテナは不要です。
## ビルド環境がインターネットに接続できない場合
ビルドは [draw.io のリリース](https://github.com/jgraph/drawio/releases) から `draw.war`(約 50 MB)をダウンロードします。ビルド環境から GitHub にアクセスできない場合は、次のどちらかを行ってください。
- 別のマシンで `scripts/fetch-drawio.mjs` に書かれたバージョンの `draw.war` をダウンロードし、`public/drawio` に展開します(`WEB-INF` と `META-INF` は削除)。さらにバージョン(例:`v32.0.2`)を `public/drawio/.version` に書き込みます。ビルドはこのコピーを使います。
- インターネットに接続できるマシンでイメージをビルドし、オフライン環境に転送します。
## 別の draw.io サーバーを使う場合(任意)
ビルド時の変数 `NEXT_PUBLIC_DRAWIO_BASE_URL` で、`jgraph/drawio` イメージなど別の draw.io を使うこともできます。
2. `docker-compose.yml` を作成します。
```yaml
services:
@@ -36,7 +24,9 @@ services:
depends_on: [drawio]
```
外部の draw.io を使うと、アプリは draw.io 自身のツールバーを使い、次の機能は使えなくなります:AI が変更した図形の強調表示、選択した図形についての依頼、アプリのキャンバスツールバー、Ctrl+Z による AI の変更の取り消し。ブラウザは、別のオリジンから配信されたエディターをページから操作することを許可しないためです。
3. `docker compose up -d` を実行し、`http://localhost:3000` にアクセスします。
## 設定と重要な警告
**`NEXT_PUBLIC_DRAWIO_BASE_URL` は、ユーザーのブラウザからアクセスできる必要があります。**
@@ -45,4 +35,4 @@ services:
| ローカルホスト | `http://localhost:8080` |
| リモート/サーバー | `http://YOUR_SERVER_IP:8080` |
**Docker の内部エイリアス**(例:`http://drawio:8080`)は**使用しないでください**。ブラウザはそれらを解決できません。
**`http://drawio:8080` のような Docker 内部のエイリアスは絶対に使用しないでください。** ブラウザはこれらを名前解決できません。
+4 -17
View File
@@ -70,7 +70,7 @@ AI_MODEL=global.anthropic.claude-sonnet-4-5-20250929-v1:0
# AZURE_REASONING_SUMMARY=detailed
# Ollama Configuration (Local or Cloud)
# OLLAMA_BASE_URL=https://ollama.com/api # Optional: Ollama Cloud; defaults to local Ollama (http://127.0.0.1:11434/api)
# OLLAMA_BASE_URL=https://ollama.com/api # Optional: Ollama Cloud; defaults to local Ollama (http://127.0.0.1:11434)
# OLLAMA_API_KEY=your-ollama-cloud-api-key # Optional: For Ollama Cloud or authenticated remote instances
# OLLAMA_ENABLE_THINKING=true # Optional: Enable thinking for models that support it (e.g., qwen3)
@@ -143,11 +143,8 @@ AI_MODEL=global.anthropic.claude-sonnet-4-5-20250929-v1:0
# SETTINGS_FILE=./data/settings.json # Optional: custom settings file location
# Draw.io Configuration (Optional)
# By default the app serves its own copy of draw.io from /drawio (downloaded into
# public/drawio by `npm run dev` / `npm run build`). Same origin is required for
# highlighting AI changes, asking about selected shapes and the custom toolbar.
# NEXT_PUBLIC_DRAWIO_BASE_URL=https://embed.diagrams.net # Optional: use an external draw.io instead
# With an external draw.io the app falls back to draw.io's own toolbar and those features are off.
# NEXT_PUBLIC_DRAWIO_BASE_URL=https://embed.diagrams.net # Default: https://embed.diagrams.net
# Use this to point to a self-hosted draw.io instance
# Subdirectory Deployment (Optional)
# For deploying to a subdirectory (e.g., https://example.com/nextaidrawio)
@@ -166,22 +163,12 @@ AI_MODEL=global.anthropic.claude-sonnet-4-5-20250929-v1:0
# Set to "false" to block private IPs, localhost, and internal hostnames
# ALLOW_PRIVATE_URLS=false
# Behind a CDN such as Cloudflare (Optional)
# The daily quota is counted per IP. By default the IP is the first
# X-Forwarded-For entry, which visitors can set to anything. Name the header
# your CDN fills with the visitor's real IP instead:
# CLIENT_IP_HEADER=cf-connecting-ip
# Then have the CDN add an X-Origin-Secret header with this value to every
# request. API calls without it get 403, so nobody can skip the CDN and fake
# the IP header above.
# ORIGIN_SECRET=a-long-random-string
# Self-hosted deployment (Optional)
# Self-hosted users may implement custom quota-management solutions,
# which triggers the client UI to display messages suggesting self-hosting or sponsorship.
# This switch allows self-hosted users to provide custom messages in response to a 429 code,
# in messageTokenSelfHosted, messageApiSelfHosted, and tipSelfHosted translation strings.
# It also turns off the MCP server advertisement in Settings > About.
# It also turns off the MCP server advertisement in the chat examples.
# NEXT_PUBLIC_SELFHOSTED=true
# Minimax Configuration (Optional)
+19 -41
View File
@@ -1,10 +1,9 @@
import type { RefObject } from "react"
import type { MutableRefObject } from "react"
import type { DiagramOperation } from "@/components/chat/types"
import type {
ValidationState,
ValidationStatus,
} from "@/components/chat/ValidationCard"
import type { LoadMode } from "@/contexts/diagram-context"
import type { ValidationResult } from "@/lib/diagram-validator"
import { formatValidationFeedback } from "@/lib/diagram-validator"
import { isMxCellXmlComplete } from "@/lib/utils"
@@ -51,20 +50,16 @@ type ValidateDiagramFn = (
) => Promise<ValidationResult>
interface UseDiagramToolHandlersParams {
partialXmlRef: RefObject<string>
editDiagramOriginalXmlRef: RefObject<Map<string, string>>
partialXmlRef: MutableRefObject<string>
editDiagramOriginalXmlRef: MutableRefObject<Map<string, string>>
// Tool calls the streaming preview must leave alone (shared with it)
processedToolCallsRef: RefObject<Set<string>>
processedToolCallsRef: MutableRefObject<Set<string>>
// Failed VLM validations in the current user turn (reset on each user message)
validationRetryCountRef: RefObject<number>
chartXMLRef: RefObject<string>
onDisplayChart: (
xml: string,
skipValidation?: boolean,
mode?: LoadMode,
meta?: { toolCallId?: string },
) => string | null
onFetchChart: () => Promise<string>
validationRetryCountRef: MutableRefObject<number>
chartXMLRef: MutableRefObject<string>
onDisplayChart: (xml: string, skipValidation?: boolean) => string | null
onFetchChart: (saveToHistory?: boolean) => Promise<string>
onExport: () => void
captureValidationPng?: () => Promise<string | null>
validateDiagram?: ValidateDiagramFn
enableVlmValidation?: boolean
@@ -95,6 +90,7 @@ export function useDiagramToolHandlers({
chartXMLRef,
onDisplayChart,
onFetchChart,
onExport,
captureValidationPng,
validateDiagram,
enableVlmValidation = true,
@@ -137,17 +133,7 @@ export function useDiagramToolHandlers({
// which the server runs, still arrives here) leave the stored
// originals for the preview code to undo.
if (toolCall.toolName === "display_diagram") {
// A truncated diagram is not drawn (append_diagram finishes it):
// its preview is undone like a failed call's, so its original
// stays stored
const { xml } = (toolCall.input ?? {}) as { xml?: unknown }
const truncated =
typeof xml === "string" && !isMxCellXmlComplete(xml)
await handleDisplayDiagram(
toolCall,
addToolOutput,
truncated ? undefined : takeOriginals(),
)
await handleDisplayDiagram(toolCall, addToolOutput, takeOriginals())
} else if (toolCall.toolName === "edit_diagram") {
await handleEditDiagram(toolCall, addToolOutput, takeOriginals())
} else if (toolCall.toolName === "append_diagram") {
@@ -224,21 +210,16 @@ NEXT STEP: Call append_diagram with the continuation XML.
// Wrap, validate and auto-fix the model's XML like the MCP server's
// create_new_diagram, then load it
// One undo step (and a version) right away, before the screenshot
// check: a stop, another chat or a hand edit during the check then
// finds the diagram already in place
const prepared = prepareNewDiagram(finalXml, NEW_PAGE)
const validationError = prepared.ok
? onDisplayChart(prepared.xml, true, "commit", {
toolCallId: toolCall.toolCallId,
})
? onDisplayChart(prepared.xml, true)
: prepared.error
if (validationError) {
console.warn("[display_diagram] Validation error:", validationError)
// Undo the streamed preview, as a failed edit does: the canvas
// keeps the diagram from before this failed call
if (originalXml) onDisplayChart(originalXml, true, "revert")
if (originalXml) onDisplayChart(originalXml, true)
// Return error to model - sendAutomaticallyWhen will trigger retry
if (DEBUG) {
console.log(
@@ -449,7 +430,7 @@ ${finalXml}
// On failure, undo the streaming preview so the canvas matches the XML
// reported back to the model
const restoreOriginal = () => {
if (originalXml) onDisplayChart(originalXml, true, "revert")
if (originalXml) onDisplayChart(originalXml, true)
}
try {
if (originalXml) {
@@ -461,7 +442,7 @@ ${finalXml}
currentXml = cachedXML
} else {
// Last resort: export from iframe
currentXml = await onFetchChart()
currentXml = await onFetchChart(false)
}
}
@@ -489,9 +470,8 @@ Please check the cell IDs and retry.`,
return
}
onDisplayChart(outcome.xml, true, "commit", {
toolCallId: toolCall.toolCallId,
})
onDisplayChart(outcome.xml, true)
onExport()
addToolOutput({
tool: "edit_diagram",
toolCallId: toolCall.toolCallId,
@@ -581,15 +561,13 @@ Start your continuation with the NEXT character after where it stopped.`,
// It draws now: it takes the stored originals, as display_diagram
const originalXml = prepared.ok ? takeOriginals() : undefined
const validationError = prepared.ok
? onDisplayChart(prepared.xml, true, "commit", {
toolCallId: toolCall.toolCallId,
})
? onDisplayChart(prepared.xml, true)
: prepared.error
if (validationError) {
// Loading failed: back to the diagram before the previews
if (prepared.ok && originalXml) {
onDisplayChart(originalXml, true, "revert")
onDisplayChart(originalXml, true)
}
addToolOutput({
tool: "append_diagram",
+6 -51
View File
@@ -14,30 +14,18 @@ import {
isIndexedDBAvailable,
migrateFromLocalStorage,
readSessionCount,
renameSession as renameSessionInDB,
type SessionMetadata,
type StoredMessage,
saveSession,
} from "@/lib/session-storage"
import { STORAGE_KEYS } from "@/lib/storage"
import {
type DiagramVersion,
versionsFromLegacyHistory,
} from "@/stores/versions-store"
export interface SessionData {
messages: StoredMessage[]
xmlSnapshots: [number, string][]
diagramXml: string
thumbnailDataUrl?: string
versions?: DiagramVersion[]
/** Title for a session created by this save (default: first message) */
title?: string
}
/** Versions of a stored session, converting the old history format */
export function getSessionVersions(session: ChatSession): DiagramVersion[] {
return session.versions ?? versionsFromLegacyHistory(session.diagramHistory)
diagramHistory?: { svg: string; xml: string }[]
}
// Taken right before a save's data is read: the chat on screen then, and
@@ -70,7 +58,6 @@ export interface UseSessionManagerReturn {
clearCurrentSession: () => void
getChatGeneration: () => number
getSaveTicket: () => SaveTicket
renameSession: (id: string, title: string) => Promise<void>
}
// Reading the session list loads every stored session in full, and window
@@ -280,7 +267,7 @@ export function useSessionManager(
xmlSnapshots: session.xmlSnapshots,
diagramXml: session.diagramXml,
thumbnailDataUrl: session.thumbnailDataUrl,
versions: getSessionVersions(session),
diagramHistory: session.diagramHistory,
}
},
[currentSessionId, changeChat],
@@ -332,8 +319,8 @@ export function useSessionManager(
xmlSnapshots: data.xmlSnapshots,
diagramXml: data.diagramXml,
thumbnailDataUrl: data.thumbnailDataUrl,
versions: data.versions,
title: data.title || extractTitle(data.messages),
diagramHistory: data.diagramHistory,
title: extractTitle(data.messages),
}
// Without a stored session, keep no session id (it would end
// up in the URL and point to nothing after a reload)
@@ -361,9 +348,8 @@ export function useSessionManager(
thumbnailDataUrl:
data.thumbnailDataUrl ??
currentSession.thumbnailDataUrl,
versions:
data.versions ?? getSessionVersions(currentSession),
diagramHistory: undefined,
diagramHistory:
data.diagramHistory ?? currentSession.diagramHistory,
updatedAt: Date.now(),
// Update title if it's still default and we have messages
title:
@@ -426,36 +412,6 @@ export function useSessionManager(
[],
)
const renameSession = useCallback(
(id: string, title: string): Promise<void> => {
const trimmed = title.trim().slice(0, 100)
if (!trimmed) return Promise.resolve()
// In the save queue, so a save running now cannot put the old
// title back
const run = async () => {
const updated = await renameSessionInDB(id, trimmed)
if (!updated) {
notifySaveFailed(dict.errors.sessionSaveFailed)
return
}
const current = currentSessionRef.current
if (current?.id === id) {
currentSessionRef.current = { ...current, title: trimmed }
setCurrentSession(currentSessionRef.current)
}
setSessions((prev) =>
prev.map((s) =>
s.id === id ? { ...s, title: trimmed } : s,
),
)
}
const result = saveQueueRef.current.then(run)
saveQueueRef.current = result.catch(() => {})
return result
},
[dict],
)
return {
sessions,
currentSessionId,
@@ -469,6 +425,5 @@ export function useSessionManager(
clearCurrentSession,
getChatGeneration,
getSaveTicket,
renameSession,
}
}
+1 -2
View File
@@ -25,7 +25,6 @@ import { getEnvFallback } from "@/lib/admin/settings"
import { isPrivateUrl, redirectGuardedFetch } from "@/lib/ssrf-protection"
import {
normalizeBaseUrl,
ollamaApiUrl,
PROVIDER_INFO,
type ProviderName,
} from "@/lib/types/model-config"
@@ -1026,7 +1025,7 @@ export function getAIModel(clientOverrides?: ClientOverrides): ModelConfig {
? PROVIDER_INFO.ollama.defaultBaseUrl
: resolveBaseUrlEnv(overrides, "OLLAMA_BASE_URL"))
model = createOllama({
...(baseURL && { baseURL: ollamaApiUrl(baseURL) }),
...(baseURL && { baseURL }),
...(apiKey && {
headers: { Authorization: `Bearer ${apiKey}` },
}),
-31
View File
@@ -133,34 +133,3 @@ export function fixToolInputJson(input: string): string {
.replace(/"(\w+)"\s*=\s*"/g, '"$1": "')
)
}
// Shapes the user selected on the canvas; sent with the request as
// selectedCells: [{ id, label }]
const MAX_SELECTED_CELLS = 50
const MAX_SELECTED_TEXT = 100
function cleanSelectionText(value: unknown): string {
if (typeof value !== "string") return ""
return value
.replace(/[\r\n"]+/g, " ")
.trim()
.slice(0, MAX_SELECTED_TEXT)
}
/** Prompt text describing the selection, or "" when there is none */
export function formatSelectionContext(selectedCells: unknown): string {
if (!Array.isArray(selectedCells)) return ""
const lines = selectedCells
.slice(0, MAX_SELECTED_CELLS)
.map((cell) => {
const id = cleanSelectionText(cell?.id)
if (!id) return null
const label = cleanSelectionText(cell?.label)
return label ? `- id="${id}" (${label})` : `- id="${id}"`
})
.filter((line): line is string => line !== null)
if (lines.length === 0) return ""
return `The user selected these shapes on the canvas before sending this message:
${lines.join("\n")}
Apply the request to the selected shapes unless it clearly asks for something else. Prefer edit_diagram with these cell ids over redrawing the whole diagram.`
}
-37
View File
@@ -1,37 +0,0 @@
import type { getSelectedAIConfig } from "@/hooks/use-model-config"
type AIConfig = ReturnType<typeof getSelectedAIConfig>
/**
* Request headers for /api/chat: the access code, the user's own provider
* credentials (when a client-side model is selected) and per-request options.
*/
export function buildChatHeaders(
config: AIConfig,
options: { minimalStyle: boolean; maxOutputTokens: string },
): Record<string, string> {
const headers: Record<string, string> = {
"x-access-code": config.accessCode,
}
const set = (name: string, value: string | undefined) => {
if (value) headers[name] = value
}
if (config.aiProvider) {
set("x-ai-provider", config.aiProvider)
set("x-ai-base-url", config.aiBaseUrl)
set("x-ai-api-key", config.aiApiKey)
set("x-ai-model", config.aiModel)
// AWS Bedrock credentials
set("x-aws-access-key-id", config.awsAccessKeyId)
set("x-aws-secret-access-key", config.awsSecretAccessKey)
set("x-aws-region", config.awsRegion)
set("x-aws-session-token", config.awsSessionToken)
// Vertex AI credentials (Express Mode)
set("x-vertex-api-key", config.vertexApiKey)
}
// Selected model ID for server model lookup (apiKeyEnv/baseUrlEnv)
set("x-selected-model-id", config.selectedModelId)
if (options.minimalStyle) headers["x-minimal-style"] = "true"
set("x-max-output-tokens", options.maxOutputTokens)
return headers
}
-22
View File
@@ -1,22 +0,0 @@
/** Copy text, falling back to execCommand for non-secure contexts (HTTP) */
export async function copyText(text: string): Promise<boolean> {
try {
await navigator.clipboard.writeText(text)
return true
} catch {
const textarea = document.createElement("textarea")
textarea.value = text
textarea.style.position = "fixed"
textarea.style.left = "-9999px"
textarea.style.opacity = "0"
document.body.appendChild(textarea)
try {
textarea.select()
return document.execCommand("copy")
} catch {
return false
} finally {
document.body.removeChild(textarea)
}
}
}
-220
View File
@@ -1,220 +0,0 @@
import pako from "pako"
/** What changed on the first page between two versions of a diagram */
export interface ChangeSummary {
shapesAdded: number
shapesRemoved: number
shapesChanged: number
edgesAdded: number
edgesRemoved: number
edgesChanged: number
}
export interface DiagramDiff {
summary: ChangeSummary
/** Ids of cells that were added or changed (for highlighting) */
touchedIds: string[]
}
export const EMPTY_SUMMARY: ChangeSummary = {
shapesAdded: 0,
shapesRemoved: 0,
shapesChanged: 0,
edgesAdded: 0,
edgesRemoved: 0,
edgesChanged: 0,
}
interface CellInfo {
isEdge: boolean
signature: string
}
/** Inflate a compressed <diagram> payload (base64, raw deflate, URI-encoded) */
function inflateDiagram(text: string): string | null {
try {
const binary = atob(text.trim())
const bytes = new Uint8Array(binary.length)
for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i)
const inflated = pako.inflateRaw(bytes, { to: "string" })
return decodeURIComponent(inflated)
} catch {
return null
}
}
/** A page's mxGraphModel element, inflated when the page is compressed */
function modelOfPage(diagram: Element): Element | null {
const model = diagram.querySelector("mxGraphModel")
if (model) return model
const inflated = inflateDiagram(diagram.textContent || "")
if (!inflated) return null
const inner = new DOMParser().parseFromString(inflated, "text/xml")
return inner.querySelector("mxGraphModel")
}
function parse(xml: string): Document | null {
if (!xml?.trim()) return null
const doc = new DOMParser().parseFromString(xml, "text/xml")
return doc.querySelector("parsererror") ? null : doc
}
/** The first page's mxGraphModel element, whatever wrapper the XML has */
function firstPageModel(xml: string): Element | null {
const doc = parse(xml)
if (!doc) return null
const diagram = doc.querySelector("diagram")
if (diagram) return modelOfPage(diagram)
return doc.querySelector("mxGraphModel") ?? doc.documentElement
}
/**
* The document with every compressed page inflated in place, as draw.io
* saves it after an edit. Returned unchanged when there is nothing to do.
*/
export function inflatePages(xml: string): string {
const doc = parse(xml)
if (!doc) return xml
let changed = false
for (const diagram of Array.from(doc.getElementsByTagName("diagram"))) {
if (diagram.querySelector("mxGraphModel")) continue
const model = modelOfPage(diagram)
if (!model) continue
diagram.textContent = ""
diagram.appendChild(doc.importNode(model, true))
changed = true
}
return changed ? new XMLSerializer().serializeToString(doc) : xml
}
/**
* What a cell is, independent of how the XML was written: the same cell
* saved by draw.io and written by the model can order attributes and style
* entries differently
*/
function signatureOf(node: Element): string {
const attrs = Array.from(node.attributes)
.map((a) => {
if (a.name !== "style") return `${a.name}=${a.value}`
const entries = a.value
.split(";")
.map((e) => e.trim())
.filter(Boolean)
.sort()
return `style=${entries.join(";")}`
})
.sort()
const children = Array.from(node.children).map(signatureOf)
return `<${node.tagName} ${attrs.join(" ")}>${children.join("")}</>`
}
function collectCells(xml: string): Map<string, CellInfo> {
return cellsOf(firstPageModel(xml))
}
function cellsOf(model: Element | null): Map<string, CellInfo> {
const cells = new Map<string, CellInfo>()
if (!model) return cells
for (const cell of Array.from(model.getElementsByTagName("mxCell"))) {
// Cells with links or custom data are wrapped; the wrapper holds the id
const wrapper = cell.parentElement
const isWrapped =
wrapper?.tagName === "object" || wrapper?.tagName === "UserObject"
const id = cell.getAttribute("id") || wrapper?.getAttribute("id")
if (!id || id === "0" || id === "1") continue
const node = isWrapped && wrapper ? wrapper : cell
cells.set(id, {
isEdge: cell.getAttribute("edge") === "1",
signature: signatureOf(node),
})
}
return cells
}
export function diffDiagrams(beforeXml: string, afterXml: string): DiagramDiff {
const before = collectCells(beforeXml)
const after = collectCells(afterXml)
const summary: ChangeSummary = { ...EMPTY_SUMMARY }
const touchedIds: string[] = []
for (const [id, cell] of after) {
const old = before.get(id)
if (!old) {
touchedIds.push(id)
if (cell.isEdge) summary.edgesAdded++
else summary.shapesAdded++
} else if (old.signature !== cell.signature) {
touchedIds.push(id)
if (cell.isEdge) summary.edgesChanged++
else summary.shapesChanged++
}
}
for (const [id, cell] of before) {
if (after.has(id)) continue
if (cell.isEdge) summary.edgesRemoved++
else summary.shapesRemoved++
}
return { summary, touchedIds }
}
export function isEmptySummary(summary: ChangeSummary): boolean {
return Object.values(summary).every((n) => n === 0)
}
/** Count of shapes and connectors on the first page */
export function countCells(xml: string): { shapes: number; edges: number } {
let shapes = 0
let edges = 0
for (const cell of collectCells(xml).values()) {
if (cell.isEdge) edges++
else shapes++
}
return { shapes, edges }
}
// Where the view was scrolled to, saved with the page; not a change
const VIEW_ATTRIBUTES = new Set(["dx", "dy"])
/**
* Whether two documents hold the same pages (names and cells) and page
* settings. A setting written on one side only is draw.io's default being
* filled in, so only settings both sides have are compared.
*/
export function isSameDocument(a: string, b: string): boolean {
const pagesOf = (xml: string) => {
const doc = parse(xml)
if (!doc) return null
const diagrams = Array.from(doc.getElementsByTagName("diagram"))
if (diagrams.length === 0) {
return [{ name: null, model: doc.querySelector("mxGraphModel") }]
}
return diagrams.map((d) => ({
name: d.getAttribute("name"),
model: modelOfPage(d),
}))
}
const pagesA = pagesOf(a)
const pagesB = pagesOf(b)
if (!pagesA || !pagesB) return a === b
if (pagesA.length !== pagesB.length) return false
return pagesA.every((pageA, i) => {
const pageB = pagesB[i]
if (pageA.name !== null && pageB.name !== null) {
if (pageA.name !== pageB.name) return false
}
if (pageA.model && pageB.model) {
for (const attr of Array.from(pageA.model.attributes)) {
if (VIEW_ATTRIBUTES.has(attr.name)) continue
const other = pageB.model.getAttribute(attr.name)
if (other !== null && other !== attr.value) return false
}
}
const cellsA = cellsOf(pageA.model)
const cellsB = cellsOf(pageB.model)
if (cellsA.size !== cellsB.size) return false
for (const [id, cell] of cellsA) {
if (cellsB.get(id)?.signature !== cell.signature) return false
}
return true
})
}
+17
View File
@@ -0,0 +1,17 @@
export const DRAWIO_THEMES = [
"kennedy",
"atlas",
"dark",
"min",
"sketch",
"simple",
] as const
export type DrawioTheme = (typeof DRAWIO_THEMES)[number]
export function isDrawioTheme(value: unknown): value is DrawioTheme {
return (
typeof value === "string" &&
(DRAWIO_THEMES as readonly string[]).includes(value)
)
}
-118
View File
@@ -1,118 +0,0 @@
import { getAssetUrl } from "@/lib/base-path"
import type { Locale } from "@/lib/i18n/config"
/** External draw.io configured at build time (cross-origin, limited features) */
export const EXTERNAL_DRAWIO_URL = process.env.NEXT_PUBLIC_DRAWIO_BASE_URL || ""
/**
* URL of the draw.io editor iframe. By default this is the copy bundled in
* public/drawio, served from our own origin.
*/
export function getDrawioSrc({
lang,
isElectron,
}: {
lang: Locale
isElectron: boolean
}): string {
// react-drawio used `new URL(baseUrl)`, so keep an absolute URL, and
// include index.html because Next.js does not serve directory indexes
const base =
EXTERNAL_DRAWIO_URL ||
`${window.location.origin}${getAssetUrl("/drawio/index.html")}`
const url = new URL(base)
const params: Record<string, string> = {
embed: "1",
proto: "json",
configure: "1",
ui: "simple",
spin: "0",
libraries: "0",
noSaveBtn: "1",
noExitBtn: "1",
saveAndExit: "0",
// The diagram always renders on a light sheet; dark mode only
// recolors the table around it
dark: "0",
// draw.io names Traditional Chinese "zh-tw"
lang: lang === "zh-Hant" ? "zh-tw" : lang,
}
// No calls to external services from the desktop app
if (isElectron && !EXTERNAL_DRAWIO_URL) params.offline = "1"
for (const [key, value] of Object.entries(params)) {
url.searchParams.set(key, value)
}
return url.toString()
}
/**
* Show the page (a white sheet with a grid) unless the diagram says
* otherwise. AI output usually has a bare <mxGraphModel>, and draw.io's
* simple UI would then show neither page nor grid.
*/
export function withPageDefaults(xml: string): string {
return xml.replace(
/<mxGraphModel\b([^>]*?)(\/?)>/g,
(_match, attrs, slash) => {
let next = attrs as string
if (!/\spage\s*=/.test(next)) next += ' page="1"'
if (!/\sgrid\s*=/.test(next)) next += ' grid="1"'
if (!/\sgridSize\s*=/.test(next)) next += ' gridSize="10"'
return `<mxGraphModel${next}${slash}>`
},
)
}
/** Empty document loaded when draw.io starts */
export const EMPTY_DRAWIO_DOCUMENT = withPageDefaults(
'<mxfile><diagram name="Page-1" id="page-1"><mxGraphModel><root><mxCell id="0"/><mxCell id="1" parent="0"/></root></mxGraphModel></diagram></mxfile>',
)
/**
* Editor defaults sent with draw.io's configure message. The simple UI turns
* page view off for new diagrams; keep the white page on the canvas, as in
* the classic UI.
*/
export const DRAWIO_CONFIG = {
defaultPageVisible: true,
defaultGridEnabled: true,
}
/**
* CSS injected into draw.io. `customChrome` hides draw.io's own toolbar and
* page tabs because the app draws its own over the canvas.
*/
export function getDrawioCss({
dark,
customChrome,
}: {
dark: boolean
customChrome: boolean
}): string {
const workspace = dark ? "#0f2440" : "#f4f5f7"
// draw.io itself stays light, so its focus color does too
const accent = "#14181d"
let css = `
:root {
--workspace-color: ${workspace};
--focus-color: ${accent};
}
.geDiagramContainer { background-color: ${workspace}; }
.geBackgroundPage { box-shadow: 0 1px 2px rgba(20,24,29,.08), 0 12px 32px -12px rgba(20,24,29,${dark ? ".5" : ".18"}) !important; }
`
if (customChrome) {
css += `
.geSimpleMainMenu, .geTabContainer, .geFooterContainer, .geStatus,
.geToolbarContainer:not(.geFormatContainer *), .geHsplit,
.geSidebarContainer:not(.geFormatContainer) { display: none !important; }
.geDiagramContainer { top: 0 !important; bottom: 0 !important; left: 0 !important; border: 0 !important; }
.geFormatContainer { top: 64px !important; bottom: 64px !important; border-radius: 14px 0 0 14px; box-shadow: 0 10px 30px -8px rgba(20,24,29,.2); border: 0 !important; }
body.nai-shapes-open .geSidebarContainer:not(.geFormatContainer) { display: block !important; top: 64px !important; bottom: 64px !important; left: 12px !important; width: 248px !important; transform: none !important; border-radius: 14px; box-shadow: 0 10px 30px -8px rgba(20,24,29,.2); border: 0 !important; z-index: 3; }
`
} else {
css += `
.geSimpleMainMenu { border-bottom: 1px solid #e4e7eb !important; box-shadow: none !important; }
`
}
return css
}
-738
View File
@@ -1,738 +0,0 @@
/**
* Direct access to the draw.io editor running in our same-origin iframe.
*
* draw.io has no public JavaScript API for embedders; everything here uses
* its internal objects (EditorUi, mxGraph). Every call checks that what it
* needs exists, so a draw.io update that renames something turns the feature
* off (with a console warning) instead of breaking the page.
*
* With an external draw.io (cross-origin) none of this is available and the
* app uses the postMessage protocol only.
*/
import { type SelectedCell, useCanvasStore } from "@/stores/canvas-store"
import { useVersionsStore } from "@/stores/versions-store"
type EditorUi = any
type FrameWindow = any
let ui: EditorUi | null = null
let win: FrameWindow | null = null
let frameElement: HTMLIFrameElement | null = null
let detachListeners: (() => void) | null = null
const warned = new Set<string>()
// Keyboard shortcuts of the app that must also work while focus is inside
// the draw.io iframe (keydown events there never reach our window)
let appShortcutHandler: ((event: KeyboardEvent) => boolean) | null = null
/** The handler returns true when it handled the key */
export function setAppShortcutHandler(
handler: ((event: KeyboardEvent) => boolean) | null,
) {
appShortcutHandler = handler
}
function warnOnce(key: string, message: string) {
if (warned.has(key)) return
warned.add(key)
console.warn(`[drawio] ${message}`)
}
export function attachEditor(
editorUi: EditorUi,
frameWindow: FrameWindow,
frame: HTMLIFrameElement,
) {
if (ui === editorUi) return
detachEditor()
ui = editorUi
win = frameWindow
frameElement = frame
detachListeners = installListeners()
keepPanelsOnResize()
closeSidePanels()
softenGrid()
useCanvasStore.getState().set({ hasEditor: true })
syncAll()
}
// draw.io's simple UI opens or closes its format panel and shape library
// when its window crosses a width; the app's toolbar opens them, so a
// resize (the chat panel sliding, a narrow window) must leave them alone.
// windowResized only toggles them when it knows the previous width.
function keepPanelsOnResize() {
const original = ui?.windowResized
if (typeof original !== "function" || original.naiWrapped) return
const wrapped = function (this: any, ...args: unknown[]) {
this.lastWindowWidth = null
return original.apply(this, args)
}
wrapped.naiWrapped = true
ui.windowResized = wrapped
}
// draw.io's grid (#e6e6e6 every 10 px) is busy behind pale shapes
function softenGrid() {
const view = graph()?.view
if (!view) return
try {
view.gridColor = "#eceef1"
view.validateBackground()
} catch {
// ignore
}
}
// draw.io opens its format panel (and sometimes the shape library) on start;
// the app opens them on demand from its own toolbar
function closeSidePanels() {
try {
if (isFormatPanelOpen()) ui.actions?.get?.("format")?.funct()
} catch {
// ignore
}
try {
if (ui.sidebarWindow?.window?.isVisible?.()) {
ui.sidebarWindow.window.setVisible(false)
}
} catch {
// ignore
}
}
export function detachEditor() {
detachListeners?.()
detachListeners = null
clearHighlights()
ui = null
win = null
frameElement = null
previewBase = null
useCanvasStore.getState().set({
hasEditor: false,
selection: [],
selectionRect: null,
canUndo: false,
canRedo: false,
})
}
export function hasEditor() {
return ui !== null
}
/** Ids of the cells on the current page (development checks only) */
/** Development only: the view's numbers, for end-to-end checks */
export function debugView() {
const g = graph()
if (!g) return null
return {
scale: g.view.scale,
tx: g.view.translate.x,
ty: g.view.translate.y,
scrollLeft: g.container.scrollLeft,
scrollTop: g.container.scrollTop,
width: g.container.clientWidth,
hsplit: ui?.hsplitPosition,
containerLeft: g.container.style.left,
}
}
export function debugCellIds(): string[] {
const g = graph()
if (!g) return []
return Object.keys(g.model.cells ?? {}).filter(
(id) => id !== "0" && id !== "1",
)
}
function graph() {
return ui?.editor?.graph ?? null
}
// ---------------------------------------------------------------------------
// Actions (used by the toolbar)
// ---------------------------------------------------------------------------
export function hasAction(name: string): boolean {
if (!ui) return false
const action = ui.actions?.get?.(name)
if (!action) {
warnOnce(`action:${name}`, `Action "${name}" not found, hiding it`)
return false
}
return true
}
export function runAction(name: string) {
const action = ui?.actions?.get?.(name)
if (!action) return
try {
action.funct()
} catch (error) {
console.warn(`[drawio] Action "${name}" failed:`, error)
}
// Some actions change state without firing an event we listen to
setTimeout(syncAll, 0)
}
/** Whether the grid and the page view are on (for the menu's check marks) */
export function viewToggles(): { grid: boolean; pageView: boolean } {
const g = graph()
return { grid: !!g?.isGridEnabled?.(), pageView: !!g?.pageVisible }
}
export function focusCanvas() {
try {
frameElement?.focus()
graph()?.container?.focus()
} catch {
// ignore
}
}
export function stopFreehand() {
const freehand = graph()?.freehand
if (freehand?.isDrawing?.()) freehand.stopDrawing()
syncAll()
}
// ---------------------------------------------------------------------------
// Replacing the diagram (AI changes)
// ---------------------------------------------------------------------------
// Diagram before the current AI turn started streaming. Previews are applied
// without undo history; the final result replaces this base as one undo step.
let previewBase: string | null = null
function currentFileXml(): string | null {
try {
const node = ui.getXmlFileData(null, null, true)
return win.mxUtils.getXml(node)
} catch {
return null
}
}
function withoutUndo(fn: () => void) {
const manager = ui?.editor?.undoManager
if (!manager?.undoableEditHappened) {
fn()
return
}
const original = manager.undoableEditHappened
manager.undoableEditHappened = () => {}
try {
fn()
} finally {
manager.undoableEditHappened = original
}
}
/**
* replaceDiagramData replaces the current page with one <mxGraphModel>. Only
* single-page documents qualify; multi-page ones fall back to a full load.
*/
function toSinglePageModel(xml: string): string | null {
const doc = new DOMParser().parseFromString(xml, "text/xml")
if (doc.querySelector("parsererror")) return null
const root = doc.documentElement
if (root.nodeName === "mxGraphModel") return xml
if (root.nodeName !== "mxfile") return null
const diagrams = root.getElementsByTagName("diagram")
if (diagrams.length !== 1) return null
const model = diagrams[0].getElementsByTagName("mxGraphModel")[0]
if (model) return new XMLSerializer().serializeToString(model)
// Compressed page
try {
const text = diagrams[0].textContent?.trim()
const inflated = text ? win?.Graph?.decompress?.(text) : null
return typeof inflated === "string" &&
inflated.includes("<mxGraphModel")
? inflated
: null
} catch {
return null
}
}
function replace(xml: string) {
const model = toSinglePageModel(xml)
if (!model || typeof ui?.replaceDiagramData !== "function") {
throw new Error("Diagram can't be replaced in place")
}
ui.replaceDiagramData(model)
}
function isEmptyModel(): boolean {
const g = graph()
if (!g) return true
const root = g.model.getRoot()
const layer = root ? g.model.getChildAt(root, 0) : null
return !layer || g.model.getChildCount(layer) === 0
}
/** Can this diagram go through the editor (with undo) instead of a full load? */
export function canReplaceDiagram(xml: string): boolean {
if (!ui) return false
if (typeof ui.replaceDiagramData !== "function") {
warnOnce("replace", "replaceDiagramData not found, using full loads")
return false
}
const pageCount = Array.isArray(ui.pages) ? ui.pages.length : 1
return pageCount <= 1 && toSinglePageModel(xml) !== null
}
/** Show a streaming preview without touching the undo history */
export function previewDiagram(xml: string) {
if (previewBase === null) previewBase = currentFileXml() ?? ""
const wasEmpty = isEmptyModel()
withoutUndo(() => replace(xml))
if (wasEmpty) fitDiagram()
}
/** Name of the page in a single-page mxfile, if it has one */
function pageName(xml: string): string | null {
const doc = new DOMParser().parseFromString(xml, "text/xml")
if (doc.documentElement?.nodeName !== "mxfile") return null
return doc.getElementsByTagName("diagram")[0]?.getAttribute("name") || null
}
/**
* Apply the final AI result (or a restored version) as a single undo step,
* page name included. The view is fitted only when the canvas was empty, so
* a restore keeps the user's zoom.
*/
export function commitDiagram(xml: string) {
const wasEmpty =
isEmptyModel() || (previewBase !== null && isEmptyXml(previewBase))
if (previewBase !== null) {
const base = previewBase
previewBase = null
// Undo then goes back to the diagram before streaming started
if (base && toSinglePageModel(base)) withoutUndo(() => replace(base))
}
const model = graph()?.model
const name = pageName(xml)
const page = ui?.currentPage
model?.beginUpdate()
try {
replace(xml)
if (name && page && win?.RenamePage && page.getName?.() !== name) {
model.execute(new win.RenamePage(ui, page, name))
}
} finally {
model?.endUpdate()
}
if (wasEmpty) fitDiagram()
}
/** Throw away the preview and go back to the given diagram */
export function revertPreview(targetXml?: string) {
const base = targetXml ?? previewBase ?? null
previewBase = null
if (base) withoutUndo(() => replace(base))
}
/** Forget the preview base (new user turn) without changing the canvas */
export function resetPreview() {
previewBase = null
}
function isEmptyXml(xml: string | null): boolean {
if (!xml) return true
return !/<mxCell\b[^>]*\bid=["'](?!0["']|1["'])/.test(xml)
}
// When the app last fitted the diagram on its own; a canvas resize right
// after (the chat panel sliding in) fits again, unless the user zoomed since
let lastAutoFitAt = 0
// True while fitDiagram runs: its own zoom change is not the user's
let fitting = false
/** The zoom changed: if the user did it, a later resize keeps their view */
function zoomChanged() {
if (!fitting) lastAutoFitAt = 0
}
/** Fit again if the last automatic fit just happened (layout still moving) */
export function refitIfRecent(windowMs = 1500) {
if (Date.now() - lastAutoFitAt < windowMs) {
fitDiagram()
return true
}
return false
}
// Room around a fitted diagram: the app's toolbar sits over the top of the
// canvas and the page tabs over the bottom (half of this on each side)
const FIT_BORDER = 128
/**
* Fit the shapes (not the whole page) into the canvas, at most at 100%,
* clear of the toolbar and the page tabs
*/
export function fitDiagram() {
const g = graph()
if (!g) return
fitting = true
try {
const b = g.getGraphBounds()
const { scale, translate } = g.view
if (b && b.width > 0 && b.height > 0 && win?.mxRectangle) {
const model = new win.mxRectangle(
b.x / scale - translate.x,
b.y / scale - translate.y,
b.width / scale,
b.height / scale,
)
g.fitWindow(model, FIT_BORDER, 1)
} else if (ui?.actions?.get?.("fitWindow")) {
ui.actions.get("fitWindow").funct()
if (g.view.scale > 1) g.zoomTo(1, true)
}
} catch {
// ignore
} finally {
fitting = false
}
// After the zoom events of the fit itself
lastAutoFitAt = Date.now()
syncZoom()
}
/** The canvas changed width: keep what was in the middle in the middle */
export function keepCenter(oldWidth: number, newWidth: number) {
const container = graph()?.container
if (!container || !oldWidth || !newWidth) return
container.scrollLeft += (oldWidth - newWidth) / 2
}
// ---------------------------------------------------------------------------
// Highlighting what the AI changed
// ---------------------------------------------------------------------------
let highlights: any[] = []
export function clearHighlights() {
for (const h of highlights) {
try {
h.destroy()
} catch {
// ignore
}
}
highlights = []
}
// Outline of changed shapes: dark enough to show on white paper and on
// draw.io's pale fills (yellow ones included)
const HIGHLIGHT_OUTLINE = "#a86b00"
/**
* Marks changed cells: shapes get a soft halo plus a thin outline,
* connectors only the outline (a halo would cover their labels and arrows)
*/
export function highlightCells(ids: string[], color: string) {
clearHighlights()
const g = graph()
if (!g || !win?.mxCellHighlight || ids.length === 0) return
const add = (
state: any,
stroke: string,
width: number,
opacity: number,
) => {
const h = new win.mxCellHighlight(g, stroke, width)
h.opacity = opacity
h.highlight(state)
highlights.push(h)
}
for (const id of ids.slice(0, 200)) {
const cell = g.model.getCell(id)
const state = cell ? g.view.getState(cell) : null
if (!state) continue
if (!g.model.isEdge(cell)) add(state, color, 8, 18)
add(state, HIGHLIGHT_OUTLINE, 2, 100)
}
}
// ---------------------------------------------------------------------------
// Selection
// ---------------------------------------------------------------------------
function cellLabel(cell: any): string {
const g = graph()
let text = ""
try {
text = g?.convertValueToString(cell) ?? ""
} catch {
text = ""
}
// Labels can contain HTML
text = text
.replace(/<br\s*\/?>/gi, " ")
.replace(/<[^>]+>/g, "")
.replace(/&nbsp;/g, " ")
.replace(/&amp;/g, "&")
.replace(/&lt;/g, "<")
.replace(/&gt;/g, ">")
.replace(/\s+/g, " ")
.trim()
return text.length > 80 ? `${text.slice(0, 80)}…` : text
}
function readSelection(): SelectedCell[] {
const g = graph()
if (!g) return []
return (g.getSelectionCells() as any[])
.filter((cell) => cell?.id)
.map((cell) => ({
id: String(cell.id),
label: cellLabel(cell),
isEdge: !!g.model.isEdge(cell),
}))
}
function readSelectionRect() {
const g = graph()
if (!g || g.isSelectionEmpty()) return null
const cells = g.getSelectionCells()
const bounds = g.view.getBounds(cells)
if (!bounds) return null
const container = g.container as HTMLElement
const rect = container.getBoundingClientRect()
return {
x: rect.left + bounds.x - container.scrollLeft,
y: rect.top + bounds.y - container.scrollTop,
width: bounds.width,
height: bounds.height,
}
}
export function clearSelection() {
graph()?.clearSelection()
}
// ---------------------------------------------------------------------------
// Pages
// ---------------------------------------------------------------------------
function readPages() {
const pages = (ui?.pages ?? []) as any[]
return pages.map((page, index) => ({
id: String(page.getId?.() ?? index),
name: String(page.getName?.() ?? `Page-${index + 1}`),
}))
}
function findPage(id: string) {
return ((ui?.pages ?? []) as any[]).find(
(page) => String(page.getId?.()) === id,
)
}
export function selectPage(id: string) {
const page = findPage(id)
if (page && typeof ui.selectPage === "function") ui.selectPage(page)
syncAll()
}
export function addPage() {
runAction("insertPage")
}
export function renamePage(id: string) {
const page = findPage(id)
if (page && typeof ui.renamePage === "function") ui.renamePage(page)
}
export function duplicatePage(id: string) {
const page = findPage(id)
if (page && typeof ui.duplicatePage === "function") ui.duplicatePage(page)
syncAll()
}
export function removePage(id: string) {
const page = findPage(id)
if (page && typeof ui.removePage === "function") ui.removePage(page)
syncAll()
}
export function canManagePages(): boolean {
return (
!!ui && Array.isArray(ui.pages) && typeof ui.selectPage === "function"
)
}
// ---------------------------------------------------------------------------
// Keeping the store in sync
// ---------------------------------------------------------------------------
function syncZoom() {
const g = graph()
if (!g) return
useCanvasStore.getState().set({ zoom: Math.round(g.view.scale * 100) })
}
function syncSelectionRect() {
useCanvasStore.getState().set({ selectionRect: readSelectionRect() })
}
function syncAll() {
if (!ui) return
const g = graph()
const manager = ui.editor?.undoManager
useCanvasStore.getState().set({
canUndo: !!manager?.canUndo?.(),
canRedo: !!manager?.canRedo?.(),
zoom: g ? Math.round(g.view.scale * 100) : 100,
pages: readPages(),
currentPageId: ui.currentPage?.getId
? String(ui.currentPage.getId())
: null,
selection: readSelection(),
selectionRect: readSelectionRect(),
isFreehand: !!g?.freehand?.isDrawing?.(),
isFormatOpen: isFormatPanelOpen(),
isShapesOpen: isShapesPanelOpen(),
})
}
// draw.io keeps its docked shape library "visible" from the start; the app's
// CSS hides it unless the frame's body has this class
export const SHAPES_OPEN_CLASS = "nai-shapes-open"
function isShapesPanelOpen(): boolean {
return !!win?.document?.body?.classList.contains(SHAPES_OPEN_CLASS)
}
/** Show or hide draw.io's shape library, floating over the canvas */
export function toggleShapesPanel() {
const body = win?.document?.body
if (!body) return
// Only the class: draw.io treats its docked library as open from the
// start, and lays the canvas out next to it while it shows
body.classList.toggle(SHAPES_OPEN_CLASS, !isShapesPanelOpen())
syncAll()
}
function isFormatPanelOpen(): boolean {
try {
if (typeof ui.isFormatPanelVisible === "function") {
return !!ui.isFormatPanelVisible()
}
return (ui.formatWidth ?? 0) > 0
} catch {
return false
}
}
function installListeners(): () => void {
const g = graph()
if (!g || !win?.mxEvent) return () => {}
const mxEvent = win.mxEvent
const cleanups: (() => void)[] = []
const listen = (source: any, name: string, handler: () => void) => {
if (!source?.addListener) return
source.addListener(name, handler)
cleanups.push(() => source.removeListener(handler))
}
// Any user action ends the "what the AI just changed" highlight
const onSelection = () => {
clearHighlights()
syncAll()
}
const onModelChange = () => {
syncAll()
}
// An undo step was added, undone or redone: the canvas no longer shows
// a known version. The app's own commits add a step too, and set the
// version flags again right after.
const onUndoableChange = () => {
useVersionsStore.getState().clearCanvasFlags()
syncAll()
}
const onView = () => {
syncZoom()
syncSelectionRect()
}
const onScale = () => {
zoomChanged()
onView()
}
listen(g.getSelectionModel(), mxEvent.CHANGE, onSelection)
listen(g.model, mxEvent.CHANGE, onModelChange)
listen(ui.editor?.undoManager, mxEvent.ADD, onUndoableChange)
listen(ui.editor?.undoManager, mxEvent.UNDO, onUndoableChange)
listen(ui.editor?.undoManager, mxEvent.REDO, onUndoableChange)
listen(ui.editor?.undoManager, mxEvent.CLEAR, syncAll)
listen(g.view, mxEvent.SCALE, onScale)
listen(g.view, mxEvent.TRANSLATE, onView)
listen(g.view, mxEvent.SCALE_AND_TRANSLATE, onScale)
listen(ui.editor, "pageSelected", syncAll)
listen(ui, "pageSelected", syncAll)
listen(ui, "formatWidthChanged", syncAll)
// Capture phase, so the app shortcut wins before draw.io's key handler
const onKeyDown = (event: KeyboardEvent) => {
if (appShortcutHandler?.(event)) {
event.preventDefault()
event.stopPropagation()
}
}
const frameDocument = win.document as Document | undefined
frameDocument?.addEventListener("keydown", onKeyDown, true)
cleanups.push(() =>
frameDocument?.removeEventListener("keydown", onKeyDown, true),
)
const container = g.container as HTMLElement | undefined
container?.addEventListener("scroll", syncSelectionRect, { passive: true })
cleanups.push(() =>
container?.removeEventListener("scroll", syncSelectionRect),
)
return () => {
for (const cleanup of cleanups) cleanup()
}
}
/**
* Finds the EditorUi instance inside a same-origin draw.io iframe.
*
* draw.io keeps no global reference to it, so we wrap prototype methods it
* calls on startup and after every load; the first call hands us `this`.
* Returns false when the frame is cross-origin.
*/
export function watchForEditorUi(
frameWindow: Window,
onFound: (editorUi: EditorUi) => void,
): boolean {
let w: any
try {
w = frameWindow as any
// Throws for a cross-origin frame
void w.document
} catch {
return false
}
const hook = (proto: any, method: string) => {
if (!proto || typeof proto[method] !== "function") return
if (proto[method].__naiWrapped) return
const original = proto[method]
const wrapped = function (this: any, ...args: any[]) {
onFound(this)
return original.apply(this, args)
}
;(wrapped as any).__naiWrapped = true
proto[method] = wrapped
}
hook(w.EditorUi?.prototype, "updateActionStates")
hook(w.App?.prototype, "fileLoaded")
hook(w.EditorUi?.prototype, "fileLoaded")
return true
}
+116 -228
View File
@@ -13,12 +13,12 @@
"nav": {
"about": "About",
"editor": "Editor",
"newChat": "New diagram",
"newChat": "Start fresh chat",
"github": "GitHub",
"settings": "Settings",
"hidePanel": "Hide chat panel",
"showPanel": "Show chat panel",
"aiChat": "AI chat"
"hidePanel": "Hide chat panel (Ctrl+B)",
"showPanel": "Show chat panel (Ctrl+B)",
"aiChat": "AI Chat"
},
"providers": {
"useServerDefault": "Use Server Default",
@@ -39,11 +39,12 @@
},
"chat": {
"placeholder": "Describe your diagram or upload a file...",
"inputLabel": "Chat input",
"send": "Send",
"stopGeneration": "Stop generation",
"sendMessage": "Send message",
"clearConversation": "Clear conversation",
"diagramHistory": "Diagram history",
"saveDiagram": "Save diagram",
"uploadFile": "Upload file (image, PDF, text)",
"minimalStyle": "Minimal",
"styledMode": "Styled",
@@ -57,28 +58,24 @@
"badResponse": "Bad response",
"clickToEdit": "Click to edit",
"editMessage": "Edit message",
"saveAndSubmit": "Save & submit",
"ExtractURL": "Add a web page",
"attach": "Add",
"placeholderFollowUp": "Ask for a change, or select shapes to edit them",
"emptyThread": "Draw on the canvas, or describe what you want below.",
"attachedImage": "Attached image",
"retry": "Retry"
"saveAndSubmit": "Save & Submit",
"ExtractURL": "Extract from URL"
},
"examples": {
"title": "Create diagrams with AI",
"subtitle": "Describe what you want to create or upload an image to replicate",
"paperToDiagram": "Paper to diagram",
"quickExamples": "Quick Examples",
"paperToDiagram": "Paper to Diagram",
"paperDescription": "Upload .pdf, .txt, .md, .json, .csv, .py, .js, .ts and more",
"animatedDiagram": "Animated diagram",
"animatedDiagram": "Animated Diagram",
"animatedDescription": "Draw a transformer architecture with animated connectors",
"awsArchitecture": "AWS architecture",
"awsArchitecture": "AWS Architecture",
"awsDescription": "Create a cloud architecture diagram with AWS icons",
"replicateFlowchart": "Replicate a flowchart",
"replicateFlowchart": "Replicate Flowchart",
"replicateDescription": "Upload and replicate an existing flowchart",
"creativeDrawing": "Creative drawing",
"creativeDrawing": "Creative Drawing",
"creativeDescription": "Draw something fun and creative",
"cachedNote": "Examples open instantly and don't use your model.",
"cachedNote": "Examples are cached for instant response",
"mcpServer": "MCP Server",
"mcpDescription": "Use in Claude Desktop, VS Code & Cursor"
},
@@ -87,7 +84,7 @@
"description": "Configure your application settings.",
"apiKeysModels": "API Keys & Models",
"apiKeysModelsDescription": "Configure AI providers and API keys.",
"accessCode": "Access code",
"accessCode": "Access Code",
"accessCodePlaceholder": "Enter access code",
"accessCodeDescription": "Required to use this application.",
"aiProvider": "AI Provider Settings",
@@ -104,10 +101,17 @@
"language": "Language",
"languageDescription": "Choose your interface language.",
"theme": "Theme",
"themeDescription": "Light or dark interface. The diagram always stays on a white sheet, the way it exports.",
"diagramStyle": "Diagram style",
"diagramStyleDescription": "How the AI draws new diagrams: Styled uses colors and icons, Minimal keeps shapes plain.",
"sendShortcut": "Send shortcut",
"themeDescription": "Dark/Light mode for interface and DrawIO canvas.",
"drawioStyle": "DrawIO Style",
"drawioStyleDescription": "Canvas style",
"themeDefault": "Default",
"themeDark": "Dark",
"themeMinimal": "Minimal",
"themeSketch": "Sketch",
"themeSimple": "Simple",
"diagramStyle": "Diagram Style",
"diagramStyleDescription": "Toggle between minimal and styled diagram output.",
"sendShortcut": "Send Shortcut",
"sendShortcutDescription": "Choose how to send messages.",
"enterToSend": "Enter to send",
"ctrlEnterToSend": "Cmd/Ctrl+Enter to send",
@@ -115,59 +119,52 @@
"diagramActionsDescription": "Manage diagram history and exports",
"history": "History",
"download": "Download",
"proxy": "Proxy settings",
"proxy": "Proxy Settings",
"proxyDescription": "Configure HTTP/HTTPS proxy for API requests (Desktop only)",
"httpProxy": "HTTP proxy",
"httpsProxy": "HTTPS proxy",
"httpProxy": "HTTP Proxy",
"httpsProxy": "HTTPS Proxy",
"applyProxy": "Apply",
"proxyApplied": "Proxy settings applied",
"diagramValidation": "Check diagrams with a screenshot (experimental)",
"diagramValidationDescription": "After each drawing, a model that can read images looks at a screenshot and asks for fixes. Needs a model that can read images.",
"diagramValidation": "Diagram Validation (Experimental)",
"diagramValidationDescription": "Use a vision language model to validate generated diagrams. Requires a VLM like GPT-5.2 or Sonnet-4.5.",
"enabled": "Enabled",
"disabled": "Disabled",
"customSystemMessage": "Custom instructions",
"customSystemMessage": "Custom System Message",
"customSystemMessageDescription": "Add custom instructions appended to the AI's system prompt.",
"customSystemMessagePlaceholder": "e.g., Always use blue color scheme for diagrams...",
"maxOutputTokens": "Max output tokens",
"maxOutputTokens": "Max Output Tokens",
"maxOutputTokensDescription": "Budget for one reply, shared by thinking and the diagram XML. Raise it if the AI keeps thinking and no diagram appears. Leave empty for the default.",
"tabModels": "Models & keys",
"tabAppearance": "Appearance",
"tabAdvanced": "Advanced",
"tabAbout": "About",
"themeLight": "Light",
"themeDarkMode": "Dark",
"themeSystem": "System",
"accessCodeSaved": "Access code saved",
"proxyInvalid": "Proxy addresses must start with http:// or https://",
"proxyFailed": "Couldn't apply the proxy settings",
"appVersion": "Version",
"projectWebsite": "Project website"
"panelVisibility": "Lobby Panels",
"panelVisibilityDescription": "Choose which panels to show on the chat lobby.",
"showRecentChats": "Recent Chats",
"showMyTemplates": "My Templates",
"showQuickExamples": "Quick Examples"
},
"save": {
"title": "Export diagram",
"description": "Pick a format and a file name.",
"title": "Save Diagram",
"description": "Choose a format and filename to save your diagram.",
"format": "Format",
"filename": "Filename",
"filenamePlaceholder": "Enter filename",
"formats": {
"drawio": "draw.io file",
"png": "PNG image",
"svg": "SVG image",
"drawio": "Draw.io XML",
"png": "PNG Image",
"svg": "SVG Image",
"xmlsvg": "Editable SVG"
},
"savedSuccessfully": "Diagram exported",
"exportButton": "Export",
"hints": {
"drawio": "Keeps everything editable",
"png": "A picture for slides and documents",
"svg": "A picture that scales without blur",
"xmlsvg": "A picture that still opens in draw.io"
}
"savedSuccessfully": "Saved successfully!"
},
"history": {
"title": "Diagram History",
"description": "Here saved each diagram before AI modification.\nClick on a diagram to restore it",
"noHistory": "No history available yet. Send messages to create diagram history.",
"version": "Version",
"restoreTo": "Restore to Version {version}?"
},
"dialogs": {
"clearTitle": "Clear Everything?",
"clearDescription": "This will clear the current conversation and reset the diagram. This action cannot be undone.",
"clearEverything": "Clear everything",
"clearEverything": "Clear Everything",
"clearSuccess": "Started a fresh chat"
},
"errors": {
@@ -192,13 +189,9 @@
"sessionSaveFailed": "Could not save this chat. Browser storage may be full: delete old chats from history and try again.",
"sessionSaveFailedLeave": "Could not save this chat. Browser storage may be full. You can go on without saving it, then delete old chats from the list in the new chat.",
"continueWithoutSaving": "Continue without saving",
"notADiagramFile": "{name} isn't a draw.io diagram.",
"fileTooLong": "{name}: Content exceeds {limit}k character limit ({size}k chars)",
"fileReadFailed": "Failed to read file: {name}",
"llm": {
"invalid_api_key": "The provider rejected the API key. Check it in model settings.",
"forbidden": "The provider refused the request. The key may not have access to this model or region.",
"server_key_forbidden": "Today's free quota is used up. It resets tomorrow. You can also add your own API key in model settings to keep going.",
"model_not_found": "The provider does not know this model. Check the model ID in model settings.",
"insufficient_quota": "The provider account has no credit or quota left.",
"rate_limited": "The provider is limiting requests. Wait a moment and try again.",
@@ -226,13 +219,18 @@
"tipSelfHosted": "<strong>Tip:</strong> You can configure your own API key in the settings to continue using the service.",
"reset": "Your limit resets tomorrow. Thanks for understanding.",
"doubaoSponsorship": "<a href=\"{link}\" target=\"_blank\" rel=\"noopener noreferrer\" class=\"underline hover:text-foreground\">Register here</a> to get 500K free tokens per model (including Doubao, DeepSeek and Kimi), then configure your API key in model settings.",
"configModel": "Use your API key",
"configModel": "Use Your API Key",
"selfHost": "Self-host",
"sponsor": "Sponsor",
"learnMore": "Learn more →",
"usedOf": "{used}/{limit}"
},
"tools": {
"generateDiagram": "Generate Diagram",
"editDiagram": "Edit Diagram",
"appendDiagram": "Continue Diagram",
"complete": "Complete",
"error": "Error",
"truncated": "Truncated"
},
"file": {
@@ -241,14 +239,13 @@
"removeFile": "Remove file"
},
"url": {
"title": "Add content from a web page",
"title": "Extract Content from URL",
"description": "Paste a URL to extract and analyze its content",
"Extracting": "Extracting...",
"extract": "Extract",
"Cancel": "Cancel",
"enterUrl": "Please enter a URL",
"invalidFormat": "Invalid URL format",
"failed": "Couldn't read that page"
"invalidFormat": "Invalid URL format"
},
"reasoning": {
"thinking": "Thinking...",
@@ -279,34 +276,32 @@
"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": "Your diagrams",
"tooltip": "Chat History",
"newChat": "New Chat",
"empty": "No diagrams yet",
"emptyHint": "Describe a diagram to start",
"empty": "No chat history yet",
"emptyHint": "Start a conversation to begin",
"today": "Today",
"yesterday": "Yesterday",
"thisWeek": "This Week",
"earlier": "Earlier",
"deleteTitle": "Delete this diagram?",
"deleteDescription": "The diagram and its conversation are deleted for good.",
"recentChats": "Recent diagrams",
"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 diagrams…",
"noResults": "No chats found",
"rename": "Rename",
"busyHint": "Available when the answer is finished"
"searchPlaceholder": "Search chats...",
"noResults": "No chats found"
},
"templates": {
"title": "Saved prompts",
"subtitle": "Prompts you reuse, one click away",
"emptyTitle": "No saved prompts yet",
"emptyDescription": "Save a prompt you reuse, for example \"Draw an AWS architecture for…\". It shows up here.",
"createFirst": "Save your first prompt",
"title": "My Templates",
"subtitle": "Your personal prompt library for quick diagram creation",
"emptyTitle": "No templates yet",
"emptyDescription": "Create your first template to start building your personal prompt library",
"createFirst": "Create First Template",
"neverUsed": "Not used yet",
"usedCount": "{count} uses",
"myTemplates": "Saved prompts",
"createTitle": "Save a prompt",
"createDescription": "Save a prompt to use again later.",
"myTemplates": "My Templates",
"createTitle": "Create Template",
"createDescription": "Save a prompt for repeated use. Templates help you quickly start common workflows.",
"promptLabel": "Prompt",
"promptPlaceholder": "Describe the diagram you want to create...",
"promptRequired": "Prompt is required",
@@ -314,43 +309,42 @@
"titlePlaceholder": "Enter a title",
"titleHint": "Leave empty to use the first 20 characters of prompt",
"descriptionLabel": "Description",
"descriptionPlaceholder": "What this prompt is for",
"pinnedLabel": "Pin to top",
"pinnedHint": "Pinned prompts appear at the top of the list",
"createButton": "Save prompt",
"createFailed": "Couldn't save the prompt. Try again.",
"editTitle": "Edit saved prompt",
"editDescription": "Change the prompt and its settings.",
"updateFailed": "Couldn't update the prompt. Try again.",
"descriptionPlaceholder": "Add a description for this template",
"pinnedLabel": "Pin Template",
"pinnedHint": "Pinned templates appear at the top of the list",
"createButton": "Create Template",
"createFailed": "Failed to create template. Please try again.",
"editTitle": "Edit Template",
"editDescription": "Update your template content and settings.",
"updateFailed": "Failed to update template. Please try again.",
"duplicate": "Duplicate",
"copySuffix": "(copy)",
"deleteTitle": "Delete this saved prompt?",
"deleteDescription": "The prompt is deleted for good.",
"deleteTitle": "Delete this template?",
"deleteDescription": "This will permanently delete this template. This action cannot be undone.",
"confirmSendTitle": "Replace current input?",
"confirmSendDescription": "You have unsent text in the message box. Sending this prompt replaces it.",
"confirmSendButton": "Send prompt",
"searchPlaceholder": "Search saved prompts…",
"searchNoResults": "No saved prompts match",
"confirmSendDescription": "You have unsent content in the input. Sending this template will replace it.",
"confirmSendButton": "Send Template",
"searchPlaceholder": "Search templates...",
"searchNoResults": "No templates match your search",
"pin": "Pin to top",
"unpin": "Unpin from top",
"saveAsTemplate": "Save this prompt",
"exportTemplates": "Export saved prompts",
"importTemplates": "Import saved prompts",
"exportEmpty": "No saved prompts to export",
"exportSuccess": "Exported {count} saved prompt(s)",
"saveAsTemplate": "Save as Template",
"exportTemplates": "Export Templates",
"importTemplates": "Import Templates",
"exportEmpty": "No templates to export",
"exportSuccess": "Exported {count} template(s) successfully",
"importNoFile": "Please select a JSON file",
"importFailed": "Import failed: {error}",
"importSuccess": "Imported {imported} saved prompt(s), skipped {skipped} duplicate(s)",
"saveDisabledHint": "Type a prompt first"
"importSuccess": "Imported {imported} template(s), skipped {skipped} duplicate(s)"
},
"validation": {
"title": "Diagram check",
"title": "Validate Diagram",
"capturing": "Capturing",
"validating": "Validating",
"validatingWithAttempt": "Validating ({attempt}/{max})",
"valid": "Valid",
"validWithWarnings": "Looks fine, with notes",
"issuesFound": "Issues found",
"validWithWarnings": "Valid with Warnings",
"issuesFound": "Issues Found",
"error": "Error",
"skipped": "Skipped",
"capturedScreenshot": "Captured Screenshot:",
@@ -358,20 +352,20 @@
"suggestions": "Suggestions:",
"passedValidation": "Diagram passed visual validation - no issues detected.",
"improvementRequested": "Improvement requested - check the new diagram below",
"improveWithSuggestions": "Improve with these notes",
"improveWithSuggestions": "Improve with Suggestions",
"regenerateWithFeedback": "Regenerate the diagram using the validation feedback"
},
"modelConfig": {
"title": "AI Model Configuration",
"description": "Configure multiple AI providers and models",
"configure": "Configure",
"addProvider": "Add provider",
"addModel": "Add model",
"addProvider": "Add Provider",
"addModel": "Add Model",
"modelId": "Model ID",
"modelLabel": "Display Label",
"streaming": "Enable Streaming",
"deleteProvider": "Delete provider",
"deleteModel": "Delete model",
"deleteProvider": "Delete Provider",
"deleteModel": "Delete Model",
"noModels": "No models configured. Add a model to get started.",
"selectProvider": "Select a provider or add a new one",
"configureMultiple": "Configure multiple AI providers and switch between them easily",
@@ -380,15 +374,15 @@
"validationError": "Validation failed",
"addModelFirst": "Add at least one model to validate",
"providers": "Providers",
"addProviderHint": "No providers yet",
"addProviderHint": "Add a provider to get started",
"verified": "Verified",
"configuration": "Configuration",
"displayName": "Display name",
"displayName": "Display Name",
"awsAccessKeyId": "AWS Access Key ID",
"awsSecretAccessKey": "AWS Secret Access Key",
"awsRegion": "AWS region",
"awsRegion": "AWS Region",
"selectRegion": "Select region",
"apiKey": "API key",
"apiKey": "API Key",
"enterApiKey": "Enter your API key",
"enterSecretKey": "Enter your secret access key",
"baseUrl": "Base URL",
@@ -409,7 +403,7 @@
"noModelsConfigured": "No models configured",
"modelIdEmpty": "Model ID cannot be empty",
"modelIdExists": "This model ID already exists",
"configureProviders": "Set up AI providers",
"configureProviders": "Configure AI Providers",
"selectProviderHint": "Select a provider from the list or add a new one to configure API keys and models",
"deleteConfirmDesc": "Are you sure you want to delete {name}? This will remove all configured models and cannot be undone.",
"typeToConfirm": "Type \"{name}\" to confirm",
@@ -420,24 +414,22 @@
"delete": "Delete",
"clickToChange": "(click to change)",
"usingServerDefault": "Using server default model",
"selectModel": "Select model",
"selectModel": "Select Model",
"searchModels": "Search models...",
"noVerifiedModels": "No verified models. Test your models first.",
"noModelsFound": "No models found.",
"default": "Default",
"serverDefault": "Server default",
"serverModels": "Server models",
"userModels": "Your models",
"serverDefault": "Server Default",
"serverModels": "Server Models",
"userModels": "User Models",
"configureModels": "Configure Models...",
"onlyVerifiedShown": "Only verified models are shown",
"showUnvalidatedModels": "Show models that haven't been tested",
"showUnvalidatedModels": "Show unvalidated models",
"allModelsShown": "All models are shown (including unvalidated)",
"unvalidatedModelWarning": "This model has not been validated",
"serverDefaultModel": "Server default model",
"showValue": "Show value",
"hideValue": "Hide value",
"noProvidersTitle": "Using the model set up on this server",
"noProvidersHint": "To use other models, add a provider with your own API key below the list on the left."
"hideValue": "Hide value"
},
"admin": {
"title": "Admin Settings",
@@ -582,109 +574,5 @@
"description": "Timezone for the daily reset boundary."
}
}
},
"workspace": {
"toolbar": "Canvas tools",
"undo": "Undo",
"redo": "Redo",
"select": "Select",
"shapes": "Shapes",
"text": "Text",
"connector": "Connector",
"freehand": "Freehand",
"zoomIn": "Zoom in",
"zoomOut": "Zoom out",
"zoomMenu": "Zoom",
"fitWindow": "Fit to window",
"zoomReset": "Actual size",
"style": "Style",
"more": "More",
"export": "Export",
"openFile": "Open .drawio file",
"findReplace": "Find and replace",
"layers": "Layers",
"outline": "Outline",
"toggleGrid": "Grid",
"togglePageView": "Page view",
"insertImage": "Insert image",
"insertLink": "Insert link",
"selectAll": "Select all",
"editDiagram": "Edit diagram XML",
"print": "Print",
"addPage": "Add page",
"renamePage": "Rename",
"duplicatePage": "Duplicate",
"deletePage": "Delete page",
"canvasLoading": "Loading the canvas…",
"askAi": "Ask AI to change this",
"untitled": "Untitled diagram",
"newDiagram": "New diagram",
"mobileCanvas": "Canvas",
"mobileChat": "Chat"
},
"selection": {
"one": "Selected: {label}",
"many": "{count} shapes selected",
"shape": "shape",
"connector": "connector",
"remove": "Don't send the selection",
"placeholder": "Describe how to change the selected shapes"
},
"versions": {
"strip": "Versions",
"created": "Drew the diagram",
"edited": "Changed the diagram",
"undone": "Change undone",
"undo": "Undo change",
"redo": "Redo change",
"restore": "Restore",
"restored": "Restored this version",
"compare": "Compare",
"compareTitle": "Compare versions",
"currentCanvas": "Canvas now",
"versionLabel": "Version {n}",
"previous": "Previous version",
"next": "Next version",
"rendering": "Rendering preview…",
"showCode": "Show XML",
"hideCode": "Hide XML",
"drawing": "Drawing the diagram…",
"editing": "Changing the diagram…",
"loadingLibrary": "Loading a shape library…",
"libraryLoaded": "Loaded a shape library",
"failed": "This attempt failed",
"failedRetrying": "This attempt failed, trying again",
"stopped": "Stopped",
"truncatedHint": "The reply hit the output limit before the diagram was complete. Ask for a simpler diagram, or raise Max output tokens in Settings > Advanced.",
"noChanges": "No visible changes",
"listSeparator": ", ",
"shapesAddedOne": "added 1 shape",
"shapesAddedOther": "added {count} shapes",
"edgesAddedOne": "added 1 connector",
"edgesAddedOther": "added {count} connectors",
"shapesChangedOne": "changed 1 shape",
"shapesChangedOther": "changed {count} shapes",
"edgesChangedOne": "changed 1 connector",
"edgesChangedOther": "changed {count} connectors",
"shapesRemovedOne": "removed 1 shape",
"shapesRemovedOther": "removed {count} shapes",
"edgesRemovedOne": "removed 1 connector",
"edgesRemovedOther": "removed {count} connectors",
"totals": "{shapes} shapes, {edges} connectors",
"restoreVersion": "Restore v{n}",
"restoredVersion": "Restored v{n}",
"undoRestore": "Undo",
"noPreview": "No preview",
"compareSince": "Since version {n}, the canvas has changed:",
"isOnCanvas": "This version is what the canvas shows now.",
"differsElsewhere": "The first page is the same as in version {n}; other pages or the page settings have changed."
},
"lobby": {
"title": "What would you like to draw?",
"lede": "Describe a diagram, or upload a screenshot or PDF. You get a regular draw.io diagram that you can keep editing and export.",
"placeholder": "For example: a login sequence diagram with SMS codes and retries",
"recent": "Recent diagrams",
"drawYourself": "Start with a blank canvas",
"openFileHint": "Keep working on a diagram you have"
}
}
+97 -209
View File
@@ -13,11 +13,11 @@
"nav": {
"about": "概要",
"editor": "エディタ",
"newChat": "新しい図",
"newChat": "新しいチャットを開始",
"github": "GitHub",
"settings": "設定",
"hidePanel": "チャットパネルを非表示",
"showPanel": "チャットパネルを表示",
"hidePanel": "チャットパネルを非表示 (Ctrl+B)",
"showPanel": "チャットパネルを表示 (Ctrl+B)",
"aiChat": "AI チャット"
},
"providers": {
@@ -39,11 +39,12 @@
},
"chat": {
"placeholder": "ダイアグラムを説明するか、ファイルをアップロード...",
"inputLabel": "チャット入力",
"send": "送信",
"stopGeneration": "生成を停止",
"sendMessage": "メッセージを送信",
"clearConversation": "会話をクリア",
"diagramHistory": "ダイアグラム履歴",
"saveDiagram": "ダイアグラムを保存",
"uploadFile": "ファイルをアップロード(画像、PDF、テキスト)",
"minimalStyle": "ミニマル",
"styledMode": "スタイル付き",
@@ -58,27 +59,23 @@
"clickToEdit": "クリックして編集",
"editMessage": "メッセージを編集",
"saveAndSubmit": "保存して送信",
"ExtractURL": "Web ページを追加",
"attach": "追加",
"placeholderFollowUp": "変更したい内容を入力。図形を選んでから頼むこともできます",
"emptyThread": "キャンバスに直接描くか、下で描きたいものを伝えてください。",
"attachedImage": "添付画像",
"retry": "再試行"
"ExtractURL": "URLから抽出"
},
"examples": {
"title": "AI でダイアグラムを作成",
"subtitle": "作成したいものを説明するか、画像をアップロードして複製",
"paperToDiagram": "論文を図に",
"quickExamples": "クイック例",
"paperToDiagram": "論文からダイアグラムへ",
"paperDescription": ".pdf, .txt, .md, .json, .csv, .py, .js, .ts などをアップロード",
"animatedDiagram": "アニメーション図",
"animatedDescription": "アニメーションコネクタ付きの Transformer アーキテクチャを描画",
"awsArchitecture": "AWS 構成図",
"awsArchitecture": "AWS アーキテクチャ",
"awsDescription": "AWS アイコンでクラウドアーキテクチャ図を作成",
"replicateFlowchart": "フローチャートを再現",
"replicateFlowchart": "フローチャートを複製",
"replicateDescription": "既存のフローチャートをアップロードして複製",
"creativeDrawing": "自由なイラスト",
"creativeDrawing": "クリエイティブな描画",
"creativeDescription": "楽しくてクリエイティブなものを描く",
"cachedNote": "例はすぐに開き、モデルは使いません。",
"cachedNote": "例はキャッシュされ、即座に応答します",
"mcpServer": "MCP サーバー",
"mcpDescription": "Claude Desktop、VS Code、Cursor で使用"
},
@@ -104,10 +101,17 @@
"language": "言語",
"languageDescription": "インターフェース言語を選択します。",
"theme": "テーマ",
"themeDescription": "画面をライトにするかダークにするか。図はエクスポートしたときと同じく、常に白い用紙の上に表示されます。",
"diagramStyle": "図のスタイル",
"diagramStyleDescription": "AI が新しい図を描くときのスタイル。スタイル付きは色やアイコンを使い、ミニマルは素朴な図形だけにします。",
"sendShortcut": "送信のショートカット",
"themeDescription": "インターフェースと DrawIO キャンバスのダーク/ライトモード。",
"drawioStyle": "DrawIO スタイル",
"drawioStyleDescription": "キャンバススタイル",
"themeDefault": "デフォルト",
"themeDark": "ダーク",
"themeMinimal": "ミニマル",
"themeSketch": "スケッチ",
"themeSimple": "シンプル",
"diagramStyle": "ダイアグラムスタイル",
"diagramStyleDescription": "ミニマルとスタイル付きの出力を切り替えます。",
"sendShortcut": "送信ショートカット",
"sendShortcutDescription": "メッセージの送信方法を選択します。",
"enterToSend": "Enterで送信",
"ctrlEnterToSend": "Cmd/Ctrl+Enterで送信",
@@ -121,48 +125,41 @@
"httpsProxy": "HTTPS プロキシ",
"applyProxy": "適用",
"proxyApplied": "プロキシ設定が適用されました",
"diagramValidation": "スクリーンショットで図をチェック(実験的)",
"diagramValidationDescription": "描くたびに、画像を読めるモデルがスクリーンショットを確認し、修正を求めます。画像を読めるモデルが必要です。",
"diagramValidation": "ダイアグラム検証(実験的)",
"diagramValidationDescription": "視覚言語モデルを使用して生成されたダイアグラムを検証します。GPT-5.2 や Sonnet-4.5 などの VLM が必要です。",
"enabled": "有効",
"disabled": "無効",
"customSystemMessage": "カスタム指示",
"customSystemMessage": "カスタムシステムメッセージ",
"customSystemMessageDescription": "AIのシステムプロンプトに追加されるカスタム指示を入力します。",
"customSystemMessagePlaceholder": "例:ダイアグラムには常に青色のカラースキームを使用...",
"maxOutputTokens": "最大出力トークン数",
"maxOutputTokensDescription": "1回の応答の予算で、思考過程とダイアグラムの XML が共有します。AI が考え続けてダイアグラムが生成されない場合は大きくしてください。空欄ならデフォルト値を使います。",
"tabModels": "モデルとキー",
"tabAppearance": "外観",
"tabAdvanced": "詳細",
"tabAbout": "このアプリについて",
"themeLight": "ライト",
"themeDarkMode": "ダーク",
"themeSystem": "システムに合わせる",
"accessCodeSaved": "アクセスコードを保存しました",
"proxyInvalid": "プロキシのアドレスは http:// または https:// で始めてください",
"proxyFailed": "プロキシ設定を適用できませんでした",
"appVersion": "バージョン",
"projectWebsite": "プロジェクトのサイト"
"panelVisibility": "ロビーパネル",
"panelVisibilityDescription": "チャットロビーに表示するパネルを選択します。",
"showRecentChats": "最近のチャット",
"showMyTemplates": "マイテンプレート",
"showQuickExamples": "クイック例"
},
"save": {
"title": "図をエクスポート",
"description": "形式とファイル名を選んでください。",
"title": "ダイアグラムを保存",
"description": "形式とファイル名を選択してダイアグラムを保存します。",
"format": "形式",
"filename": "ファイル名",
"filenamePlaceholder": "ファイル名を入力",
"formats": {
"drawio": "draw.io ファイル",
"drawio": "Draw.io XML",
"png": "PNG 画像",
"svg": "SVG 画像",
"xmlsvg": "編集できる SVG"
"xmlsvg": "編集可能 SVG"
},
"savedSuccessfully": "エクスポートしました",
"exportButton": "エクスポート",
"hints": {
"drawio": "すべて編集できるまま保存",
"png": "スライドや文書向けの画像",
"svg": "拡大してもぼやけない画像",
"xmlsvg": "draw.io でも開ける画像"
}
"savedSuccessfully": "保存完了!"
},
"history": {
"title": "ダイアグラム履歴",
"description": "AI 修正前に保存された各ダイアグラム。\nダイアグラムをクリックして復元",
"noHistory": "まだ履歴がありません。メッセージを送信してダイアグラム履歴を作成してください。",
"version": "バージョン",
"restoreTo": "バージョン {version} に復元しますか?"
},
"dialogs": {
"clearTitle": "すべてクリアしますか?",
@@ -192,13 +189,9 @@
"sessionSaveFailed": "このチャットを保存できませんでした。ブラウザのストレージがいっぱいの可能性があります。履歴から古いチャットを削除して、もう一度お試しください。",
"sessionSaveFailedLeave": "このチャットを保存できませんでした。ブラウザのストレージがいっぱいの可能性があります。保存せずに続けて、新しいチャットの一覧から古いチャットを削除できます。",
"continueWithoutSaving": "保存せずに続ける",
"notADiagramFile": "{name} は draw.io の図ではありません。",
"fileTooLong": "{name}:内容が上限の {limit}k 文字を超えています({size}k 文字)",
"fileReadFailed": "ファイルを読み込めませんでした:{name}",
"llm": {
"invalid_api_key": "プロバイダーが API キーを拒否しました。モデル設定で確認してください。",
"forbidden": "プロバイダーがリクエストを拒否しました。このキーにはこのモデルまたはリージョンの利用権限がない可能性があります。",
"server_key_forbidden": "本日の無料枠を使い切りました。明日になると自動的に回復します。モデル設定でご自身の API キーを入力すると、引き続きご利用いただけます。",
"model_not_found": "プロバイダーがこのモデルを認識できません。モデル設定でモデル ID を確認してください。",
"insufficient_quota": "プロバイダーのアカウントの残高または利用枠がなくなりました。",
"rate_limited": "プロバイダーがリクエスト数を制限しています。少し待ってから再試行してください。",
@@ -226,13 +219,18 @@
"tipSelfHosted": "<strong>ヒント:</strong>設定で独自の API キーを設定することで、引き続きサービスをご利用いただけます。",
"reset": "制限は明日リセットされます。ご理解ありがとうございます。",
"doubaoSponsorship": "<a href=\"{link}\" target=\"_blank\" rel=\"noopener noreferrer\" class=\"underline hover:text-foreground\">こちらから登録</a>すると、各モデル(Doubao、DeepSeek、Kimi含む)で50万トークンを無料で取得できます。モデル設定でAPIキーを設定してください。",
"configModel": "自分の API キーを使う",
"configModel": "APIキーを使用",
"selfHost": "セルフホスト",
"sponsor": "スポンサー",
"learnMore": "詳細 →",
"usedOf": "{used}/{limit}"
},
"tools": {
"generateDiagram": "ダイアグラムを生成",
"editDiagram": "ダイアグラムを編集",
"appendDiagram": "ダイアグラムに追加",
"complete": "完了",
"error": "エラー",
"truncated": "切り捨て"
},
"file": {
@@ -241,14 +239,13 @@
"removeFile": "ファイルを削除"
},
"url": {
"title": "Web ページの内容を追加",
"title": "URLからコンテンツを抽出",
"description": "URLを貼り付けてそのコンテンツを抽出および分析します",
"Extracting": "抽出中...",
"extract": "抽出",
"Cancel": "キャンセル",
"enterUrl": "URLを入力してください",
"invalidFormat": "無効なURL形式です",
"failed": "このページを読み込めませんでした"
"invalidFormat": "無効なURL形式です"
},
"reasoning": {
"thinking": "考え中...",
@@ -279,30 +276,28 @@
"usageNotice": "利用量の増加に伴い、コスト削減のためモデルを Claude から minimax-m2 に変更し、いくつかの利用制限を設けました。詳細は概要ページをご覧ください。"
},
"sessionHistory": {
"tooltip": "自分の図",
"tooltip": "チャット履歴",
"newChat": "新しいチャット",
"empty": "まだ図はありません",
"emptyHint": "図を説明して始めましょう",
"empty": "チャット履歴はまだありません",
"emptyHint": "会話を始めてください",
"today": "今日",
"yesterday": "昨日",
"thisWeek": "今週",
"earlier": "それ以前",
"deleteTitle": "この図を削除しますか?",
"deleteDescription": "図とその会話は完全に削除されます。",
"recentChats": "最近の図",
"deleteTitle": "このチャットを削除しますか?",
"deleteDescription": "このチャットセッションとダイアグラムは完全に削除されます。この操作は取り消せません。",
"recentChats": "最近のチャット",
"justNow": "たった今",
"searchPlaceholder": "図を検索…",
"noResults": "チャットが見つかりません",
"rename": "名前を変更",
"busyHint": "回答が終わると選べます"
"searchPlaceholder": "チャットを検索...",
"noResults": "チャットが見つかりません"
},
"validation": {
"title": "図のチェック",
"title": "ダイアグラムを検証",
"capturing": "キャプチャ中",
"validating": "検証中",
"validatingWithAttempt": "検証中 ({attempt}/{max})",
"valid": "有効",
"validWithWarnings": "問題なし(注意点あり)",
"validWithWarnings": "有効(警告あり)",
"issuesFound": "問題が見つかりました",
"error": "エラー",
"skipped": "スキップ",
@@ -311,7 +306,7 @@
"suggestions": "提案:",
"passedValidation": "ダイアグラムは視覚検証に合格しました - 問題は検出されませんでした。",
"improvementRequested": "改善リクエスト済み - 下の新しいダイアグラムを確認してください",
"improveWithSuggestions": "この指摘で改善",
"improveWithSuggestions": "提案で改善",
"regenerateWithFeedback": "検証フィードバックを使用してダイアグラムを再生成"
},
"modelConfig": {
@@ -333,7 +328,7 @@
"validationError": "検証に失敗しました",
"addModelFirst": "検証するには少なくとも1つのモデルを追加してください",
"providers": "プロバイダー",
"addProviderHint": "プロバイダーはまだありません",
"addProviderHint": "プロバイダーを追加して開始",
"verified": "検証済み",
"configuration": "設定",
"displayName": "表示名",
@@ -362,7 +357,7 @@
"noModelsConfigured": "モデルが設定されていません",
"modelIdEmpty": "モデル ID は空にできません",
"modelIdExists": "このモデル ID は既に存在します",
"configureProviders": "AI プロバイダーの設定",
"configureProviders": "AI プロバイダーを設定",
"selectProviderHint": "リストからプロバイダーを選択するか、新規追加して API キーとモデルを設定",
"deleteConfirmDesc": "{name} を削除してもよろしいですか?設定されたすべてのモデルが削除され、元に戻せません。",
"typeToConfirm": "確認のため「{name}」と入力",
@@ -378,31 +373,29 @@
"noVerifiedModels": "検証済みのモデルがありません。先にモデルをテストしてください。",
"noModelsFound": "モデルが見つかりません。",
"default": "デフォルト",
"serverDefault": "サーバーの既定",
"serverModels": "サーバーのモデル",
"userModels": "自分のモデル",
"serverDefault": "サーバーデフォルト",
"serverModels": "サーバーモデル",
"userModels": "ユーザーモデル",
"configureModels": "モデルを設定...",
"onlyVerifiedShown": "検証済みのモデルのみ表示",
"showUnvalidatedModels": "未テストのモデルも表示",
"showUnvalidatedModels": "未検証のモデルを表示",
"allModelsShown": "すべてのモデルを表示(未検証を含む)",
"unvalidatedModelWarning": "このモデルは検証されていません",
"serverDefaultModel": "サーバーデフォルトモデル",
"showValue": "値を表示",
"hideValue": "値を非表示",
"noProvidersTitle": "このサーバーで設定されたモデルを使用中",
"noProvidersHint": "ほかのモデルを使うには、左のリストの下でプロバイダーを追加し、自分の API キーを入力してください。"
"hideValue": "値を非表示"
},
"templates": {
"title": "保存したプロンプト",
"subtitle": "よく使うプロンプトをワンクリックで",
"emptyTitle": "保存したプロンプトはまだありません",
"emptyDescription": "「…の AWS 構成図を描いて」のようによく使うプロンプトを保存すると、ここに表示されます。",
"createFirst": "最初のプロンプトを保存",
"title": "マイテンプレート",
"subtitle": "素早いダイアグラム作成のための個人的なプロンプトライブラリ",
"emptyTitle": "テンプレートがありません",
"emptyDescription": "最初のテンプレートを作成して、個人的なプロンプトライブラリを構築しましょう",
"createFirst": "最初のテンプレートを作成",
"neverUsed": "未使用",
"usedCount": "{count} 回使用",
"myTemplates": "保存したプロンプト",
"createTitle": "プロンプトを保存",
"createDescription": "あとで使えるようにプロンプトを保存します。",
"myTemplates": "マイテンプレート",
"createTitle": "テンプレート作成",
"createDescription": "再利用のためにプロンプトを保存します。テンプレートを使用すると、一般的なワークフローを素早く開始できます。",
"promptLabel": "プロンプト",
"promptPlaceholder": "作成するダイアグラムを説明してください...",
"promptRequired": "プロンプトは必須です",
@@ -410,34 +403,33 @@
"titlePlaceholder": "タイトルを入力",
"titleHint": "空欄の場合はプロンプトの最初の20文字が使用されます",
"descriptionLabel": "説明",
"descriptionPlaceholder": "このプロンプトの用途",
"pinnedLabel": "先頭に固定",
"pinnedHint": "固定したプロンプトはリストの先頭に表示されます",
"createButton": "保存",
"createFailed": "プロンプトを保存できませんでした。もう一度お試しください。",
"editTitle": "プロンプトを編集",
"editDescription": "プロンプトと設定を変更します。",
"updateFailed": "プロンプトを更新できませんでした。もう一度お試しください。",
"descriptionPlaceholder": "このテンプレートの説明を追加",
"pinnedLabel": "テンプレートをピン留め",
"pinnedHint": "ピン留めされたテンプレートはリストの上部に表示されます",
"createButton": "テンプレート作成",
"createFailed": "テンプレートの作成に失敗しました。もう一度お試しください。",
"editTitle": "テンプレート編集",
"editDescription": "テンプレートの内容と設定を更新します。",
"updateFailed": "テンプレートの更新に失敗しました。もう一度お試しください。",
"duplicate": "複製",
"copySuffix": "(コピー)",
"deleteTitle": "このプロンプトを削除しますか?",
"deleteDescription": "プロンプトは完全に削除されます。",
"deleteTitle": "このテンプレートを削除しますか?",
"deleteDescription": "このテンプレートは完全に削除されます。この操作は取り消せません。",
"confirmSendTitle": "現在の入力を置き換えますか?",
"confirmSendDescription": "入力欄に未送信の内容があります。このプロンプトを送ると置き換わります。",
"confirmSendButton": "送信",
"searchPlaceholder": "プロンプトを検索…",
"searchNoResults": "一致するプロンプトはありません",
"confirmSendDescription": "未送信のコンテンツがあります。このテンプレートを送信すると置き換えられます。",
"confirmSendButton": "テンプレートを送信",
"searchPlaceholder": "テンプレートを検索...",
"searchNoResults": "検索に一致するテンプレートがありません",
"pin": "上部にピン留め",
"unpin": "ピン留め解除",
"saveAsTemplate": "このプロンプトを保存",
"exportTemplates": "プロンプトをエクスポート",
"importTemplates": "プロンプトをインポート",
"exportEmpty": "エクスポートするプロンプトがありません",
"exportSuccess": "{count} 件のプロンプトをエクスポートしました",
"saveAsTemplate": "テンプレートとして保存",
"exportTemplates": "テンプレートをエクスポート",
"importTemplates": "テンプレートをインポート",
"exportEmpty": "エクスポートするテンプレートがありません",
"exportSuccess": "{count} 件のテンプレートをエクスポートしました",
"importNoFile": "JSON ファイルを選択してください",
"importFailed": "インポートに失敗しました:{error}",
"importSuccess": "{imported} 件のプロンプトをインポートし、重複 {skipped} 件をスキップしました",
"saveDisabledHint": "先にプロンプトを入力してください"
"importSuccess": "{imported} 件インポート、{skipped} 件の重複をスキップしました"
},
"admin": {
"title": "管理者設定",
@@ -582,109 +574,5 @@
"description": "1 日のリセット境界に使用するタイムゾーン。"
}
}
},
"workspace": {
"toolbar": "キャンバスツール",
"undo": "元に戻す",
"redo": "やり直す",
"select": "選択",
"shapes": "図形ライブラリ",
"text": "テキスト",
"connector": "コネクタ",
"freehand": "フリーハンド",
"zoomIn": "拡大",
"zoomOut": "縮小",
"zoomMenu": "ズーム",
"fitWindow": "ウィンドウに合わせる",
"zoomReset": "実際のサイズ",
"style": "スタイル",
"more": "その他",
"export": "エクスポート",
"openFile": ".drawio ファイルを開く",
"findReplace": "検索と置換",
"layers": "レイヤー",
"outline": "アウトライン",
"toggleGrid": "グリッド",
"togglePageView": "ページ表示",
"insertImage": "画像を挿入",
"insertLink": "リンクを挿入",
"selectAll": "すべて選択",
"editDiagram": "図の XML を編集",
"print": "印刷",
"addPage": "ページを追加",
"renamePage": "名前を変更",
"duplicatePage": "複製",
"deletePage": "ページを削除",
"canvasLoading": "キャンバスを読み込み中…",
"askAi": "ここを AI に直してもらう",
"untitled": "無題の図",
"newDiagram": "新しい図",
"mobileCanvas": "キャンバス",
"mobileChat": "チャット"
},
"selection": {
"one": "選択中:{label}",
"many": "{count} 個の図形を選択中",
"shape": "図形",
"connector": "コネクタ",
"remove": "選択した図形を送らない",
"placeholder": "選択した図形をどう変えたいか書いてください"
},
"versions": {
"strip": "バージョン",
"created": "図を描きました",
"edited": "図を修正しました",
"undone": "この変更は取り消しました",
"undo": "変更を取り消す",
"redo": "変更をやり直す",
"restore": "復元",
"restored": "このバージョンに戻しました",
"compare": "比較",
"compareTitle": "バージョンを比較",
"currentCanvas": "現在のキャンバス",
"versionLabel": "バージョン {n}",
"previous": "前のバージョン",
"next": "次のバージョン",
"rendering": "プレビューを作成中…",
"showCode": "XML を表示",
"hideCode": "XML を隠す",
"drawing": "図を描いています…",
"editing": "図を修正しています…",
"loadingLibrary": "図形ライブラリを読み込み中…",
"libraryLoaded": "図形ライブラリを読み込みました",
"failed": "今回はうまくいきませんでした",
"failedRetrying": "うまくいかなかったので再試行しています",
"stopped": "停止しました",
"truncatedHint": "図が完成する前に出力の上限に達しました。もっと簡単な図を頼むか、設定 > 詳細 で最大出力トークン数を増やしてください。",
"noChanges": "目に見える変更はありません",
"listSeparator": "、",
"shapesAddedOne": "図形を 1 個追加",
"shapesAddedOther": "図形を {count} 個追加",
"edgesAddedOne": "コネクタを 1 本追加",
"edgesAddedOther": "コネクタを {count} 本追加",
"shapesChangedOne": "図形を 1 個変更",
"shapesChangedOther": "図形を {count} 個変更",
"edgesChangedOne": "コネクタを 1 本変更",
"edgesChangedOther": "コネクタを {count} 本変更",
"shapesRemovedOne": "図形を 1 個削除",
"shapesRemovedOther": "図形を {count} 個削除",
"edgesRemovedOne": "コネクタを 1 本削除",
"edgesRemovedOther": "コネクタを {count} 本削除",
"totals": "図形 {shapes} 個、コネクタ {edges} 本",
"restoreVersion": "v{n} に戻す",
"restoredVersion": "v{n} に戻しました",
"undoRestore": "元に戻す",
"noPreview": "プレビューなし",
"compareSince": "バージョン {n} から現在までの変更:",
"isOnCanvas": "現在のキャンバスはこのバージョンと同じです。",
"differsElsewhere": "1 ページ目はバージョン {n} と同じですが、ほかのページかページ設定が変わっています。"
},
"lobby": {
"title": "どんな図を描きますか?",
"lede": "図の内容を説明するか、スクリーンショットや PDF をアップロードしてください。できあがるのは通常の draw.io の図なので、続けて編集もエクスポートもできます。",
"placeholder": "例:SMS 認証とリトライを含むログインのシーケンス図",
"recent": "最近の図",
"drawYourself": "白紙のキャンバスから始める",
"openFileHint": "手元の図の続きを編集"
}
}
+100 -212
View File
@@ -13,12 +13,12 @@
"nav": {
"about": "關於",
"editor": "編輯器",
"newChat": "新增圖紙",
"newChat": "開始新對話",
"github": "GitHub",
"settings": "設定",
"hidePanel": "隱藏聊天面板",
"showPanel": "顯示聊天面板",
"aiChat": "AI 對話"
"hidePanel": "隱藏聊天面板 (Ctrl+B)",
"showPanel": "顯示聊天面板 (Ctrl+B)",
"aiChat": "AI 聊天"
},
"providers": {
"useServerDefault": "使用伺服器預設值",
@@ -39,11 +39,12 @@
},
"chat": {
"placeholder": "描述您的圖表或上傳檔案...",
"inputLabel": "聊天輸入框",
"send": "傳送",
"stopGeneration": "停止產生",
"sendMessage": "傳送訊息",
"clearConversation": "清除對話",
"diagramHistory": "圖表歷史",
"saveDiagram": "儲存圖表",
"uploadFile": "上傳檔案(圖片、PDF、文字)",
"minimalStyle": "簡約",
"styledMode": "精緻",
@@ -57,28 +58,24 @@
"badResponse": "無幫助",
"clickToEdit": "點擊編輯",
"editMessage": "編輯訊息",
"saveAndSubmit": "儲存並送出",
"ExtractURL": "加入網頁",
"attach": "新增",
"placeholderFollowUp": "說說想怎麼改,也可以先選取要改的圖形",
"emptyThread": "可以直接在畫布上畫,也可以在下面說說要畫什麼。",
"attachedImage": "附加的圖片",
"retry": "重試"
"saveAndSubmit": "儲存並提交",
"ExtractURL": "從 URL 擷取"
},
"examples": {
"title": "用 AI 建立圖表",
"subtitle": "描述您想要建立的內容或上傳圖片進行複製",
"paperToDiagram": "論文轉圖表",
"quickExamples": "快速範例",
"paperToDiagram": "文件轉圖表",
"paperDescription": "上傳 .pdf, .txt, .md, .json, .csv, .py, .js, .ts 等檔案",
"animatedDiagram": "動畫圖表",
"animatedDescription": "繪製帶有動畫連接器的 Transformer 架構",
"awsArchitecture": "AWS 架構",
"awsDescription": "使用 AWS 圖示建立雲端架構圖",
"replicateFlowchart": "複刻流程圖",
"replicateFlowchart": "複製流程圖",
"replicateDescription": "上傳並複製現有流程圖",
"creativeDrawing": "創意繪畫",
"creativeDrawing": "創意繪圖",
"creativeDescription": "繪製有趣且富有創意的內容",
"cachedNote": "範例會立即開啟,不呼叫模型。",
"cachedNote": "範例已快取,可即時回應",
"mcpServer": "MCP 伺服器",
"mcpDescription": "在 Claude Desktop、VS Code 和 Cursor 中使用"
},
@@ -104,10 +101,17 @@
"language": "語言",
"languageDescription": "選擇介面語言。",
"theme": "主題",
"themeDescription": "介面用淺色還是深色。圖紙始終是白底,和匯出後的樣子一致。",
"diagramStyle": "圖形風格",
"diagramStyleDescription": "AI 畫新圖時的風格:精緻會用顏色和圖示,簡約只用樸素的圖形。",
"sendShortcut": "傳送快速鍵",
"themeDescription": "介面和 DrawIO 畫布的深色/淺色模式。",
"drawioStyle": "DrawIO 樣式",
"drawioStyleDescription": "畫布樣式",
"themeDefault": "預設",
"themeDark": "深色",
"themeMinimal": "簡約",
"themeSketch": "草圖",
"themeSimple": "簡單",
"diagramStyle": "圖表樣式",
"diagramStyleDescription": "切換簡約與精緻圖表輸出模式。",
"sendShortcut": "傳送快捷鍵",
"sendShortcutDescription": "選擇傳送訊息的方式。",
"enterToSend": "Enter 傳送",
"ctrlEnterToSend": "Cmd/Ctrl+Enter 傳送",
@@ -121,53 +125,46 @@
"httpsProxy": "HTTPS 代理",
"applyProxy": "套用",
"proxyApplied": "代理設定已套用",
"diagramValidation": "用截圖檢查圖表(實驗性)",
"diagramValidationDescription": "每次畫完後,讓能看懂圖片的模型檢查截圖並要求修正。需要能讀圖片的模型。",
"diagramValidation": "圖表驗證(實驗性)",
"diagramValidationDescription": "使用視覺語言模型驗證產生的圖表。需要支援視覺的模型,如 GPT-5.2 或 Sonnet-4.5。",
"enabled": "已啟用",
"disabled": "已停用",
"customSystemMessage": "自訂指令",
"customSystemMessage": "自訂系統訊息",
"customSystemMessageDescription": "新增自訂指示,將附加到 AI 的系統提示末尾。",
"customSystemMessagePlaceholder": "例如:圖表始終使用藍色配色方案...",
"maxOutputTokens": "最大輸出 token 數",
"maxOutputTokensDescription": "單次回覆的額度,思考過程與圖表 XML 共用。若 AI 一直在思考卻沒有產生圖表,請將它調大。留空則使用預設值。",
"tabModels": "模型與金鑰",
"tabAppearance": "外觀",
"tabAdvanced": "進階",
"tabAbout": "關於",
"themeLight": "淺色",
"themeDarkMode": "深色",
"themeSystem": "跟隨系統",
"accessCodeSaved": "存取碼已儲存",
"proxyInvalid": "代理位址必須以 http:// 或 https:// 開頭",
"proxyFailed": "代理設定沒有生效",
"appVersion": "版本",
"projectWebsite": "專案網站"
"panelVisibility": "大廳面板",
"panelVisibilityDescription": "選擇在聊天大廳顯示哪些面板。",
"showRecentChats": "最近聊天",
"showMyTemplates": "我的範本",
"showQuickExamples": "快速範例"
},
"save": {
"title": "匯出圖紙",
"description": "選擇格式和檔案名稱。",
"title": "儲存圖表",
"description": "選擇格式和檔案名稱以儲存您的圖表。",
"format": "格式",
"filename": "檔案名稱",
"filenamePlaceholder": "輸入檔案名稱",
"formats": {
"drawio": "draw.io 檔案",
"drawio": "Draw.io XML",
"png": "PNG 圖片",
"svg": "SVG 圖片",
"xmlsvg": "可編輯的 SVG"
"xmlsvg": "可編輯 SVG"
},
"savedSuccessfully": "已匯出",
"exportButton": "匯出",
"hints": {
"drawio": "所有內容都能繼續編輯",
"png": "用於簡報和文件的圖片",
"svg": "放大也不會模糊的圖片",
"xmlsvg": "圖片,也能在 draw.io 裡開啟編輯"
}
"savedSuccessfully": "儲存成功!"
},
"history": {
"title": "圖表歷史",
"description": "在 AI 修改之前儲存的每個圖表。\n點擊圖表以還原它",
"noHistory": "尚無歷史紀錄。傳送訊息以建立圖表歷史。",
"version": "版本",
"restoreTo": "還原到版本 {version}?"
},
"dialogs": {
"clearTitle": "清除所有內容?",
"clearDescription": "這將清除目前對話並重設圖表。此操作無法復原。",
"clearEverything": "全部清除",
"clearEverything": "清除所有內容",
"clearSuccess": "已開始新對話"
},
"errors": {
@@ -192,13 +189,9 @@
"sessionSaveFailed": "無法儲存這個對話。瀏覽器儲存空間可能已滿,請在歷史紀錄裡刪除舊對話後重試。",
"sessionSaveFailedLeave": "無法儲存這個對話,瀏覽器儲存空間可能已滿。可以不儲存它、直接繼續,再在新對話的列表裡刪除舊對話。",
"continueWithoutSaving": "不儲存,繼續",
"notADiagramFile": "{name} 不是 draw.io 圖表檔案。",
"fileTooLong": "{name}:內容超過了 {limit}k 字元的上限({size}k 字元)",
"fileReadFailed": "無法讀取檔案:{name}",
"llm": {
"invalid_api_key": "服務商拒絕了這個 API Key,請在模型設定中檢查。",
"forbidden": "服務商拒絕了這次請求。這個 Key 可能沒有使用該模型或該地區的權限。",
"server_key_forbidden": "今天的免費額度已經用完,明天會自動恢復。您也可以在模型設定中填寫自己的 API Key 繼續使用。",
"model_not_found": "服務商找不到這個模型,請在模型設定中檢查模型 ID。",
"insufficient_quota": "服務商帳戶的餘額或額度已經用完。",
"rate_limited": "服務商正在限制請求頻率,請稍候再試。",
@@ -226,13 +219,18 @@
"tipSelfHosted": "<strong>提示:</strong>您可以在設定中配置自己的 API 金鑰以繼續使用服務。",
"reset": "您的限制將在明天重設。感謝您的理解。",
"doubaoSponsorship": "<a href=\"{link}\" target=\"_blank\" rel=\"noopener noreferrer\" class=\"underline hover:text-foreground\">點此註冊</a>可獲得每個模型 50 萬免費 Token(包括豆包、DeepSeek 和 Kimi),然後在模型設定中配置您的 API Key。",
"configModel": "使用自己的 API Key",
"configModel": "使用您的金鑰",
"selfHost": "自行託管",
"sponsor": "贊助",
"learnMore": "了解更多 →",
"usedOf": "{used}/{limit}"
},
"tools": {
"generateDiagram": "產生圖表",
"editDiagram": "編輯圖表",
"appendDiagram": "繼續圖表",
"complete": "完成",
"error": "錯誤",
"truncated": "已截斷"
},
"file": {
@@ -241,14 +239,13 @@
"removeFile": "移除檔案"
},
"url": {
"title": "加入網頁內容",
"title": "從 URL 擷取內容",
"description": "貼上 URL 以擷取和分析其內容",
"Extracting": "擷取中...",
"extract": "擷取",
"Cancel": "取消",
"enterUrl": "請輸入 URL",
"invalidFormat": "URL 格式無效",
"failed": "無法讀取這個網頁"
"invalidFormat": "URL 格式無效"
},
"reasoning": {
"thinking": "思考中...",
@@ -279,34 +276,32 @@
"usageNotice": "由於使用量過高,我已將模型從 Claude 更換為 minimax-m2,並設定了一些用量限制。詳情請查看關於頁面。"
},
"sessionHistory": {
"tooltip": "我的圖紙",
"tooltip": "聊天歷史",
"newChat": "新對話",
"empty": "還沒有圖紙",
"emptyHint": "描述一張圖來開始",
"empty": "暫無聊天紀錄",
"emptyHint": "開始對話吧",
"today": "今天",
"yesterday": "昨天",
"thisWeek": "本週",
"earlier": "更早",
"deleteTitle": "刪除這張圖紙?",
"deleteDescription": "圖紙和它的對話會被永久刪除。",
"recentChats": "最近的圖紙",
"deleteTitle": "刪除此對話?",
"deleteDescription": "這將永久刪除此聊天工作階段及其圖表。此操作無法復原。",
"recentChats": "最近對話",
"justNow": "剛剛",
"searchPlaceholder": "搜尋圖紙…",
"noResults": "未找到對話",
"rename": "重新命名",
"busyHint": "回答完成後才能切換"
"searchPlaceholder": "搜尋對話...",
"noResults": "未找到對話"
},
"templates": {
"title": "常用提示詞",
"subtitle": "常用的提示詞,點一下就能用",
"emptyTitle": "還沒有常用提示詞",
"emptyDescription": "把經常用的提示詞存起來,比如「畫一張……的 AWS 架構圖」,之後會出現在這裡。",
"createFirst": "儲存第一個提示詞",
"title": "我的範本",
"subtitle": "快速建立圖表的個人提示庫",
"emptyTitle": "尚無範本",
"emptyDescription": "建立您的第一個範本,開始構建您的個人提示庫",
"createFirst": "建立第一個範本",
"neverUsed": "未使用過",
"usedCount": "使用 {count} 次",
"myTemplates": "常用提示詞",
"createTitle": "儲存提示詞",
"createDescription": "把提示詞存起來,以後再用。",
"myTemplates": "我的範本",
"createTitle": "建立範本",
"createDescription": "儲存常用提示以便重複使用。範本幫助您快速開始常用工作流程。",
"promptLabel": "提示",
"promptPlaceholder": "描述您想建立的圖表...",
"promptRequired": "提示為必填項",
@@ -314,42 +309,41 @@
"titlePlaceholder": "輸入標題",
"titleHint": "留空則使用提示的前 20 個字元",
"descriptionLabel": "描述",
"descriptionPlaceholder": "這個提示詞的用途",
"pinnedLabel": "置頂",
"pinnedHint": "置頂的提示詞顯示在清單最上面",
"createButton": "儲存",
"createFailed": "儲存提示詞失敗,請重試。",
"editTitle": "編輯提示詞",
"editDescription": "修改提示詞和它的設定。",
"updateFailed": "更新提示詞失敗,請重試。",
"descriptionPlaceholder": "為此範本新增描述",
"pinnedLabel": "釘選範本",
"pinnedHint": "釘選的範本會顯示在清單頂部",
"createButton": "建立範本",
"createFailed": "建立範本失敗,請重試。",
"editTitle": "編輯範本",
"editDescription": "更新範本內容和設定。",
"updateFailed": "更新範本失敗,請重試。",
"duplicate": "複製",
"copySuffix": "(副本)",
"deleteTitle": "刪除這個提示詞?",
"deleteDescription": "提示詞會被永久刪除。",
"deleteTitle": "刪除此範本?",
"deleteDescription": "此操作將永久刪除該範本,無法復原。",
"confirmSendTitle": "取代目前輸入?",
"confirmSendDescription": "輸入框裡有還沒傳送的內容,傳送這個提示詞會取代它。",
"confirmSendButton": "傳送",
"searchPlaceholder": "搜尋提示詞…",
"searchNoResults": "沒有符合的提示詞",
"confirmSendDescription": "您有未傳送的內容。傳送此範本將取代它。",
"confirmSendButton": "傳送範本",
"searchPlaceholder": "搜尋範本...",
"searchNoResults": "沒有符合的範本",
"pin": "置頂",
"unpin": "取消置頂",
"saveAsTemplate": "儲存這個提示詞",
"exportTemplates": "匯出提示詞",
"importTemplates": "匯入提示詞",
"exportEmpty": "沒有可匯出的提示詞",
"exportSuccess": "已匯出 {count} 個提示詞",
"saveAsTemplate": "儲存為範本",
"exportTemplates": "匯出範本",
"importTemplates": "匯入範本",
"exportEmpty": "沒有可匯出的範本",
"exportSuccess": "成功匯出 {count} 個範本",
"importNoFile": "請選擇一個 JSON 檔案",
"importFailed": "匯入失敗:{error}",
"importSuccess": "已匯入 {imported} 個提示詞,略過 {skipped} 個重複",
"saveDisabledHint": "先在輸入框裡寫好提示詞"
"importSuccess": "成功匯入 {imported} 個範本,跳過 {skipped} 個重複"
},
"validation": {
"title": "圖表檢查",
"title": "驗證圖表",
"capturing": "截圖中",
"validating": "驗證中",
"validatingWithAttempt": "驗證中 ({attempt}/{max})",
"valid": "通過",
"validWithWarnings": "大致沒問題,有幾點提示",
"validWithWarnings": "通過(有警告)",
"issuesFound": "發現問題",
"error": "錯誤",
"skipped": "已跳過",
@@ -358,19 +352,19 @@
"suggestions": "建議:",
"passedValidation": "圖表通過視覺驗證 - 未發現問題。",
"improvementRequested": "改進請求已傳送 - 請查看下方新圖表",
"improveWithSuggestions": "依這些建議改進",
"improveWithSuggestions": "根據建議改進",
"regenerateWithFeedback": "使用驗證回饋重新產生圖表"
},
"modelConfig": {
"title": "AI 模型配置",
"description": "配置多個 AI 提供商和模型",
"configure": "配置",
"addProvider": "新增服務商",
"addProvider": "新增提供商",
"addModel": "新增模型",
"modelId": "模型 ID",
"modelLabel": "顯示名稱",
"streaming": "啟用串流輸出",
"deleteProvider": "刪除服務商",
"deleteProvider": "刪除提供商",
"deleteModel": "刪除模型",
"noModels": "尚未配置模型。新增模型以開始使用。",
"selectProvider": "選擇一個提供商或新增",
@@ -380,7 +374,7 @@
"validationError": "驗證失敗",
"addModelFirst": "請先新增至少一個模型以進行驗證",
"providers": "提供商",
"addProviderHint": "還沒有服務商",
"addProviderHint": "新增提供商即可開始使用",
"verified": "已驗證",
"configuration": "配置",
"displayName": "顯示名稱",
@@ -388,7 +382,7 @@
"awsSecretAccessKey": "AWS Secret Access Key",
"awsRegion": "AWS 區域",
"selectRegion": "選擇區域",
"apiKey": "API Key",
"apiKey": "API 金鑰",
"enterApiKey": "輸入您的 API 金鑰",
"enterSecretKey": "輸入您的 Secret Key",
"baseUrl": "基礎 URL",
@@ -409,7 +403,7 @@
"noModelsConfigured": "尚未配置模型",
"modelIdEmpty": "模型 ID 不能為空",
"modelIdExists": "此模型 ID 已存在",
"configureProviders": "設定 AI 服務商",
"configureProviders": "配置 AI 提供商",
"selectProviderHint": "從列表中選擇提供商或新增以配置 API 金鑰和模型",
"deleteConfirmDesc": "確定要刪除 {name} 嗎?這將移除所有配置的模型且無法復原。",
"typeToConfirm": "輸入「{name}」以確認",
@@ -427,17 +421,15 @@
"default": "預設",
"serverDefault": "伺服器預設",
"serverModels": "伺服器模型",
"userModels": "我的模型",
"userModels": "使用者模型",
"configureModels": "配置模型...",
"onlyVerifiedShown": "僅顯示已驗證的模型",
"showUnvalidatedModels": "顯示還沒測試過的模型",
"showUnvalidatedModels": "顯示未驗證的模型",
"allModelsShown": "顯示所有模型(包括未驗證的)",
"unvalidatedModelWarning": "此模型尚未驗證",
"serverDefaultModel": "伺服器預設模型",
"showValue": "顯示值",
"hideValue": "隱藏值",
"noProvidersTitle": "正在使用伺服器上設定的模型",
"noProvidersHint": "想用其他模型,可以在左側清單下方新增服務商並填寫自己的 API Key。"
"hideValue": "隱藏值"
},
"admin": {
"title": "管理員設定",
@@ -582,109 +574,5 @@
"description": "每日重置邊界所用的時區。"
}
}
},
"workspace": {
"toolbar": "畫布工具",
"undo": "復原",
"redo": "重做",
"select": "選取",
"shapes": "圖形庫",
"text": "文字",
"connector": "連線",
"freehand": "手繪",
"zoomIn": "放大",
"zoomOut": "縮小",
"zoomMenu": "縮放",
"fitWindow": "符合視窗",
"zoomReset": "實際大小",
"style": "樣式",
"more": "更多",
"export": "匯出",
"openFile": "開啟 .drawio 檔案",
"findReplace": "尋找與取代",
"layers": "圖層",
"outline": "縮圖",
"toggleGrid": "格線",
"togglePageView": "頁面檢視",
"insertImage": "插入圖片",
"insertLink": "插入連結",
"selectAll": "全選",
"editDiagram": "編輯圖表 XML",
"print": "列印",
"addPage": "新增頁面",
"renamePage": "重新命名",
"duplicatePage": "複製頁面",
"deletePage": "刪除頁面",
"canvasLoading": "正在載入畫布…",
"askAi": "讓 AI 改這裡",
"untitled": "未命名圖紙",
"newDiagram": "新增圖紙",
"mobileCanvas": "畫布",
"mobileChat": "對話"
},
"selection": {
"one": "已選取:{label}",
"many": "已選取 {count} 個圖形",
"shape": "圖形",
"connector": "連線",
"remove": "不傳送選取的圖形",
"placeholder": "說說要怎麼改選取的圖形"
},
"versions": {
"strip": "版本",
"created": "畫好了圖表",
"edited": "改好了圖表",
"undone": "這次修改已復原",
"undo": "復原修改",
"redo": "重做修改",
"restore": "還原",
"restored": "已還原到這一版",
"compare": "比較",
"compareTitle": "比較版本",
"currentCanvas": "現在的畫布",
"versionLabel": "第 {n} 版",
"previous": "上一版",
"next": "下一版",
"rendering": "正在產生預覽…",
"showCode": "檢視 XML",
"hideCode": "收起 XML",
"drawing": "正在畫圖…",
"editing": "正在修改圖表…",
"loadingLibrary": "正在讀取圖形庫…",
"libraryLoaded": "讀取了圖形庫",
"failed": "這次沒有成功",
"failedRetrying": "這次沒有成功,正在重試",
"stopped": "已停止",
"truncatedHint": "回覆達到輸出長度上限,圖表沒有畫完。可以要一張簡單些的圖,或在 設定 > 進階 中調高最大輸出長度。",
"noChanges": "沒有可見的變化",
"listSeparator": ",",
"shapesAddedOne": "新增 1 個圖形",
"shapesAddedOther": "新增 {count} 個圖形",
"edgesAddedOne": "新增 1 條連線",
"edgesAddedOther": "新增 {count} 條連線",
"shapesChangedOne": "改了 1 個圖形",
"shapesChangedOther": "改了 {count} 個圖形",
"edgesChangedOne": "改了 1 條連線",
"edgesChangedOther": "改了 {count} 條連線",
"shapesRemovedOne": "刪了 1 個圖形",
"shapesRemovedOther": "刪了 {count} 個圖形",
"edgesRemovedOne": "刪了 1 條連線",
"edgesRemovedOther": "刪了 {count} 條連線",
"totals": "共 {shapes} 個圖形、{edges} 條連線",
"restoreVersion": "還原到 v{n}",
"restoredVersion": "已還原到 v{n}",
"undoRestore": "復原",
"noPreview": "沒有預覽",
"compareSince": "從第 {n} 版到現在,畫布上:",
"isOnCanvas": "畫布現在顯示的就是這一版。",
"differsElsewhere": "第一頁和第 {n} 版相同,其他頁面或頁面設定有改動。"
},
"lobby": {
"title": "說說你要畫什麼",
"lede": "描述一張圖,或上傳截圖、PDF。畫出來的是標準的 draw.io 圖表,可以接著改,也可以匯出。",
"placeholder": "例如:使用者登入的時序圖,包含簡訊驗證碼和失敗重試",
"recent": "最近的圖紙",
"drawYourself": "從空白畫布開始",
"openFileHint": "接著編輯已有的圖"
}
}
+98 -210
View File
@@ -13,12 +13,12 @@
"nav": {
"about": "关于",
"editor": "编辑器",
"newChat": "新建图纸",
"newChat": "开始新对话",
"github": "GitHub",
"settings": "设置",
"hidePanel": "隐藏聊天面板",
"showPanel": "显示聊天面板",
"aiChat": "AI 对话"
"hidePanel": "隐藏聊天面板 (Ctrl+B)",
"showPanel": "显示聊天面板 (Ctrl+B)",
"aiChat": "AI 聊天"
},
"providers": {
"useServerDefault": "使用服务器默认值",
@@ -39,11 +39,12 @@
},
"chat": {
"placeholder": "描述您的图表或上传文件...",
"inputLabel": "聊天输入框",
"send": "发送",
"stopGeneration": "停止生成",
"sendMessage": "发送消息",
"clearConversation": "清除对话",
"diagramHistory": "图表历史",
"saveDiagram": "保存图表",
"uploadFile": "上传文件(图片、PDF、文本)",
"minimalStyle": "简约",
"styledMode": "精致",
@@ -58,27 +59,23 @@
"clickToEdit": "点击编辑",
"editMessage": "编辑消息",
"saveAndSubmit": "保存并提交",
"ExtractURL": "添加网页",
"attach": "添加",
"placeholderFollowUp": "说说想怎么改,也可以先选中要改的图形",
"emptyThread": "可以直接在画布上画,也可以在下面说说要画什么。",
"attachedImage": "附带的图片",
"retry": "重试"
"ExtractURL": "从 URL 提取"
},
"examples": {
"title": "用 AI 创建图表",
"subtitle": "描述您想要创建的内容或上传图片进行复制",
"paperToDiagram": "论文转图表",
"quickExamples": "快速示例",
"paperToDiagram": "文档转图表",
"paperDescription": "上传 .pdf, .txt, .md, .json, .csv, .py, .js, .ts 等文件",
"animatedDiagram": "动画图表",
"animatedDescription": "绘制带有动画连接器的 Transformer 架构",
"awsArchitecture": "AWS 架构",
"awsDescription": "使用 AWS 图标创建云架构图",
"replicateFlowchart": "复刻流程图",
"replicateFlowchart": "复制流程图",
"replicateDescription": "上传并复制现有流程图",
"creativeDrawing": "创意绘画",
"creativeDrawing": "创意绘图",
"creativeDescription": "绘制有趣且富有创意的内容",
"cachedNote": "示例会立即打开,不调用模型。",
"cachedNote": "示例已缓存,可即时响应",
"mcpServer": "MCP 服务器",
"mcpDescription": "在 Claude Desktop、VS Code 和 Cursor 中使用"
},
@@ -104,9 +101,16 @@
"language": "语言",
"languageDescription": "选择界面语言。",
"theme": "主题",
"themeDescription": "界面用浅色还是深色。图纸始终是白底,和导出后的样子一致。",
"diagramStyle": "图形风格",
"diagramStyleDescription": "AI 画新图时的风格:精致会用颜色和图标,简约只用朴素的图形。",
"themeDescription": "界面和 DrawIO 画布的深色/浅色模式。",
"drawioStyle": "DrawIO 样式",
"drawioStyleDescription": "画布样式",
"themeDefault": "默认",
"themeDark": "深色",
"themeMinimal": "简约",
"themeSketch": "草图",
"themeSimple": "简单",
"diagramStyle": "图表样式",
"diagramStyleDescription": "切换简约与精致图表输出模式。",
"sendShortcut": "发送快捷键",
"sendShortcutDescription": "选择发送消息的方式。",
"enterToSend": "回车发送",
@@ -121,53 +125,46 @@
"httpsProxy": "HTTPS 代理",
"applyProxy": "应用",
"proxyApplied": "代理设置已应用",
"diagramValidation": "用截图检查图表(实验性)",
"diagramValidationDescription": "每次画完后,让能看懂图片的模型检查截图并要求修正。需要能读图片的模型。",
"diagramValidation": "图表验证(实验性)",
"diagramValidationDescription": "使用视觉语言模型验证生成的图表。需要支持视觉的模型,如 GPT-5.2 或 Sonnet-4.5。",
"enabled": "已启用",
"disabled": "已禁用",
"customSystemMessage": "自定义指令",
"customSystemMessage": "自定义系统消息",
"customSystemMessageDescription": "添加自定义指令,将附加到 AI 的系统提示末尾。",
"customSystemMessagePlaceholder": "例如:图表始终使用蓝色配色方案...",
"maxOutputTokens": "最大输出 token 数",
"maxOutputTokensDescription": "单次回复的额度,思考过程和图表 XML 共用。如果 AI 一直在思考却没有生成图表,请把它调大。留空则使用默认值。",
"tabModels": "模型和密钥",
"tabAppearance": "外观",
"tabAdvanced": "高级",
"tabAbout": "关于",
"themeLight": "浅色",
"themeDarkMode": "深色",
"themeSystem": "跟随系统",
"accessCodeSaved": "访问码已保存",
"proxyInvalid": "代理地址必须以 http:// 或 https:// 开头",
"proxyFailed": "代理设置没有生效",
"appVersion": "版本",
"projectWebsite": "项目网站"
"panelVisibility": "大厅面板",
"panelVisibilityDescription": "选择在聊天大厅显示哪些面板。",
"showRecentChats": "最近聊天",
"showMyTemplates": "我的模板",
"showQuickExamples": "快速示例"
},
"save": {
"title": "导出图纸",
"description": "选择格式和文件名。",
"title": "保存图表",
"description": "选择格式和文件名以保存您的图表。",
"format": "格式",
"filename": "文件名",
"filenamePlaceholder": "输入文件名",
"formats": {
"drawio": "draw.io 文件",
"drawio": "Draw.io XML",
"png": "PNG 图片",
"svg": "SVG 图片",
"xmlsvg": "可编辑的 SVG"
"xmlsvg": "可编辑 SVG"
},
"savedSuccessfully": "已导出",
"exportButton": "导出",
"hints": {
"drawio": "所有内容都能继续编辑",
"png": "用于幻灯片和文档的图片",
"svg": "放大也不会模糊的图片",
"xmlsvg": "图片,也能在 draw.io 里打开编辑"
}
"savedSuccessfully": "保存成功!"
},
"history": {
"title": "图表历史",
"description": "在 AI 修改之前保存的每个图表。\n点击图表以恢复它",
"noHistory": "尚无历史记录。发送消息以创建图表历史。",
"version": "版本",
"restoreTo": "恢复到版本 {version}?"
},
"dialogs": {
"clearTitle": "清除所有内容?",
"clearDescription": "这将清除当前对话并重置图表。此操作无法撤消。",
"clearEverything": "全部清除",
"clearEverything": "清除所有内容",
"clearSuccess": "已开始新对话"
},
"errors": {
@@ -192,13 +189,9 @@
"sessionSaveFailed": "无法保存这个对话。浏览器存储空间可能已满,请在历史记录里删除旧对话后重试。",
"sessionSaveFailedLeave": "无法保存这个对话,浏览器存储空间可能已满。可以不保存它、直接继续,再在新对话的列表里删除旧对话。",
"continueWithoutSaving": "不保存,继续",
"notADiagramFile": "{name} 不是 draw.io 图表文件。",
"fileTooLong": "{name}:内容超过了 {limit}k 字符的上限({size}k 字符)",
"fileReadFailed": "无法读取文件:{name}",
"llm": {
"invalid_api_key": "服务商拒绝了这个 API Key,请在模型设置里检查。",
"forbidden": "服务商拒绝了这次请求。这个 Key 可能没有使用该模型或该地区的权限。",
"server_key_forbidden": "今天的免费额度已经用完,明天会自动恢复。您也可以在模型设置里填写自己的 API Key 继续使用。",
"model_not_found": "服务商找不到这个模型,请在模型设置里检查模型 ID。",
"insufficient_quota": "服务商账户的余额或额度已经用完。",
"rate_limited": "服务商正在限制请求频率,请稍等片刻再试。",
@@ -226,13 +219,18 @@
"tipSelfHosted": "<strong>提示:</strong>您可以在设置中配置自己的 API 密钥以继续使用服务。",
"reset": "您的限制将在明天重置。感谢您的理解。",
"doubaoSponsorship": "<a href=\"{link}\" target=\"_blank\" rel=\"noopener noreferrer\" class=\"underline hover:text-foreground\">点击此处注册</a>可获得每个模型 50 万免费 Token(包括豆包、DeepSeek 和 Kimi),然后在模型设置中配置您的 API Key。",
"configModel": "使用自己的 API Key",
"configModel": "使用您的密钥",
"selfHost": "自托管",
"sponsor": "赞助",
"learnMore": "了解更多 →",
"usedOf": "{used}/{limit}"
},
"tools": {
"generateDiagram": "生成图表",
"editDiagram": "编辑图表",
"appendDiagram": "继续图表",
"complete": "完成",
"error": "错误",
"truncated": "已截断"
},
"file": {
@@ -241,14 +239,13 @@
"removeFile": "移除文件"
},
"url": {
"title": "添加网页内容",
"title": "从 URL 提取内容",
"description": "粘贴 URL 以提取和分析其内容",
"Extracting": "提取中...",
"extract": "提取",
"Cancel": "取消",
"enterUrl": "请输入 URL",
"invalidFormat": "URL 格式无效",
"failed": "无法读取这个网页"
"invalidFormat": "URL 格式无效"
},
"reasoning": {
"thinking": "思考中...",
@@ -279,34 +276,32 @@
"usageNotice": "由于使用量过高,我已将模型从 Claude 更换为 minimax-m2,并设置了一些用量限制。详情请查看关于页面。"
},
"sessionHistory": {
"tooltip": "我的图纸",
"tooltip": "聊天历史",
"newChat": "新对话",
"empty": "还没有图纸",
"emptyHint": "描述一张图来开始",
"empty": "暂无聊天记录",
"emptyHint": "开始对话吧",
"today": "今天",
"yesterday": "昨天",
"thisWeek": "本周",
"earlier": "更早",
"deleteTitle": "删除这张图纸?",
"deleteDescription": "图纸和它的对话会被永久删除。",
"recentChats": "最近的图纸",
"deleteTitle": "删除此对话?",
"deleteDescription": "这将永久删除此聊天会话及其图表。此操作无法撤消。",
"recentChats": "最近对话",
"justNow": "刚刚",
"searchPlaceholder": "搜索图纸…",
"noResults": "未找到对话",
"rename": "重命名",
"busyHint": "回答完成后才能切换"
"searchPlaceholder": "搜索对话...",
"noResults": "未找到对话"
},
"templates": {
"title": "常用提示词",
"subtitle": "常用的提示词,点一下就能用",
"emptyTitle": "还没有常用提示词",
"emptyDescription": "把经常用的提示词存起来,比如“画一张……的 AWS 架构图”,之后会出现在这里。",
"createFirst": "保存第一个提示词",
"title": "我的模板库",
"subtitle": "您的个人 prompt 库,快速创建图表",
"emptyTitle": "暂无模板",
"emptyDescription": "创建您的第一个模板,开始构建个人 prompt 库",
"createFirst": "创建第一个模板",
"neverUsed": "未使用过",
"usedCount": "使用 {count} 次",
"myTemplates": "常用提示词",
"createTitle": "保存提示词",
"createDescription": "把提示词存起来,以后再用。",
"myTemplates": "我的模板",
"createTitle": "创建模板",
"createDescription": "保存常用 prompt 以便重复使用。模板帮助您快速开始常用工作流。",
"promptLabel": "提示词",
"promptPlaceholder": "描述您想要创建的图表...",
"promptRequired": "提示词不能为空",
@@ -314,42 +309,41 @@
"titlePlaceholder": "输入标题",
"titleHint": "留空则使用提示词前 20 个字符作为标题",
"descriptionLabel": "描述",
"descriptionPlaceholder": "这个提示词的用途",
"pinnedLabel": "置顶",
"pinnedHint": "置顶的提示词显示在列表最上面",
"createButton": "保存",
"createFailed": "保存提示词失败,请重试。",
"editTitle": "编辑提示词",
"editDescription": "修改提示词和它的设置。",
"updateFailed": "更新提示词失败,请重试。",
"descriptionPlaceholder": "为此模板添加描述",
"pinnedLabel": "置顶模板",
"pinnedHint": "置顶的模板会显示在列表顶部",
"createButton": "创建模板",
"createFailed": "创建模板失败,请重试。",
"editTitle": "编辑模板",
"editDescription": "更新模板内容和设置。",
"updateFailed": "更新模板失败,请重试。",
"duplicate": "复制",
"copySuffix": "(副本)",
"deleteTitle": "删除这个提示词?",
"deleteDescription": "提示词会被永久删除。",
"deleteTitle": "删除此模板?",
"deleteDescription": "此操作将永久删除该模板,无法撤销。",
"confirmSendTitle": "替换当前输入?",
"confirmSendDescription": "输入框里有还没发送的内容,发送这个提示词会替换它。",
"confirmSendButton": "发送",
"searchPlaceholder": "搜索提示词…",
"searchNoResults": "没有匹配的提示词",
"confirmSendDescription": "您有未发送的内容。发送此模板将替换它。",
"confirmSendButton": "发送模板",
"searchPlaceholder": "搜索模板...",
"searchNoResults": "没有匹配的模板",
"pin": "置顶",
"unpin": "取消置顶",
"saveAsTemplate": "保存这个提示词",
"exportTemplates": "导出提示词",
"importTemplates": "导入提示词",
"exportEmpty": "没有可导出的提示词",
"exportSuccess": "已导出 {count} 个提示词",
"saveAsTemplate": "保存为模板",
"exportTemplates": "导出模板",
"importTemplates": "导入模板",
"exportEmpty": "没有可导出的模板",
"exportSuccess": "成功导出 {count} 个模板",
"importNoFile": "请选择一个 JSON 文件",
"importFailed": "导入失败:{error}",
"importSuccess": "已导入 {imported} 个提示词,跳过 {skipped} 个重复",
"saveDisabledHint": "先在输入框里写好提示词"
"importSuccess": "成功导入 {imported} 个模板,跳过 {skipped} 个重复"
},
"validation": {
"title": "图表检查",
"title": "验证图表",
"capturing": "截图中",
"validating": "验证中",
"validatingWithAttempt": "验证中 ({attempt}/{max})",
"valid": "通过",
"validWithWarnings": "基本没问题,有几点提示",
"validWithWarnings": "通过(有警告)",
"issuesFound": "发现问题",
"error": "错误",
"skipped": "已跳过",
@@ -358,19 +352,19 @@
"suggestions": "建议:",
"passedValidation": "图表通过视觉验证 - 未发现问题。",
"improvementRequested": "改进请求已发送 - 请查看下方新图表",
"improveWithSuggestions": "按这些建议改进",
"improveWithSuggestions": "根据建议改进",
"regenerateWithFeedback": "使用验证反馈重新生成图表"
},
"modelConfig": {
"title": "AI 模型配置",
"description": "配置多个 AI 提供商和模型",
"configure": "配置",
"addProvider": "添加服务商",
"addProvider": "添加提供商",
"addModel": "添加模型",
"modelId": "模型 ID",
"modelLabel": "显示名称",
"streaming": "启用流式输出",
"deleteProvider": "删除服务商",
"deleteProvider": "删除提供商",
"deleteModel": "删除模型",
"noModels": "尚未配置模型。添加模型以开始使用。",
"selectProvider": "选择一个提供商或添加新的",
@@ -380,7 +374,7 @@
"validationError": "验证失败",
"addModelFirst": "请先添加至少一个模型以进行验证",
"providers": "提供商",
"addProviderHint": "还没有服务商",
"addProviderHint": "添加提供商即可开始使用",
"verified": "已验证",
"configuration": "配置",
"displayName": "显示名称",
@@ -388,7 +382,7 @@
"awsSecretAccessKey": "AWS Secret Access Key",
"awsRegion": "AWS 区域",
"selectRegion": "选择区域",
"apiKey": "API Key",
"apiKey": "API 密钥",
"enterApiKey": "输入您的 API 密钥",
"enterSecretKey": "输入您的 Secret Key",
"baseUrl": "基础 URL",
@@ -409,7 +403,7 @@
"noModelsConfigured": "尚未配置模型",
"modelIdEmpty": "模型 ID 不能为空",
"modelIdExists": "此模型 ID 已存在",
"configureProviders": "设置 AI 服务商",
"configureProviders": "配置 AI 提供商",
"selectProviderHint": "从列表中选择提供商或添加新的以配置 API 密钥和模型",
"deleteConfirmDesc": "确定要删除 {name} 吗?这将移除所有配置的模型且无法撤销。",
"typeToConfirm": "输入 \"{name}\" 以确认",
@@ -427,17 +421,15 @@
"default": "默认",
"serverDefault": "服务器默认",
"serverModels": "服务器模型",
"userModels": "我的模型",
"userModels": "用户模型",
"configureModels": "配置模型...",
"onlyVerifiedShown": "仅显示已验证的模型",
"showUnvalidatedModels": "显示还没测试过的模型",
"showUnvalidatedModels": "显示未验证的模型",
"allModelsShown": "显示所有模型(包括未验证的)",
"unvalidatedModelWarning": "此模型尚未验证",
"serverDefaultModel": "服务器默认模型",
"showValue": "显示值",
"hideValue": "隐藏值",
"noProvidersTitle": "正在使用服务器上配置的模型",
"noProvidersHint": "想用其他模型,可以在左侧列表下方添加服务商并填写自己的 API Key。"
"hideValue": "隐藏值"
},
"admin": {
"title": "管理员设置",
@@ -582,109 +574,5 @@
"description": "每日重置边界所用的时区。"
}
}
},
"workspace": {
"toolbar": "画布工具",
"undo": "撤销",
"redo": "重做",
"select": "选择",
"shapes": "图形库",
"text": "文字",
"connector": "连线",
"freehand": "手绘",
"zoomIn": "放大",
"zoomOut": "缩小",
"zoomMenu": "缩放",
"fitWindow": "适合窗口",
"zoomReset": "实际大小",
"style": "样式",
"more": "更多",
"export": "导出",
"openFile": "打开 .drawio 文件",
"findReplace": "查找和替换",
"layers": "图层",
"outline": "缩略图",
"toggleGrid": "网格",
"togglePageView": "页面视图",
"insertImage": "插入图片",
"insertLink": "插入链接",
"selectAll": "全选",
"editDiagram": "编辑图表 XML",
"print": "打印",
"addPage": "新建页面",
"renamePage": "重命名",
"duplicatePage": "复制页面",
"deletePage": "删除页面",
"canvasLoading": "正在加载画布…",
"askAi": "让 AI 改这里",
"untitled": "未命名图纸",
"newDiagram": "新建图纸",
"mobileCanvas": "画布",
"mobileChat": "对话"
},
"selection": {
"one": "已选:{label}",
"many": "已选 {count} 个图形",
"shape": "图形",
"connector": "连线",
"remove": "不发送选中的图形",
"placeholder": "说说要怎么改选中的图形"
},
"versions": {
"strip": "版本",
"created": "画好了图表",
"edited": "改好了图表",
"undone": "这次修改已撤销",
"undo": "撤销修改",
"redo": "重做修改",
"restore": "恢复",
"restored": "已恢复到这一版",
"compare": "对比",
"compareTitle": "对比版本",
"currentCanvas": "现在的画布",
"versionLabel": "第 {n} 版",
"previous": "上一版",
"next": "下一版",
"rendering": "正在生成预览…",
"showCode": "查看 XML",
"hideCode": "收起 XML",
"drawing": "正在画图…",
"editing": "正在修改图表…",
"loadingLibrary": "正在读取图形库…",
"libraryLoaded": "读取了图形库",
"failed": "这次没有成功",
"failedRetrying": "这次没有成功,正在重试",
"stopped": "已停止",
"truncatedHint": "回复达到了输出长度上限,图表没有画完。可以要一张简单些的图,或者在 设置 > 高级 里调高最大输出长度。",
"noChanges": "没有可见的变化",
"listSeparator": ",",
"shapesAddedOne": "新增 1 个图形",
"shapesAddedOther": "新增 {count} 个图形",
"edgesAddedOne": "新增 1 条连线",
"edgesAddedOther": "新增 {count} 条连线",
"shapesChangedOne": "改了 1 个图形",
"shapesChangedOther": "改了 {count} 个图形",
"edgesChangedOne": "改了 1 条连线",
"edgesChangedOther": "改了 {count} 条连线",
"shapesRemovedOne": "删了 1 个图形",
"shapesRemovedOther": "删了 {count} 个图形",
"edgesRemovedOne": "删了 1 条连线",
"edgesRemovedOther": "删了 {count} 条连线",
"totals": "共 {shapes} 个图形、{edges} 条连线",
"restoreVersion": "恢复到 v{n}",
"restoredVersion": "已恢复到 v{n}",
"undoRestore": "撤销",
"noPreview": "没有预览",
"compareSince": "从第 {n} 版到现在,画布上:",
"isOnCanvas": "画布现在显示的就是这一版。",
"differsElsewhere": "第一页和第 {n} 版相同,其他页面或页面设置有改动。"
},
"lobby": {
"title": "说说你要画什么",
"lede": "描述一张图,或者上传截图、PDF。画出来的是标准的 draw.io 图表,可以接着改,也可以导出。",
"placeholder": "比如:用户登录的时序图,包含短信验证码和失败重试",
"recent": "最近的图纸",
"drawYourself": "从空白画布开始",
"openFileHint": "接着编辑已有的图"
}
}
+1 -9
View File
@@ -24,8 +24,6 @@ export type LLMErrorCode =
| "provider_unavailable"
| "cannot_connect"
| "timeout"
// A 403 on the server's own key, e.g. a daily spend cap blocked it
| "server_key_forbidden"
| "unknown"
export interface LLMError {
@@ -132,13 +130,7 @@ export function streamErrorText(error: unknown, hideDetails = false): string {
const classified = classifyLLMError(error)
if (hideDetails) {
console.error("[chat] Provider error:", error)
if (classified.code === "forbidden") {
// The hint says all the user can do; there is no message to add
classified.code = "server_key_forbidden"
classified.message = ""
} else {
classified.message = "The provider returned an error."
}
classified.message = "The provider returned an error."
}
return JSON.stringify(classified)
}
-7
View File
@@ -1,7 +0,0 @@
/** True on macOS and iOS, where shortcuts use ⌘ instead of Ctrl */
export const isMac =
typeof navigator !== "undefined" &&
/Mac|iPhone|iPad/.test(navigator.userAgent)
/** Prefix for shortcut labels: "⌘" on Apple platforms, "Ctrl+" elsewhere */
export const modKey = isMac ? "⌘" : "Ctrl+"
+2 -6
View File
@@ -3,7 +3,6 @@ import { getModelInfo } from "@/lib/model-catalog"
import { readLimitedBody } from "@/lib/read-limited-body"
import {
normalizeBaseUrl,
ollamaApiUrl,
PROVIDER_INFO,
type ProviderName,
} from "@/lib/types/model-config"
@@ -201,11 +200,8 @@ export async function listProviderModels(
break
}
case "ollama": {
const data = await getJson(
`${ollamaApiUrl(base)}/tags`,
bearer,
fetchFn,
)
const api = base.endsWith("/api") ? base : `${base}/api`
const data = await getJson(`${api}/tags`, bearer, fetchFn)
models = (data.models ?? []).map((m: { name: string }) => ({
id: m.name,
}))
-20
View File
@@ -1,20 +0,0 @@
/** "3 min ago", "yesterday", or a short date, in the UI language */
export function formatRelativeTime(
timestamp: number,
locale: string,
justNow: string,
): string {
const diffMs = Date.now() - timestamp
const minutes = Math.floor(diffMs / 60_000)
if (minutes < 1) return justNow
const rtf = new Intl.RelativeTimeFormat(locale, { numeric: "auto" })
if (minutes < 60) return rtf.format(-minutes, "minute")
const hours = Math.floor(minutes / 60)
if (hours < 24) return rtf.format(-hours, "hour")
const days = Math.floor(hours / 24)
if (days < 7) return rtf.format(-days, "day")
return new Date(timestamp).toLocaleDateString(locale, {
month: "short",
day: "numeric",
})
}
+1 -18
View File
@@ -1,7 +1,6 @@
import { type DBSchema, type IDBPDatabase, openDB } from "idb"
import { nanoid } from "nanoid"
import { toast } from "sonner"
import type { DiagramVersion } from "@/stores/versions-store"
import type { Template } from "./template-storage"
// Constants
@@ -21,18 +20,13 @@ export interface ChatSession {
xmlSnapshots: [number, string][]
diagramXml: string
thumbnailDataUrl?: string // Small PNG preview of the diagram
// Old version list (before AI versions existed); read once, then replaced
diagramHistory?: { svg: string; xml: string }[]
// Diagram states produced by AI changes, shown as cards in the chat
versions?: DiagramVersion[]
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 }>
// e.g. the shapes selected when a user message was sent
metadata?: unknown
}
export interface SessionMetadata {
@@ -247,16 +241,6 @@ export async function enforceSessionLimit(): Promise<void> {
}
}
export async function renameSession(
id: string,
title: string,
): Promise<ChatSession | null> {
const session = await getSession(id)
if (!session) return null
const updated = { ...session, title, updatedAt: Date.now() }
return (await saveSession(updated)) ? updated : null
}
// Helper: Create a new empty session
export function createEmptySession(): ChatSession {
return {
@@ -316,7 +300,6 @@ export function sanitizeMessage(message: unknown): StoredMessage | null {
id: msg.id as string,
role: role as "user" | "assistant" | "system",
parts,
...(msg.metadata !== undefined && { metadata: msg.metadata }),
}
}
+20 -11
View File
@@ -1,19 +1,26 @@
// Centralized localStorage keys for settings.
// Chat data is stored in IndexedDB via session-storage.ts
// Centralized localStorage keys for quota tracking and settings
// Chat data is now stored in IndexedDB via session-storage.ts
export const STORAGE_KEYS = {
// Access code for password-protected deployments
// Quota tracking
requestCount: "next-ai-draw-io-request-count",
requestDate: "next-ai-draw-io-request-date",
tokenCount: "next-ai-draw-io-token-count",
tokenDate: "next-ai-draw-io-token-date",
tpmCount: "next-ai-draw-io-tpm-count",
tpmMinute: "next-ai-draw-io-tpm-minute",
// Settings
accessCode: "next-ai-draw-io-access-code",
accessCodeRequired: "next-ai-draw-io-access-code-required",
aiProvider: "next-ai-draw-io-ai-provider",
aiBaseUrl: "next-ai-draw-io-ai-base-url",
aiApiKey: "next-ai-draw-io-ai-api-key",
aiModel: "next-ai-draw-io-ai-model",
// Multi-model configuration
modelConfigs: "next-ai-draw-io-model-configs",
// Appearance
darkMode: "next-ai-draw-io-dark-mode",
locale: "next-ai-draw-io-locale",
minimalStyle: "next-ai-draw-io-minimal-style",
panelWidth: "next-ai-draw-io-panel-width",
selectedModelId: "next-ai-draw-io-selected-model-id",
// Chat input preferences
sendShortcut: "next-ai-draw-io-send-shortcut",
@@ -27,6 +34,8 @@ export const STORAGE_KEYS = {
// Output token budget per turn (empty = server default)
maxOutputTokens: "next-ai-draw-io-max-output-tokens",
// Langfuse session id
sessionId: "next-ai-draw-io-session-id",
// Panel visibility
showRecentChats: "next-ai-draw-io-show-recent-chats",
showMyTemplates: "next-ai-draw-io-show-my-templates",
showQuickExamples: "next-ai-draw-io-show-quick-examples",
} as const
+1 -10
View File
@@ -622,15 +622,6 @@ export function normalizeBaseUrl(url: string): string {
.replace(/\/(?:chat\/completions|completions|messages|responses)$/, "")
}
/**
* Ollama's native API root, which the SDK appends /chat to and the model
* list /tags. Users often enter the server address ("http://localhost:11434")
* or its OpenAI-compatible one (".../v1"); both get /api.
*/
export function ollamaApiUrl(baseUrl: string): string {
return `${normalizeBaseUrl(baseUrl).replace(/\/(?:api|v1)$/, "")}/api`
}
/** Where a chat request goes for a base URL, or null when the SDK decides */
export function chatRequestUrl(
provider: ProviderName,
@@ -639,7 +630,6 @@ export function chatRequestUrl(
const url = normalizeBaseUrl(baseUrl)
if (!url) return null
if (provider === "anthropic") return `${url}/messages`
if (provider === "ollama") return `${ollamaApiUrl(url)}/chat`
// These SDKs build their own paths (or, for MiniMax, pick the protocol
// from the URL)
const ownPaths: ProviderName[] = [
@@ -647,6 +637,7 @@ export function chatRequestUrl(
"vertexai",
"azure",
"bedrock",
"ollama",
"gateway",
"minimax",
"edgeone",
+4 -23
View File
@@ -2,7 +2,6 @@
import { useState } from "react"
import { toast } from "sonner"
import { formatMessage } from "@/lib/i18n/utils"
import {
extractPdfText,
extractTextFileContent,
@@ -17,24 +16,11 @@ export interface FileData {
isExtracting: boolean
}
/** Toast texts; {name}, {limit} and {size} are filled in */
export interface FileProcessorMessages {
tooLong: string
readFailed: string
}
const DEFAULT_MESSAGES: FileProcessorMessages = {
tooLong: "{name}: Content exceeds {limit}k character limit ({size}k chars)",
readFailed: "Failed to read file: {name}",
}
/**
* Hook for processing file uploads, especially PDFs and text files.
* Handles text extraction, character limit validation, and cleanup.
*/
export function useFileProcessor(
messages: FileProcessorMessages = DEFAULT_MESSAGES,
) {
export function useFileProcessor() {
const [files, setFiles] = useState<File[]>([])
const [pdfData, setPdfData] = useState<Map<File, FileData>>(new Map())
@@ -72,12 +58,9 @@ export function useFileProcessor(
// Check character limit
if (text.length > MAX_EXTRACTED_CHARS) {
const limitK = MAX_EXTRACTED_CHARS / 1000
toast.error(
formatMessage(messages.tooLong, {
name: file.name,
limit: MAX_EXTRACTED_CHARS / 1000,
size: (text.length / 1000).toFixed(1),
}),
`${file.name}: Content exceeds ${limitK}k character limit (${(text.length / 1000).toFixed(1)}k chars)`,
)
setPdfData((prev) => {
const next = new Map(prev)
@@ -102,9 +85,7 @@ export function useFileProcessor(
})
} catch (error) {
console.error("Failed to extract text:", error)
toast.error(
formatMessage(messages.readFailed, { name: file.name }),
)
toast.error(`Failed to read file: ${file.name}`)
setPdfData((prev) => {
const next = new Map(prev)
next.delete(file)
+2 -11
View File
@@ -2,19 +2,10 @@
* Generate a userId from request for tracking purposes.
* Uses base64url encoding of IP for URL-safe identifier.
* Note: base64 is reversible - this is NOT privacy protection.
*
* The first X-Forwarded-For entry is whatever the visitor sent, so behind a
* CDN set CLIENT_IP_HEADER to the header it fills with the real IP (e.g.
* cf-connecting-ip), and ORIGIN_SECRET so requests that skip the CDN are
* refused (see proxy.ts).
*/
export function getUserIdFromRequest(req: Request): string {
const ipHeader = process.env.CLIENT_IP_HEADER
const rawIp =
(ipHeader
? req.headers.get(ipHeader)?.trim()
: req.headers.get("x-forwarded-for")?.split(",")[0]?.trim()) ||
"anonymous"
const forwardedFor = req.headers.get("x-forwarded-for")
const rawIp = forwardedFor?.split(",")[0]?.trim() || "anonymous"
return rawIp === "anonymous"
? rawIp
: `user-${Buffer.from(rawIp).toString("base64url")}`
-35
View File
@@ -1,35 +0,0 @@
import type { ChangeSummary } from "@/lib/diagram-diff"
import { formatMessage } from "@/lib/i18n/utils"
type VersionDict = Record<string, string>
const PARTS: [keyof ChangeSummary, string][] = [
["shapesAdded", "shapesAdded"],
["edgesAdded", "edgesAdded"],
["shapesChanged", "shapesChanged"],
["edgesChanged", "edgesChanged"],
["shapesRemoved", "shapesRemoved"],
["edgesRemoved", "edgesRemoved"],
]
/**
* "Added 2 shapes, changed 1 connector". Each dictionary entry has a
* singular (...One) and plural (...Other) form; languages without plurals
* use the same text for both.
*/
export function describeChanges(
summary: ChangeSummary,
t: VersionDict,
): string {
const pieces = PARTS.filter(([field]) => summary[field] > 0).map(
([field, key]) => {
const count = summary[field]
const template = count === 1 ? t[`${key}One`] : t[`${key}Other`]
return formatMessage(template ?? "", { count })
},
)
if (pieces.length === 0) return t.noChanges ?? ""
const text = pieces.join(t.listSeparator ?? ", ")
// Sentence case for scripts with letter case
return text.charAt(0).toUpperCase() + text.slice(1)
}
-19
View File
@@ -4,31 +4,12 @@ import packageJson from "./package.json"
const nextConfig: NextConfig = {
/* config options here */
output: "standalone",
// Keep the dev badge clear of the chat panel (right) and the page tabs
// (bottom left)
devIndicators: { position: "top-left" },
// Support for subdirectory deployment (e.g., https://example.com/nextaidrawio)
// Set NEXT_PUBLIC_BASE_PATH environment variable to your subdirectory path (e.g., /nextaidrawio)
basePath: process.env.NEXT_PUBLIC_BASE_PATH || "",
env: {
APP_VERSION: packageJson.version,
},
// The bundled draw.io shares the app's origin (and its stored keys):
// only the app may put it in a frame
async headers() {
return [
{
source: "/drawio/:path*",
headers: [
{
key: "Content-Security-Policy",
value: "frame-ancestors 'self'",
},
{ key: "X-Frame-Options", value: "SAMEORIGIN" },
],
},
]
},
// Include instrumentation.ts in standalone build for Langfuse telemetry,
// and the shape library docs get_shape_library reads at run time
outputFileTracingIncludes: {
+1849 -1242
View File
File diff suppressed because it is too large Load Diff
+16 -17
View File
@@ -5,9 +5,7 @@
"private": true,
"main": "dist-electron/main/index.js",
"scripts": {
"predev": "node scripts/fetch-drawio.mjs",
"dev": "next dev --turbopack --port 6002",
"prebuild": "node scripts/fetch-drawio.mjs",
"build": "next build",
"start": "next start --port 6001",
"lint": "biome lint .",
@@ -33,15 +31,15 @@
"test:e2e": "playwright test"
},
"dependencies": {
"@ai-sdk/amazon-bedrock": "^4.0.191",
"@ai-sdk/anthropic": "^3.0.127",
"@ai-sdk/azure": "^3.0.133",
"@ai-sdk/deepseek": "^2.0.71",
"@ai-sdk/google": "^3.0.130",
"@ai-sdk/google-vertex": "^4.0.210",
"@ai-sdk/openai": "^3.0.124",
"@ai-sdk/openai-compatible": "^2.0.81",
"@ai-sdk/react": "^3.0.303",
"@ai-sdk/amazon-bedrock": "^5.0.0",
"@ai-sdk/anthropic": "^4.0.0",
"@ai-sdk/azure": "^4.0.0",
"@ai-sdk/deepseek": "^3.0.0",
"@ai-sdk/google": "^4.0.0",
"@ai-sdk/google-vertex": "^5.0.0",
"@ai-sdk/openai": "^4.0.0",
"@ai-sdk/openai-compatible": "^3.0.0",
"@ai-sdk/react": "^4.0.0",
"@aihubmix/ai-sdk-provider": "^2.2.1",
"@aws-sdk/client-dynamodb": "^3.957.0",
"@aws-sdk/credential-providers": "^3.943.0",
@@ -52,7 +50,7 @@
"@langfuse/tracing": "^4.4.9",
"@next/third-parties": "^16.0.6",
"@opennextjs/cloudflare": "^1.17.1",
"@openrouter/ai-sdk-provider": "^2.10.0",
"@openrouter/ai-sdk-provider": "^3.0.0",
"@opentelemetry/api": "^1.9.0",
"@opentelemetry/exporter-trace-otlp-http": "^0.222.0",
"@opentelemetry/sdk-trace-node": "^2.2.0",
@@ -68,7 +66,7 @@
"@radix-ui/react-tooltip": "^1.1.8",
"@radix-ui/react-use-controllable-state": "^1.2.2",
"@xmldom/xmldom": "^0.9.8",
"ai": "^6.0.300",
"ai": "^7.0.0",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"cmdk": "^1.1.1",
@@ -79,13 +77,15 @@
"nanoid": "^5.0.0",
"negotiator": "^1.0.0",
"next": "^16.0.7",
"ollama-ai-provider-v2": "^3.6.0",
"ollama-ai-provider-v2": "^4.0.0",
"pako": "^2.1.0",
"prism-react-renderer": "^2.4.1",
"react": "^19.1.2",
"react-dom": "^19.1.2",
"react-drawio": "^1.0.3",
"react-icons": "^5.5.0",
"react-markdown": "^10.1.0",
"react-resizable-panels": "^3.0.6",
"remark-gfm": "^4.0.1",
"saxes": "^6.0.0",
"server-only": "^0.0.1",
@@ -94,8 +94,7 @@
"tailwindcss-animate": "^1.0.7",
"turndown": "^7.2.0",
"unpdf": "^1.4.0",
"zod": "^4.1.12",
"zustand": "^5.0.15"
"zod": "^4.1.12"
},
"optionalDependencies": {
"@tailwindcss/oxide-linux-x64-gnu": "^4.1.18",
@@ -145,7 +144,7 @@
},
"overrides": {
"@openrouter/ai-sdk-provider": {
"ai": "^6.0.1"
"ai": "^7.0.0"
}
}
}
+70 -102
View File
@@ -507,12 +507,12 @@
}
},
"node_modules/@hono/node-server": {
"version": "2.1.3",
"resolved": "https://registry.npmjs.org/@hono/node-server/-/node-server-2.1.3.tgz",
"integrity": "sha512-TA//nWMqPhbfdfneACk6t5a9eqbS9lABEPyKn0/xZTah3H3U2XaVg85rJFl0/Fyit0I552YDHgXGVSf3GwqbUw==",
"version": "1.19.9",
"resolved": "https://registry.npmjs.org/@hono/node-server/-/node-server-1.19.9.tgz",
"integrity": "sha512-vHL6w3ecZsky+8P5MD+eFfaGTyCeOHUIFYMGpQGbrBTSmNNoxv0if69rEZ5giu36weC5saFuznL411gRX7bJDw==",
"license": "MIT",
"engines": {
"node": ">=20"
"node": ">=18.14.1"
},
"peerDependencies": {
"hono": "^4"
@@ -1045,9 +1045,9 @@
}
},
"node_modules/ajv": {
"version": "8.20.0",
"resolved": "https://registry.npmjs.org/ajv/-/ajv-8.20.0.tgz",
"integrity": "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==",
"version": "8.17.1",
"resolved": "https://registry.npmjs.org/ajv/-/ajv-8.17.1.tgz",
"integrity": "sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g==",
"license": "MIT",
"dependencies": {
"fast-deep-equal": "^3.1.3",
@@ -1088,20 +1088,20 @@
}
},
"node_modules/body-parser": {
"version": "2.3.0",
"resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.3.0.tgz",
"integrity": "sha512-2cGmJupaNgg+QUwVLAucDuWuoMZ6EX9iHDRswZ5lsNYEmwPaRknMPCLZz07yTzVq/83p4o/wzbDZbBrTvGGTIw==",
"version": "2.2.1",
"resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.2.1.tgz",
"integrity": "sha512-nfDwkulwiZYQIGwxdy0RUmowMhKcFVcYXUU7m4QlKYim1rUtg83xm2yjZ40QjDuc291AJjjeSc9b++AWHSgSHw==",
"license": "MIT",
"dependencies": {
"bytes": "^3.1.2",
"content-type": "^2.0.0",
"content-type": "^1.0.5",
"debug": "^4.4.3",
"http-errors": "^2.0.1",
"iconv-lite": "^0.7.2",
"http-errors": "^2.0.0",
"iconv-lite": "^0.7.0",
"on-finished": "^2.4.1",
"qs": "^6.15.2",
"raw-body": "^3.0.2",
"type-is": "^2.1.0"
"qs": "^6.14.0",
"raw-body": "^3.0.1",
"type-is": "^2.0.1"
},
"engines": {
"node": ">=18"
@@ -1111,19 +1111,6 @@
"url": "https://opencollective.com/express"
}
},
"node_modules/body-parser/node_modules/content-type": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/content-type/-/content-type-2.1.0.tgz",
"integrity": "sha512-mj7UPXE0jaqaOsukNZRUEfEi2AcL7C/vwmwcHV0O97eO1E1pxBZuyjlZrx5seTaNBg1U6+o35wpa35Qfcc+7ag==",
"license": "MIT",
"engines": {
"node": ">=18"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/express"
}
},
"node_modules/boolbase": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/boolbase/-/boolbase-2.0.0.tgz",
@@ -1590,9 +1577,9 @@
"license": "MIT"
},
"node_modules/es-object-atoms": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.2.tgz",
"integrity": "sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==",
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz",
"integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==",
"license": "MIT",
"dependencies": {
"es-errors": "^1.3.0"
@@ -1743,13 +1730,12 @@
}
},
"node_modules/express-rate-limit": {
"version": "8.7.0",
"resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-8.7.0.tgz",
"integrity": "sha512-hOwV7WOxXfjRpAM1DSJWZDXx3GhplwD8IfwuwvogD8i1Qnkgosw/H45s4ZnFAUHDAhPjlY9hLBvJhKmGMyY26g==",
"version": "8.2.1",
"resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-8.2.1.tgz",
"integrity": "sha512-PCZEIEIxqwhzw4KF0n7QF4QqruVTcF73O5kFKUnGOyjbCCgizBBiFaYpd/fnBLUMPw/BWw9OsiN7GgrNYr7j6g==",
"license": "MIT",
"dependencies": {
"debug": "^4.4.3",
"ip-address": "^10.2.0"
"ip-address": "10.0.1"
},
"engines": {
"node": ">= 16"
@@ -1768,9 +1754,9 @@
"license": "MIT"
},
"node_modules/fast-uri": {
"version": "3.1.8",
"resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.8.tgz",
"integrity": "sha512-GZMtZUTNRpOVIECoXwLNZS5xUGE+mVNbTB8h/7Rwh2TFWcBQiPzTgyZi05BF9UMZKkLJv8XBRJTlU7zg8+ZfMg==",
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.0.tgz",
"integrity": "sha512-iPeeDKJSWf4IEOasVVrknXpaBV0IApz/gp7S2bb7Z4Lljbl2MGJRqInZiUrQwV16cpzw/D3S5j5Julj/gT52AA==",
"funding": [
{
"type": "github",
@@ -1926,9 +1912,9 @@
}
},
"node_modules/hasown": {
"version": "2.0.4",
"resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz",
"integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==",
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz",
"integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==",
"license": "MIT",
"dependencies": {
"function-bind": "^1.1.2"
@@ -1938,9 +1924,9 @@
}
},
"node_modules/hono": {
"version": "4.13.13",
"resolved": "https://registry.npmjs.org/hono/-/hono-4.13.13.tgz",
"integrity": "sha512-CQ46U0ZkAGmbT/4UxdzzGJpacP2IeKgY4a5/tOI9AABbpOMfK739wfDXmv1usCk+3RkKj1hQy4/fjhiwa2xlrA==",
"version": "4.11.9",
"resolved": "https://registry.npmjs.org/hono/-/hono-4.11.9.tgz",
"integrity": "sha512-Eaw2YTGM6WOxA6CXbckaEvslr2Ne4NFsKrvc0v97JD5awbmeBLO5w9Ho9L9kmKonrwF9RJlW6BxT1PVv/agBHQ==",
"license": "MIT",
"engines": {
"node": ">=16.9.0"
@@ -2004,9 +1990,9 @@
}
},
"node_modules/iconv-lite": {
"version": "0.7.3",
"resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.3.tgz",
"integrity": "sha512-IKXpvIzjnC9XTAUbVBcMfGS0EPaIXtW6v+zr+RRp+hqULEpo0owZax6wyRwPOJbWbzjYspQwusTsfVr0ifh4uQ==",
"version": "0.7.1",
"resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.1.tgz",
"integrity": "sha512-2Tth85cXwGFHfvRgZWszZSvdo+0Xsqmw8k8ZwxScfcBneNUraK+dxRxRm24nszx80Y0TVio8kKLt5sLE7ZCLlw==",
"license": "MIT",
"dependencies": {
"safer-buffer": ">= 2.1.2 < 3.0.0"
@@ -2026,9 +2012,9 @@
"license": "ISC"
},
"node_modules/ip-address": {
"version": "10.7.3",
"resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.7.3.tgz",
"integrity": "sha512-A1kdq/tSb5QjvKvAMgIoEvDBIgL7qaqVP/jkvSwYYRZ9iEzvPpopxp2wQfu3SuZRHtpHNxMn8Fs0bS+gf5Xmwg==",
"version": "10.0.1",
"resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.0.1.tgz",
"integrity": "sha512-NWv9YLW4PoW2B7xtzaS3NCot75m6nK7Icdv0o3lfMceJVRfSoQwqD4wEH5rLwoKJwUiZ/rfpiVBhnaF0FK4HoA==",
"license": "MIT",
"engines": {
"node": ">= 12"
@@ -2493,9 +2479,9 @@
"license": "MIT"
},
"node_modules/nanoid": {
"version": "3.3.20",
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.20.tgz",
"integrity": "sha512-uKdg2G3GNCKQn9byYOpxbGqrT2fGO5KRt5J/8b3pok8rT6qxGWF6hxMyJiEYtAf+FVyYuD9hRaDqX5uPFYJ4ZQ==",
"version": "3.3.12",
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.12.tgz",
"integrity": "sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ==",
"dev": true,
"funding": [
{
@@ -2659,9 +2645,9 @@
}
},
"node_modules/path-to-regexp": {
"version": "8.4.2",
"resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-8.4.2.tgz",
"integrity": "sha512-qRcuIdP69NPm4qbACK+aDogI5CBDMi1jKe0ry5rSQJz8JVLsC7jV8XpiJjGRLLol3N+R5ihGYcrPLTno6pAdBA==",
"version": "8.3.0",
"resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-8.3.0.tgz",
"integrity": "sha512-7jdwVIRtsP8MYpdXSwOS0YdD0Du+qOoF/AEPIt88PcCFrZCzx41oxku1jD88hZBwbNUIEfpqvuhjFaMAqMTWnA==",
"license": "MIT",
"funding": {
"type": "opencollective",
@@ -2705,9 +2691,9 @@
}
},
"node_modules/postcss": {
"version": "8.5.29",
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.29.tgz",
"integrity": "sha512-49cGhUbXj8Qenv0iTMxA1cFBzxXoctpC9Ujd77t1WcbJIr6nF/eI7g/8MgxrYldFRuAXvja7xQRwavoW7kgrxQ==",
"version": "8.5.15",
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.15.tgz",
"integrity": "sha512-FfR8sjd4em2T6fb3I2MwAJU7HWVMr9zba+enmQeeWFfCbm+UOC/0X4DS8XtpUTMwWMGbjKYP7xjfNekzyGmB3A==",
"dev": true,
"funding": [
{
@@ -2725,9 +2711,9 @@
],
"license": "MIT",
"dependencies": {
"nanoid": "^3.3.19",
"nanoid": "^3.3.12",
"picocolors": "^1.1.1",
"source-map-js": "^1.2.2"
"source-map-js": "^1.2.1"
},
"engines": {
"node": "^10 || ^12 || >=14"
@@ -2759,13 +2745,12 @@
}
},
"node_modules/qs": {
"version": "6.16.0",
"resolved": "https://registry.npmjs.org/qs/-/qs-6.16.0.tgz",
"integrity": "sha512-h6fhOIaRrID2CbEY2fqs+7t+UXZo+MLAnU5gRIq85uFtdiUPCdsApMlHhXogKVM4HM2DVbIjGNTTYH2OcmP1vA==",
"version": "6.14.0",
"resolved": "https://registry.npmjs.org/qs/-/qs-6.14.0.tgz",
"integrity": "sha512-YWWTjgABSKcvs/nWBi9PycY/JiPJqOD4JA6o9Sej2AtvSGarXxKC3OQSk4pAarbdQlKAh5D4FCQkJNkW+GAn3w==",
"license": "BSD-3-Clause",
"dependencies": {
"es-define-property": "^1.0.1",
"side-channel": "^1.1.1"
"side-channel": "^1.1.0"
},
"engines": {
"node": ">=0.6"
@@ -2960,14 +2945,14 @@
}
},
"node_modules/side-channel": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.1.tgz",
"integrity": "sha512-6x6dK6zJdpTzF4sQeNYxwtvBzf6Eg4GtlesS94HOvTudUeyK2WXAaIfmDgsyslYrRBeFIlsi54AYsFGUuhmvrQ==",
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz",
"integrity": "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==",
"license": "MIT",
"dependencies": {
"es-errors": "^1.3.0",
"object-inspect": "^1.13.4",
"side-channel-list": "^1.0.1",
"object-inspect": "^1.13.3",
"side-channel-list": "^1.0.0",
"side-channel-map": "^1.0.1",
"side-channel-weakmap": "^1.0.2"
},
@@ -2979,13 +2964,13 @@
}
},
"node_modules/side-channel-list": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.1.tgz",
"integrity": "sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==",
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.0.tgz",
"integrity": "sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA==",
"license": "MIT",
"dependencies": {
"es-errors": "^1.3.0",
"object-inspect": "^1.13.4"
"object-inspect": "^1.13.3"
},
"engines": {
"node": ">= 0.4"
@@ -3039,9 +3024,9 @@
"license": "ISC"
},
"node_modules/source-map-js": {
"version": "1.2.2",
"resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.2.tgz",
"integrity": "sha512-KGj/8Y43x35aZVDtt+J4mK1hoLGHULMYfSkODJNQjNDC3oW1PqPoxMwo0pLUsWM/UEGzON/NxeHywEfNXNP3Vw==",
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
"integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==",
"dev": true,
"license": "BSD-3-Clause",
"engines": {
@@ -3152,34 +3137,17 @@
}
},
"node_modules/type-is": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/type-is/-/type-is-2.1.0.tgz",
"integrity": "sha512-faYHw0anBbc/kWF3zFTEnxSFOAGUX9GFbOBthvDdLsIlEoWOFOtS0zgCiQYwIskL9iGXZL3kAXD8OoZ4GmMATA==",
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/type-is/-/type-is-2.0.1.tgz",
"integrity": "sha512-OZs6gsjF4vMp32qrCbiVSkrFmXtG/AZhY3t0iAMrMBiAZyV9oALtXO8hsrHbMXF9x6L3grlFuwW2oAz7cav+Gw==",
"license": "MIT",
"dependencies": {
"content-type": "^2.0.0",
"content-type": "^1.0.5",
"media-typer": "^1.1.0",
"mime-types": "^3.0.0"
},
"engines": {
"node": ">= 18"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/express"
}
},
"node_modules/type-is/node_modules/content-type": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/content-type/-/content-type-2.1.0.tgz",
"integrity": "sha512-mj7UPXE0jaqaOsukNZRUEfEi2AcL7C/vwmwcHV0O97eO1E1pxBZuyjlZrx5seTaNBg1U6+o35wpa35Qfcc+7ag==",
"license": "MIT",
"engines": {
"node": ">=18"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/express"
"node": ">= 0.6"
}
},
"node_modules/typescript": {
+4 -17
View File
@@ -27,19 +27,9 @@ function getLocale(request: NextRequest): string | undefined {
export function proxy(request: NextRequest) {
const pathname = request.nextUrl.pathname
if (pathname.startsWith("/api/")) {
// With ORIGIN_SECRET set, API calls must come through the CDN that
// adds this header. A call straight to the origin could fake the
// CLIENT_IP_HEADER and get a fresh quota for every made-up IP.
const secret = process.env.ORIGIN_SECRET
if (secret && request.headers.get("x-origin-secret") !== secret) {
return NextResponse.json({ error: "Forbidden" }, { status: 403 })
}
return
}
// Skip static files and Next.js internals
// Skip API routes, static files, and Next.js internals
if (
pathname.startsWith("/api/") ||
pathname.startsWith("/_next/") ||
pathname.startsWith("/drawio") ||
pathname.includes("/favicon") ||
@@ -68,9 +58,6 @@ export function proxy(request: NextRequest) {
}
export const config = {
// API routes (for ORIGIN_SECRET), and pages without `/_next/` assets
matcher: [
"/api/:path*",
"/((?!api|_next/static|_next/image|favicon.ico).*)",
],
// Matcher ignoring `/_next/` and `/api/`
matcher: ["/((?!api|_next/static|_next/image|favicon.ico).*)"],
}
-162
View File
@@ -1,162 +0,0 @@
#!/usr/bin/env node
/**
* Downloads the draw.io web app into public/drawio.
*
* The app embeds this copy from its own origin. Same origin lets the page
* call the draw.io editor directly (highlight AI changes, read the selection,
* undo AI edits, custom toolbar), which a cross-origin iframe does not allow.
*
* Runs before `dev` and `build`. It does nothing when the pinned version is
* already present, or when NEXT_PUBLIC_DRAWIO_BASE_URL points to an external
* draw.io (that setup does not need the bundled copy).
*
* The release asset (draw.war) is a zip file; it is unpacked with node:zlib
* so no extra dependency is needed.
*/
import crypto from "node:crypto"
import fs from "node:fs"
import path from "node:path"
import zlib from "node:zlib"
import nextEnv from "@next/env"
// npm runs this before Next reads the .env files, so read them here: an
// external draw.io set in .env.local must skip the download too
nextEnv.loadEnvConfig(
process.cwd(),
process.env.npm_lifecycle_event === "predev",
)
// Pinned because the UI relies on draw.io internals; bump deliberately
const DRAWIO_VERSION = process.env.DRAWIO_VERSION || "v32.0.2"
// SHA-256 of each pinned release's draw.war, as GitHub lists it
const DRAWIO_SHA256 = {
"v32.0.2":
"3cb8abec8e9bfc7504760c9cdc9194ecf7e8de178aa2a1d668801c32ecf1a1a7",
}
const DEST = path.join(process.cwd(), "public", "drawio")
const STAMP = path.join(DEST, ".version")
const DOWNLOAD_URL = `https://github.com/jgraph/drawio/releases/download/${DRAWIO_VERSION}/draw.war`
function log(message) {
console.log(`[fetch-drawio] ${message}`)
}
function readStamp() {
try {
return fs.readFileSync(STAMP, "utf8").trim()
} catch {
return null
}
}
// Minimal zip reader: finds the central directory and inflates each entry
function* readZipEntries(buf) {
const EOCD_SIG = 0x06054b50
let eocd = -1
for (let i = buf.length - 22; i >= Math.max(0, buf.length - 65557); i--) {
if (buf.readUInt32LE(i) === EOCD_SIG) {
eocd = i
break
}
}
if (eocd < 0) throw new Error("Not a zip file (end record not found)")
const count = buf.readUInt16LE(eocd + 10)
let offset = buf.readUInt32LE(eocd + 16)
for (let n = 0; n < count; n++) {
if (buf.readUInt32LE(offset) !== 0x02014b50) {
throw new Error("Corrupt zip central directory")
}
const method = buf.readUInt16LE(offset + 10)
const compressedSize = buf.readUInt32LE(offset + 20)
const nameLength = buf.readUInt16LE(offset + 28)
const extraLength = buf.readUInt16LE(offset + 30)
const commentLength = buf.readUInt16LE(offset + 32)
const localOffset = buf.readUInt32LE(offset + 42)
const name = buf.toString("utf8", offset + 46, offset + 46 + nameLength)
offset += 46 + nameLength + extraLength + commentLength
const localNameLength = buf.readUInt16LE(localOffset + 26)
const localExtraLength = buf.readUInt16LE(localOffset + 28)
const start = localOffset + 30 + localNameLength + localExtraLength
const raw = buf.subarray(start, start + compressedSize)
if (name.endsWith("/")) continue
if (method !== 0 && method !== 8) {
throw new Error(`Unsupported zip compression ${method} (${name})`)
}
yield { name, data: method === 8 ? zlib.inflateRawSync(raw) : raw }
}
}
async function main() {
if (process.env.NEXT_PUBLIC_DRAWIO_BASE_URL) {
log("NEXT_PUBLIC_DRAWIO_BASE_URL is set, skipping the bundled copy")
return
}
if (process.env.SKIP_DRAWIO_DOWNLOAD === "1") {
log("SKIP_DRAWIO_DOWNLOAD=1, skipping")
return
}
if (readStamp() === DRAWIO_VERSION) return
log(`Downloading draw.io ${DRAWIO_VERSION} ...`)
let buf
try {
const res = await fetch(DOWNLOAD_URL)
if (!res.ok) throw new Error(`HTTP ${res.status} for ${DOWNLOAD_URL}`)
buf = Buffer.from(await res.arrayBuffer())
// The copy runs on the app's own origin: it must be the real release
const expected = DRAWIO_SHA256[DRAWIO_VERSION]
const actual = crypto.createHash("sha256").update(buf).digest("hex")
if (expected && actual !== expected) {
throw new Error(`draw.war checksum mismatch (got ${actual})`)
}
if (!expected) {
console.warn(
`[fetch-drawio] Warning: no pinned checksum for ${DRAWIO_VERSION}, ` +
`so the download is not verified (SHA-256 ${actual}). ` +
"Add it to DRAWIO_SHA256 after checking the release.",
)
}
} catch (error) {
if (fs.existsSync(path.join(DEST, "index.html"))) {
log(`Download failed (${error.message}); keeping the existing copy`)
return
}
console.error(
`[fetch-drawio] Download failed: ${error.message}\n` +
"The canvas needs draw.io. Check the network, or set " +
"NEXT_PUBLIC_DRAWIO_BASE_URL to an external draw.io.",
)
process.exit(1)
}
// Unpack next to the target, then swap it in
const tmp = `${DEST}.tmp-${process.pid}`
fs.rmSync(tmp, { recursive: true, force: true })
let files = 0
for (const { name, data } of readZipEntries(buf)) {
if (name.startsWith("WEB-INF/") || name.startsWith("META-INF/")) {
continue
}
const target = path.join(tmp, name)
if (!target.startsWith(tmp + path.sep)) {
throw new Error(`Unsafe path in archive: ${name}`)
}
fs.mkdirSync(path.dirname(target), { recursive: true })
fs.writeFileSync(target, data)
files++
}
fs.writeFileSync(path.join(tmp, ".version"), `${DRAWIO_VERSION}\n`)
fs.rmSync(DEST, { recursive: true, force: true })
fs.renameSync(tmp, DEST)
log(`Installed ${files} files into public/drawio`)
}
main().catch((error) => {
console.error(`[fetch-drawio] ${error.stack || error}`)
process.exit(1)
})
-54
View File
@@ -1,54 +0,0 @@
import { create } from "zustand"
export interface SelectedCell {
id: string
label: string
isEdge: boolean
}
/** Rectangle relative to the draw.io iframe's top-left corner */
export interface CanvasRect {
x: number
y: number
width: number
height: number
}
export interface CanvasPage {
id: string
name: string
}
interface CanvasState {
/** The page can call the draw.io editor directly (same-origin iframe) */
hasEditor: boolean
canUndo: boolean
canRedo: boolean
/** Zoom in percent */
zoom: number
pages: CanvasPage[]
currentPageId: string | null
selection: SelectedCell[]
selectionRect: CanvasRect | null
/** Freehand drawing is on */
isFreehand: boolean
/** The format (style) panel is open */
isFormatOpen: boolean
isShapesOpen: boolean
set: (partial: Partial<Omit<CanvasState, "set">>) => void
}
export const useCanvasStore = create<CanvasState>((set) => ({
hasEditor: false,
canUndo: false,
canRedo: false,
zoom: 100,
pages: [],
currentPageId: null,
selection: [],
selectionRect: null,
isFreehand: false,
isFormatOpen: false,
isShapesOpen: false,
set: (partial) => set(partial),
}))
-159
View File
@@ -1,159 +0,0 @@
import { create } from "zustand"
import { STORAGE_KEYS } from "@/lib/storage"
export type ThemePreference = "light" | "dark" | "system"
export type SendShortcut = "enter" | "ctrl-enter"
export const PANEL_MIN_WIDTH = 320
export const PANEL_MAX_WIDTH = 560
export const PANEL_DEFAULT_WIDTH = 384
interface SettingsState {
hydrated: boolean
theme: ThemePreference
/** Resolved from `theme` and the system setting */
isDark: boolean
sendShortcut: SendShortcut
minimalStyle: boolean
vlmValidationEnabled: boolean
customSystemMessage: string
maxOutputTokens: string
panelWidth: number
hydrate: () => void
setTheme: (theme: ThemePreference) => void
setSendShortcut: (value: SendShortcut) => void
setMinimalStyle: (value: boolean) => void
setVlmValidationEnabled: (value: boolean) => void
setCustomSystemMessage: (value: string) => void
setMaxOutputTokens: (value: string) => void
setPanelWidth: (value: number) => void
}
function read(key: string): string | null {
try {
return localStorage.getItem(key)
} catch {
return null
}
}
function write(key: string, value: string | null) {
try {
if (value === null) localStorage.removeItem(key)
else localStorage.setItem(key, value)
} catch {
// Storage can be unavailable (private mode, quota)
}
}
function systemPrefersDark(): boolean {
return (
typeof window !== "undefined" &&
window.matchMedia("(prefers-color-scheme: dark)").matches
)
}
function applyDarkClass(isDark: boolean) {
document.documentElement.classList.toggle("dark", isDark)
}
export function clampPanelWidth(width: number): number {
return Math.min(PANEL_MAX_WIDTH, Math.max(PANEL_MIN_WIDTH, width))
}
export const useSettingsStore = create<SettingsState>((set, get) => ({
hydrated: false,
theme: "system",
isDark: false,
sendShortcut: "ctrl-enter",
minimalStyle: false,
vlmValidationEnabled: false,
customSystemMessage: "",
maxOutputTokens: "",
panelWidth: PANEL_DEFAULT_WIDTH,
hydrate: () => {
if (get().hydrated) return
// Stored as "true"/"false"; missing means follow the system
const storedDark = read(STORAGE_KEYS.darkMode)
const theme: ThemePreference =
storedDark === "true"
? "dark"
: storedDark === "false"
? "light"
: "system"
const isDark =
theme === "system" ? systemPrefersDark() : theme === "dark"
const width = Number(read(STORAGE_KEYS.panelWidth))
set({
hydrated: true,
theme,
isDark,
sendShortcut:
read(STORAGE_KEYS.sendShortcut) === "enter"
? "enter"
: "ctrl-enter",
minimalStyle: read(STORAGE_KEYS.minimalStyle) === "true",
vlmValidationEnabled:
read(STORAGE_KEYS.vlmValidationEnabled) === "true",
customSystemMessage: read(STORAGE_KEYS.customSystemMessage) ?? "",
maxOutputTokens: read(STORAGE_KEYS.maxOutputTokens) ?? "",
panelWidth: width ? clampPanelWidth(width) : PANEL_DEFAULT_WIDTH,
})
applyDarkClass(isDark)
// Follow system changes while the preference is "system"
window
.matchMedia("(prefers-color-scheme: dark)")
.addEventListener("change", (event) => {
if (get().theme !== "system") return
set({ isDark: event.matches })
applyDarkClass(event.matches)
})
},
setTheme: (theme) => {
write(
STORAGE_KEYS.darkMode,
theme === "system" ? null : String(theme === "dark"),
)
const isDark =
theme === "system" ? systemPrefersDark() : theme === "dark"
set({ theme, isDark })
applyDarkClass(isDark)
},
setSendShortcut: (value) => {
write(STORAGE_KEYS.sendShortcut, value)
set({ sendShortcut: value })
},
setMinimalStyle: (value) => {
write(STORAGE_KEYS.minimalStyle, String(value))
set({ minimalStyle: value })
},
setVlmValidationEnabled: (value) => {
write(STORAGE_KEYS.vlmValidationEnabled, String(value))
set({ vlmValidationEnabled: value })
},
setCustomSystemMessage: (value) => {
write(STORAGE_KEYS.customSystemMessage, value)
set({ customSystemMessage: value })
},
setMaxOutputTokens: (value) => {
const digitsOnly = value.replace(/\D/g, "")
write(STORAGE_KEYS.maxOutputTokens, digitsOnly)
set({ maxOutputTokens: digitsOnly })
},
setPanelWidth: (value) => {
const width = clampPanelWidth(Math.round(value))
write(STORAGE_KEYS.panelWidth, String(width))
set({ panelWidth: width })
},
}))
/**
* Inline script for <head>: applies the dark class before the first paint so
* dark mode does not flash white while the page loads.
*/
export const THEME_INIT_SCRIPT = `(function(){try{var v=localStorage.getItem(${JSON.stringify(
STORAGE_KEYS.darkMode,
)});var d=v==="true"||(v===null&&window.matchMedia("(prefers-color-scheme: dark)").matches);if(d)document.documentElement.classList.add("dark")}catch(e){}})()`
-67
View File
@@ -1,67 +0,0 @@
import { create } from "zustand"
export type SettingsTab = "models" | "appearance" | "advanced" | "about"
export type MobileView = "canvas" | "chat"
interface UiState {
/** Desktop: the floating chat panel is open */
panelOpen: boolean
/** Phones: which half of the app is visible */
mobileView: MobileView
settingsOpen: boolean
settingsTab: SettingsTab
/** Version shown in the compare dialog, or null when closed */
compareVersionId: string | null
sessionsOpen: boolean
saveDialogOpen: boolean
/** Bumped to ask the composer to take focus */
focusComposerToken: number
/** Bumped when templates are saved, so template lists reload */
templatesVersion: number
setPanelOpen: (open: boolean) => void
togglePanel: () => void
setMobileView: (view: MobileView) => void
openSettings: (tab?: SettingsTab) => void
setSettingsOpen: (open: boolean) => void
setSettingsTab: (tab: SettingsTab) => void
openCompare: (versionId: string) => void
closeCompare: () => void
setSessionsOpen: (open: boolean) => void
setSaveDialogOpen: (open: boolean) => void
focusComposer: () => void
bumpTemplates: () => void
}
export const useUiStore = create<UiState>((set) => ({
panelOpen: true,
mobileView: "chat",
settingsOpen: false,
settingsTab: "models",
compareVersionId: null,
sessionsOpen: false,
saveDialogOpen: false,
focusComposerToken: 0,
templatesVersion: 0,
setPanelOpen: (open) => set({ panelOpen: open }),
togglePanel: () => set((state) => ({ panelOpen: !state.panelOpen })),
setMobileView: (view) => set({ mobileView: view }),
openSettings: (tab) =>
set((state) => ({
settingsOpen: true,
settingsTab: tab ?? state.settingsTab,
})),
setSettingsOpen: (open) => set({ settingsOpen: open }),
setSettingsTab: (tab) => set({ settingsTab: tab }),
openCompare: (versionId) => set({ compareVersionId: versionId }),
closeCompare: () => set({ compareVersionId: null }),
setSessionsOpen: (open) => set({ sessionsOpen: open }),
setSaveDialogOpen: (open) => set({ saveDialogOpen: open }),
focusComposer: () =>
set((state) => ({
panelOpen: true,
mobileView: "chat",
focusComposerToken: state.focusComposerToken + 1,
})),
bumpTemplates: () =>
set((state) => ({ templatesVersion: state.templatesVersion + 1 })),
}))
-155
View File
@@ -1,155 +0,0 @@
import { nanoid } from "nanoid"
import { create } from "zustand"
import type { ChangeSummary } from "@/lib/diagram-diff"
import { EMPTY_SUMMARY } from "@/lib/diagram-diff"
/** One diagram state produced by an AI change, shown as a card in the chat */
export interface DiagramVersion {
id: string
/** Full multi-page document after the change */
xml: string
/** Diagram before the change; kept only on the latest version */
beforeXml?: string
/** SVG data URL thumbnail; "" when none could be made */
svg?: string
createdAt: number
/** Index of the user message that started this turn */
turnIndex: number
toolCallId?: string
summary: ChangeSummary
/** Drawn on an empty canvas (the card shows totals, not changes) */
fromScratch?: boolean
/** Saved with the session: this version is what the canvas shows */
onCanvas?: boolean
/** Saved with the session: this version's change was undone */
undone?: boolean
}
// The two flags live on the versions so they are saved with the session
function withFlags(
versions: DiagramVersion[],
onCanvasId: string | null,
undoneId: string | null,
): DiagramVersion[] {
return versions.map(({ onCanvas, undone, ...rest }) => ({
...rest,
...(rest.id === onCanvasId && { onCanvas: true }),
...(rest.id === undoneId && { undone: true }),
}))
}
// Versions are stored with the session; keep the list bounded
export const MAX_VERSIONS = 20
interface VersionsState {
versions: DiagramVersion[]
/** The change on the latest version was undone with its card's button */
undoneVersionId: string | null
/** The version the canvas shows (null after an undo or hand edits) */
onCanvasVersionId: string | null
setVersions: (versions: DiagramVersion[]) => void
addVersion: (version: Omit<DiagramVersion, "id" | "createdAt">) => string
updateVersion: (id: string, patch: Partial<DiagramVersion>) => void
/** Drop versions from turns that are being re-run (edit or regenerate) */
removeFromTurn: (turnIndex: number) => void
/** The change of this version was undone; null: no undone change */
setUndone: (id: string | null) => void
/** This version is on the canvas again (restored or redone) */
setOnCanvas: (id: string) => void
/** The canvas was changed by hand (undo included): no version is on it */
clearCanvasFlags: () => void
clear: () => void
}
export const useVersionsStore = create<VersionsState>((set) => ({
versions: [],
undoneVersionId: null,
onCanvasVersionId: null,
setVersions: (versions) => {
const flagged = versions.some((v) => v.onCanvas || v.undone)
set({
// A missing thumbnail could not be made: no "rendering" forever
versions: versions.map((v) => ({ ...v, svg: v.svg ?? "" })),
undoneVersionId: versions.find((v) => v.undone)?.id ?? null,
// Sessions saved before the flags existed: the latest version
onCanvasVersionId: flagged
? (versions.find((v) => v.onCanvas)?.id ?? null)
: (versions.at(-1)?.id ?? null),
})
},
addVersion: (version) => {
const id = nanoid(10)
set((state) => {
// Only the newest version keeps its "before" XML
const older = state.versions.map(({ beforeXml, ...rest }) => rest)
const next = [
...older,
{ ...version, id, createdAt: Date.now() },
].slice(-MAX_VERSIONS)
return {
versions: withFlags(next, id, null),
undoneVersionId: null,
onCanvasVersionId: id,
}
})
return id
},
updateVersion: (id, patch) =>
set((state) => ({
versions: state.versions.map((v) =>
v.id === id ? { ...v, ...patch } : v,
),
})),
removeFromTurn: (turnIndex) =>
set((state) => {
const versions = state.versions.filter(
(v) => v.turnIndex < turnIndex,
)
// The canvas goes back to the snapshot before that turn, which
// can hold hand edits made after the latest kept version
return {
versions: withFlags(versions, null, null),
undoneVersionId: null,
onCanvasVersionId: null,
}
}),
setUndone: (id) =>
set((state) => ({
versions: withFlags(state.versions, null, id),
undoneVersionId: id,
onCanvasVersionId: null,
})),
setOnCanvas: (id) =>
set((state) => ({
versions: withFlags(state.versions, id, null),
undoneVersionId: null,
onCanvasVersionId: id,
})),
clearCanvasFlags: () =>
set((state) =>
state.onCanvasVersionId === null && state.undoneVersionId === null
? state
: {
versions: withFlags(state.versions, null, null),
undoneVersionId: null,
onCanvasVersionId: null,
},
),
clear: () =>
set({ versions: [], undoneVersionId: null, onCanvasVersionId: null }),
}))
/** Convert the old `diagramHistory` list ({svg, xml}) saved by earlier versions */
export function versionsFromLegacyHistory(
history: { svg: string; xml: string }[] | undefined,
): DiagramVersion[] {
if (!history?.length) return []
return history.slice(-MAX_VERSIONS).map((entry, index) => ({
id: `legacy-${index}`,
xml: entry.xml,
svg: entry.svg,
createdAt: 0,
turnIndex: -1,
summary: { ...EMPTY_SUMMARY },
}))
}
+3 -32
View File
@@ -32,10 +32,9 @@ test("a rejected key shows a hint, the provider's words and a settings button",
// First match: the Next.js dev overlay at the end also lists the error
await expect(page.getByText("Authentication Fails").first()).toBeVisible()
await page.getByRole("button", { name: "Open model settings" }).click()
// Settings opens on its models tab
const dialog = page.getByRole("dialog", { name: "Settings" })
await expect(dialog).toBeVisible()
await expect(dialog.getByText("Providers", { exact: true })).toBeVisible()
await expect(
page.getByRole("dialog", { name: "AI Model Configuration" }),
).toBeVisible()
})
test("an error before the stream shows its text, not raw JSON", async ({
@@ -76,31 +75,3 @@ test("a provider rate limit is not shown as this site's quota", async ({
// The site's own tokens-per-minute toast
await expect(page.getByText("Rate limit reached")).toHaveCount(0)
})
test("a refused server key shows only the quota hint and a settings button", async ({
page,
}) => {
// What the chat route streams when the server's key gets a 403, e.g.
// after a daily spend cap blocked it
const errorText = JSON.stringify({
type: "provider",
code: "server_key_forbidden",
message: "",
})
await chatWith(page, {
status: 200,
contentType: "text/event-stream",
body: `data: {"type":"start"}\n\ndata: ${JSON.stringify({ type: "error", errorText })}\n\ndata: [DONE]\n\n`,
})
await expect(
page.getByText("Today's free quota is used up", { exact: false }),
).toBeVisible({ timeout: 15000 })
await expect(page.getByText("The provider returned an error")).toHaveCount(
0,
)
await page.getByRole("button", { name: "Open model settings" }).click()
// Settings opens on its models tab
const dialog = page.getByRole("dialog", { name: "Settings" })
await expect(dialog).toBeVisible()
await expect(dialog.getByText("Providers", { exact: true })).toBeVisible()
})
+11 -8
View File
@@ -71,35 +71,38 @@ test.describe("Crossing the mobile breakpoint", () => {
})
test("keeps the chat and its streaming answer", async ({ page }) => {
const chat = page.locator('[data-panel-id="chat-panel"]')
await sendMessage(page, "Tell me a story")
await expect(page.getByText("Once upon")).toBeVisible({
timeout: 10000,
})
// Phones show the canvas or the chat, one at a time
await page.setViewportSize({ width: 600, height: 900 })
await page.getByRole("button", { name: "Chat", exact: true }).click()
await expect(page.getByText("Tell me a story")).toBeVisible()
// Half the height on mobile
await expect
.poll(async () => (await chat.boundingBox())?.height ?? 0)
.toBeCloseTo(450, -1)
await page.setViewportSize({ width: 1280, height: 800 })
// A third of the width on desktop
await expect
.poll(async () => (await chat.boundingBox())?.width ?? 0)
.toBeCloseTo(1280 / 3, -1)
await expect(page.getByText("Once upon a time.")).toBeVisible({
timeout: 10000,
})
await expect(page.getByText("Tell me a story")).toBeVisible()
})
test("reaches a chat hidden on desktop", async ({ page }) => {
// The start screen has no chat panel yet
await page.getByTestId("draw-yourself").click()
await page.getByRole("button", { name: "Hide chat panel" }).click()
test("opens a chat collapsed on desktop", async ({ page }) => {
await page.locator("button:has(svg.lucide-panel-right-close)").click()
await expect(getChatInput(page)).toBeHidden()
await page.setViewportSize({ width: 600, height: 900 })
await page.getByRole("button", { name: "Chat", exact: true }).click()
await expect(getChatInput(page)).toBeVisible()
await page.setViewportSize({ width: 1280, height: 800 })
await page.getByTestId("show-panel").click()
await expect(getChatInput(page)).toBeVisible()
})
})
+12 -210
View File
@@ -1,4 +1,3 @@
import zlib from "node:zlib"
import { expect, type Page, test } from "@playwright/test"
import { getIframe, sendMessage, waitForCompleteCount } from "./lib/fixtures"
@@ -128,9 +127,7 @@ test("display_diagram replaces the document with the fixed diagram", async ({
await sendMessage(p, "Draw two pages")
await waitForCompleteCount(p, 1)
await expect(canvas.getByText("Old A")).toBeVisible({ timeout: 15000 })
// The app's page switcher (draw.io's own page tabs are hidden)
const pageTabs = p.getByTestId("page-tabs")
await expect(pageTabs.getByText("Second", { exact: true })).toBeVisible()
await expect(canvas.getByText("Second", { exact: true })).toBeVisible()
await sendMessage(p, "Start over with three boxes")
await waitForCompleteCount(p, 2)
@@ -143,7 +140,7 @@ test("display_diagram replaces the document with the fixed diagram", async ({
}
// The old pages are gone
await expect(canvas.getByText("Old A")).toHaveCount(0)
await expect(pageTabs.getByText("Second", { exact: true })).toHaveCount(0)
await expect(canvas.getByText("Second", { exact: true })).toHaveCount(0)
})
test("an edit with a fixable cell is fixed, not rejected", async ({
@@ -224,14 +221,9 @@ test("edit_diagram applies all operations or none", async ({ page: p }) => {
await expect(canvas.getByText("Alpha", { exact: true })).toHaveCount(0)
await sendMessage(p, "Change again")
// The failed call's row opens to show the error
await p
.locator('[data-testid="tool-row"][data-tool-state="output-error"]')
.first()
.getByRole("button")
.first()
.click({ timeout: 15000 })
await expect(p.getByText(/No changes were made/).first()).toBeAttached()
await expect(p.getByText(/No changes were made/).first()).toBeAttached({
timeout: 15000,
})
await p.waitForTimeout(1000)
await expect(canvas.getByText("Gamma", { exact: true })).toBeVisible()
await expect(canvas.getByText("Broken", { exact: true })).toHaveCount(0)
@@ -617,9 +609,9 @@ test("a broken edit's preview is undone after a shape library call", async ({
await waitForCompleteCount(p, 1)
await sendMessage(p, "Add another box")
// The preview shows Gamma only for a moment; afterwards it must be gone
await expect(
p.locator('[data-tool-name="get_shape_library"]').first(),
).toBeVisible({ timeout: 15000 })
await expect(p.getByText("Get Shape Library").first()).toBeVisible({
timeout: 15000,
})
await p.waitForTimeout(2000)
await expect(canvas.getByText("Gamma", { exact: true })).toHaveCount(0)
await expect(canvas.getByText("Alpha", { exact: true })).toBeVisible()
@@ -648,12 +640,10 @@ test("a drawing rejected by the checks undoes its preview", async ({
await expect(canvas.getByText("Beta", { exact: true })).toBeVisible({
timeout: 15000,
})
// The tool row shows the rejection
await expect(
p
.locator('[data-testid="tool-row"][data-tool-state="output-error"]')
.first(),
).toBeVisible({ timeout: 15000 })
// The tool card shows the rejection
await expect(p.locator('text="Error"').first()).toBeVisible({
timeout: 15000,
})
await p.waitForTimeout(1000)
await expect(canvas.getByText("Beta", { exact: true })).toHaveCount(0)
await expect(canvas.getByText("Alpha", { exact: true })).toBeVisible()
@@ -745,191 +735,3 @@ test("stopping during the screenshot check lets the next message go at once", as
.find((part: any) => part.type === "tool-display_diagram")
expect(draw?.state).toBe("output-available")
})
test("the request after a failed screenshot check keeps the model and diagram", async ({
page: p,
}) => {
// After a failed check the SDK sends the next request itself; it must
// carry the turn's model headers and diagram
await p.addInitScript(() => {
localStorage.setItem("next-ai-draw-io-vlm-validation-enabled", "true")
})
const requests: { body: any; headers: Record<string, string> }[] = []
await p.route("**/api/chat", async (route) => {
requests.push({
body: route.request().postDataJSON(),
headers: route.request().headers(),
})
await route.fulfill({
status: 200,
contentType: "text/event-stream",
body: drawReply(`d${requests.length}`, cell("a", "Alpha", 40)),
})
})
let checks = 0
await p.route("**/api/validate-diagram", async (route) => {
checks++
await new Promise((r) => setTimeout(r, 500))
await route.fulfill({
status: 200,
contentType: "text/plain",
body: JSON.stringify(
checks === 1
? {
valid: false,
issues: [
{
type: "overlap",
severity: "critical",
description: "Boxes overlap",
},
],
suggestions: ["Move them apart"],
}
: { valid: true, issues: [], suggestions: [] },
),
})
})
await p.goto("/", { waitUntil: "networkidle" })
await getIframe(p).waitFor({ state: "visible", timeout: 30000 })
await sendMessage(p, "Draw a box")
await expect.poll(() => requests.length, { timeout: 20000 }).toBe(2)
const [first, retry] = requests
expect(retry.body.sessionId).toBe(first.body.sessionId)
expect(typeof retry.body.xml).toBe("string")
for (const name of Object.keys(first.headers)) {
if (name.startsWith("x-")) {
expect(retry.headers[name], name).toBe(first.headers[name])
}
}
})
test("a cut off drawing whose continuation fails undoes its preview", async ({
page: p,
}) => {
// The last cell is cut off: the model has to continue with append_diagram
const cut = toolCallEvents("d2", "display_diagram", {
xml: `${cell("b", "Beta", 40)}<mxCell id="c" value="Gam`,
})
const canvas = await chunkedReplies(p, [
[drawReply("d1", cell("a", "Alpha", 40))],
[
sse([{ type: "start" }, cut.start, ...cut.deltas]),
1500,
`${sse([cut.done, { type: "finish" }])}data: [DONE]\n\n`,
],
[
`${sse([{ type: "start" }, { type: "error", errorText: "Upstream connection lost" }])}data: [DONE]\n\n`,
],
])
await sendMessage(p, "Draw a box")
await waitForCompleteCount(p, 1)
await sendMessage(p, "Draw a bigger one")
await expect(canvas.getByText("Beta", { exact: true })).toBeVisible({
timeout: 15000,
})
await expect(p.getByText("Upstream connection lost").first()).toBeVisible({
timeout: 15000,
})
await expect(canvas.getByText("Beta", { exact: true })).toHaveCount(0)
await expect(canvas.getByText("Alpha", { exact: true })).toBeVisible()
})
test("retrying after an error keeps what the failed turn drew", async ({
page: p,
}) => {
const draw = toolCallEvents("d1", "display_diagram", {
xml: cell("a", "Alpha", 40),
})
const canvas = await chunkedReplies(p, [
[
sse([{ type: "start" }, draw.start, ...draw.deltas, draw.done]),
`${sse([{ type: "error", errorText: "Model is overloaded" }])}data: [DONE]\n\n`,
],
[textReply("Nothing to draw this time.")],
])
await sendMessage(p, "Draw a box")
await expect(p.getByText("Model is overloaded").first()).toBeVisible({
timeout: 15000,
})
await p.locator('[data-testid="retry-button"]').click()
await expect(p.getByText("Nothing to draw this time.")).toBeVisible({
timeout: 15000,
})
// Its card went with the answer; the strip keeps the version
const thumb = p.locator('[data-testid="version-thumb"]')
await expect(thumb).toHaveCount(1)
await thumb.click()
await p.locator('[data-testid="compare-restore"]').click()
await expect(canvas.getByText("Alpha", { exact: true })).toBeVisible()
})
test("the request after opening a compressed file has the cells", async ({
page: p,
}) => {
const requests: any[] = []
await p.route("**/api/chat", async (route) => {
requests.push(route.request().postDataJSON())
await route.fulfill({
status: 200,
contentType: "text/event-stream",
body: textReply("Looks fine."),
})
})
await p.goto("/", { waitUntil: "networkidle" })
await getIframe(p).waitFor({ state: "visible", timeout: 30000 })
// draw.io's compressed page: raw deflate of the URI-encoded model
const model = `<mxGraphModel><root><mxCell id="0"/><mxCell id="1" parent="0"/>${cell("a", "Packed", 40)}</root></mxGraphModel>`
const packed = zlib
.deflateRawSync(Buffer.from(encodeURIComponent(model)))
.toString("base64")
await p
.locator('input[type="file"][accept^=".drawio"]')
.first()
.setInputFiles({
name: "packed.drawio",
mimeType: "application/xml",
buffer: Buffer.from(
`<mxfile><diagram name="P" id="p">${packed}</diagram></mxfile>`,
),
})
await expect(
p.frameLocator("iframe").getByText("Packed", { exact: true }),
).toBeVisible()
await sendMessage(p, "What is this?")
await expect(p.getByText("Looks fine.")).toBeVisible()
// Regenerate sends the snapshot taken before the message
await p.getByText("Looks fine.").hover()
await p.getByRole("button", { name: "Regenerate response" }).click()
await expect.poll(() => requests.length).toBe(2)
expect(requests[1].xml).toContain('value="Packed"')
})
test("a rejected drawing stops saying it tries again once retries end", async ({
page: p,
}) => {
// Rejected by the checks every time: the automatic retries run out
let calls = 0
await p.route("**/api/chat", async (route) => {
calls++
const call = toolCallEvents(`r${calls}`, "display_diagram", {
xml: `<UserObject id="1" label="Bad" link="https://example.com"><mxCell vertex="1" parent="1"><mxGeometry x="0" y="0" width="80" height="40" as="geometry"/></mxCell></UserObject>`,
})
await route.fulfill({
status: 200,
contentType: "text/event-stream",
body: `${sse([{ type: "start" }, call.start, ...call.deltas, call.done, { type: "finish" }])}data: [DONE]\n\n`,
})
})
await p.goto("/", { waitUntil: "networkidle" })
await getIframe(p).waitFor({ state: "visible", timeout: 30000 })
await sendMessage(p, "Draw a box")
await expect.poll(() => calls, { timeout: 20000 }).toBe(4)
await expect(p.locator('[data-testid="new-chat-button"]')).toBeEnabled({
timeout: 15000,
})
await p.waitForTimeout(500)
expect(calls).toBe(4)
await expect(p.getByText("trying again")).toHaveCount(0)
await expect(p.getByText("This attempt failed").first()).toBeVisible()
})
+3 -1
View File
@@ -35,8 +35,10 @@ test.describe("Diagram Generation", () => {
test("generates and displays a diagram", async ({ page }) => {
await sendMessage(page, "Draw a cat")
await expect(page.locator('text="Generate Diagram"')).toBeVisible({
timeout: 15000,
})
await waitForComplete(page)
await expect(page.locator('[data-testid="version-card"]')).toBeVisible()
})
test("chat input clears after sending", async ({ page }) => {
+1 -6
View File
@@ -169,14 +169,9 @@ test.describe("Error Handling", () => {
await getIframe(page).waitFor({ state: "visible", timeout: 30000 })
await sendMessage(page, "Draw something")
// The failed call's row opens to show the error
const failed = page
.locator('[data-testid="tool-row"][data-tool-state="output-error"]')
.first()
await failed.getByRole("button").first().click({ timeout: 15000 })
await expect(
page.getByText("Invalid input for tool display_diagram").first(),
).toBeVisible()
).toBeVisible({ timeout: 15000 })
await expect(page.locator('text="Truncated"')).toHaveCount(0)
})
})

Some files were not shown because too many files have changed in this diff Show More