mirror of
https://github.com/DayuanJiang/next-ai-draw-io.git
synced 2026-10-07 02:07:47 +08:00
Compare commits
6
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
366d9ee4ff | ||
|
|
18a20aa1ca | ||
|
|
802f0ada9f | ||
|
|
257a4bca06 | ||
|
|
83f190e1cd | ||
|
|
8639779a23 |
@@ -34,15 +34,7 @@ jobs:
|
||||
node-version: 24
|
||||
cache: "npm"
|
||||
|
||||
- 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
|
||||
|
||||
# npm run build downloads draw.io first (prebuild)
|
||||
- name: Install dependencies
|
||||
run: npm install
|
||||
|
||||
@@ -66,16 +58,7 @@ jobs:
|
||||
node-version: 24
|
||||
cache: "npm"
|
||||
|
||||
- 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
|
||||
|
||||
# npm run build downloads draw.io first (prebuild)
|
||||
- name: Install dependencies
|
||||
run: npm install
|
||||
|
||||
|
||||
+3
-2
@@ -23,8 +23,9 @@ COPY . .
|
||||
# Disable Next.js telemetry during build
|
||||
ENV NEXT_TELEMETRY_DISABLED=1
|
||||
|
||||
# Build-time argument for self-hosted draw.io URL
|
||||
ARG NEXT_PUBLIC_DRAWIO_BASE_URL=https://embed.diagrams.net
|
||||
# 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=""
|
||||
ENV NEXT_PUBLIC_DRAWIO_BASE_URL=${NEXT_PUBLIC_DRAWIO_BASE_URL}
|
||||
|
||||
# Build-time argument to show About link and Notice icon
|
||||
|
||||
@@ -107,7 +107,8 @@ 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.)
|
||||
- **Diagram History**: Comprehensive version control that tracks all changes, allowing you to view and restore previous versions of your diagrams before the AI editing.
|
||||
- **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.
|
||||
- **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
|
||||
@@ -263,7 +264,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
|
||||
- **react-drawio**: For diagram representation and manipulation
|
||||
- **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
|
||||
|
||||
Diagrams are represented as XML that can be rendered in draw.io. The AI processes your commands and generates or modifies this XML accordingly.
|
||||
|
||||
|
||||
@@ -149,8 +149,10 @@ export default function AboutCN() {
|
||||
:上传现有图表或图像,让AI自动复制和增强
|
||||
</li>
|
||||
<li>
|
||||
<strong>图表历史记录</strong>
|
||||
:全面的版本控制,跟踪所有更改,允许您查看和恢复AI编辑前的图表版本
|
||||
<strong>版本与撤销</strong>
|
||||
:AI
|
||||
的每次修改都会成为聊天里的一个版本,可以对比、恢复或撤销;在画布上按
|
||||
Ctrl+Z 也能撤回
|
||||
</li>
|
||||
<li>
|
||||
<strong>交互式聊天界面</strong>
|
||||
@@ -294,7 +296,8 @@ export default function AboutCN() {
|
||||
):用于流式AI响应和多提供商支持
|
||||
</li>
|
||||
<li>
|
||||
<strong>react-drawio</strong>:用于图表表示和操作
|
||||
<strong>draw.io</strong>
|
||||
:内置在应用里的编辑器,应用可以直接控制它
|
||||
</li>
|
||||
</ul>
|
||||
<p className="text-gray-700 mt-4">
|
||||
|
||||
@@ -138,8 +138,10 @@ export default function AboutJA() {
|
||||
:既存のダイアグラムや画像をアップロードし、AIが自動的に複製・強化
|
||||
</li>
|
||||
<li>
|
||||
<strong>ダイアグラム履歴</strong>
|
||||
:すべての変更を追跡する包括的なバージョン管理。AI編集前のダイアグラムの以前のバージョンを表示・復元可能
|
||||
<strong>バージョンと取り消し</strong>
|
||||
:AI
|
||||
の変更はそれぞれチャット内のバージョンになり、比較・復元・取り消しができます。キャンバスで
|
||||
Ctrl+Z を押しても元に戻せます
|
||||
</li>
|
||||
<li>
|
||||
<strong>
|
||||
@@ -290,8 +292,8 @@ export default function AboutJA() {
|
||||
):ストリーミングAIレスポンスとマルチプロバイダーサポート
|
||||
</li>
|
||||
<li>
|
||||
<strong>react-drawio</strong>
|
||||
:ダイアグラムの表現と操作
|
||||
<strong>draw.io</strong>
|
||||
:アプリに同梱されたエディター。アプリから直接操作できます
|
||||
</li>
|
||||
</ul>
|
||||
<p className="text-gray-700 mt-4">
|
||||
|
||||
@@ -150,10 +150,10 @@ export default function About() {
|
||||
replicate and enhance them automatically
|
||||
</li>
|
||||
<li>
|
||||
<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
|
||||
<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
|
||||
</li>
|
||||
<li>
|
||||
<strong>Interactive Chat Interface</strong>:
|
||||
@@ -304,8 +304,8 @@ export default function About() {
|
||||
and multi-provider support
|
||||
</li>
|
||||
<li>
|
||||
<strong>react-drawio</strong>: For diagram
|
||||
representation and manipulation
|
||||
<strong>draw.io</strong>: The editor, bundled with
|
||||
the app so it can be driven directly
|
||||
</li>
|
||||
</ul>
|
||||
<p className="text-gray-700 mt-4">
|
||||
|
||||
+15
-4
@@ -1,19 +1,25 @@
|
||||
import { GoogleAnalytics } from "@next/third-parties/google"
|
||||
import type { Metadata, Viewport } from "next"
|
||||
import { JetBrains_Mono, Plus_Jakarta_Sans } from "next/font/google"
|
||||
import { Instrument_Sans, JetBrains_Mono } 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 plusJakarta = Plus_Jakarta_Sans({
|
||||
variable: "--font-sans",
|
||||
const instrumentSans = Instrument_Sans({
|
||||
variable: "--font-latin",
|
||||
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({
|
||||
@@ -165,17 +171,22 @@ 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={`${plusJakarta.variable} ${jetbrainsMono.variable} antialiased`}
|
||||
className={`${instrumentSans.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} />
|
||||
|
||||
+6
-251
@@ -1,256 +1,11 @@
|
||||
"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"
|
||||
import { Suspense } from "react"
|
||||
import { Workspace } from "@/components/workspace/workspace"
|
||||
|
||||
export default function Home() {
|
||||
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)
|
||||
}, [])
|
||||
|
||||
// The workspace reads ?session= from the URL, which needs a Suspense boundary
|
||||
return (
|
||||
<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>
|
||||
<Suspense fallback={<div className="fixed inset-0 bg-canvas" />}>
|
||||
<Workspace />
|
||||
</Suspense>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -25,6 +25,7 @@ import { findCachedResponse } from "@/lib/cached-responses"
|
||||
import {
|
||||
dropInvalidToolCalls,
|
||||
fixToolInputJson,
|
||||
formatSelectionContext,
|
||||
replaceHistoricalToolInputs,
|
||||
validateFileParts,
|
||||
} from "@/lib/chat-helpers"
|
||||
@@ -513,6 +514,7 @@ ${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):
|
||||
@@ -527,7 +529,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.`
|
||||
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}` : ""}`
|
||||
|
||||
const systemMessages = isSingleSystemProvider
|
||||
? [
|
||||
|
||||
+199
-293
@@ -8,21 +8,8 @@
|
||||
@theme inline {
|
||||
--color-background: var(--background);
|
||||
--color-foreground: var(--foreground);
|
||||
--font-sans: var(--font-sans);
|
||||
--font-sans: var(--font-latin), var(--font-cjk), system-ui, sans-serif;
|
||||
--font-mono: var(--font-mono);
|
||||
--color-sidebar-ring: var(--sidebar-ring);
|
||||
--color-sidebar-border: var(--sidebar-border);
|
||||
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
|
||||
--color-sidebar-accent: var(--sidebar-accent);
|
||||
--color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
|
||||
--color-sidebar-primary: var(--sidebar-primary);
|
||||
--color-sidebar-foreground: var(--sidebar-foreground);
|
||||
--color-sidebar: var(--sidebar);
|
||||
--color-chart-5: var(--chart-5);
|
||||
--color-chart-4: var(--chart-4);
|
||||
--color-chart-3: var(--chart-3);
|
||||
--color-chart-2: var(--chart-2);
|
||||
--color-chart-1: var(--chart-1);
|
||||
--color-ring: var(--ring);
|
||||
--color-input: var(--input);
|
||||
--color-border: var(--border);
|
||||
@@ -43,161 +30,16 @@
|
||||
--radius-md: calc(var(--radius) - 2px);
|
||||
--radius-lg: var(--radius);
|
||||
--radius-xl: calc(var(--radius) + 4px);
|
||||
}
|
||||
|
||||
: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 {
|
||||
/* 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);
|
||||
--color-surface-0: var(--surface-0);
|
||||
--color-surface-1: var(--surface-1);
|
||||
--color-surface-2: var(--surface-2);
|
||||
--color-surface-elevated: var(--surface-elevated);
|
||||
--color-border-subtle: var(--border-subtle);
|
||||
--color-border-default: var(--border-default);
|
||||
--color-interactive-hover: var(--interactive-hover);
|
||||
@@ -206,12 +48,150 @@
|
||||
--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;
|
||||
|
||||
--surface-0: #ffffff;
|
||||
--surface-1: #f7f8fa;
|
||||
--surface-2: #f1f3f5;
|
||||
--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-normal: 200ms;
|
||||
--ease-out: cubic-bezier(0.16, 1, 0.3, 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;
|
||||
|
||||
--surface-0: #16304f;
|
||||
--surface-1: #132b48;
|
||||
--surface-2: #1d3b5e;
|
||||
--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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -221,46 +201,66 @@
|
||||
width: 100% !important;
|
||||
}
|
||||
|
||||
/* Custom scrollbar */
|
||||
@layer utilities {
|
||||
.scrollbar-thin {
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: oklch(0.85 0.01 260) transparent;
|
||||
scrollbar-color: color-mix(in oklab, var(--foreground) 18%, transparent)
|
||||
transparent;
|
||||
}
|
||||
|
||||
.scrollbar-thin::-webkit-scrollbar {
|
||||
width: 6px;
|
||||
}
|
||||
|
||||
.scrollbar-thin::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.scrollbar-thin::-webkit-scrollbar-thumb {
|
||||
background-color: oklch(0.85 0.01 260);
|
||||
background-color: color-mix(
|
||||
in oklab,
|
||||
var(--foreground) 18%,
|
||||
transparent
|
||||
);
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
.scrollbar-thin::-webkit-scrollbar-thumb:hover {
|
||||
background-color: oklch(0.75 0.01 260);
|
||||
background-color: color-mix(
|
||||
in oklab,
|
||||
var(--foreground) 30%,
|
||||
transparent
|
||||
);
|
||||
}
|
||||
|
||||
/* Dark mode scrollbar */
|
||||
.dark .scrollbar-thin {
|
||||
scrollbar-color: oklch(0.35 0.015 260) transparent;
|
||||
/* Diagram pictures always render as on paper, also in dark mode
|
||||
(draw.io SVGs use light-dark() colors) */
|
||||
.sheet-light {
|
||||
color-scheme: light;
|
||||
}
|
||||
|
||||
.dark .scrollbar-thin::-webkit-scrollbar-thumb {
|
||||
background-color: oklch(0.35 0.015 260);
|
||||
.shadow-float {
|
||||
box-shadow: var(--shadow-float);
|
||||
}
|
||||
|
||||
.dark .scrollbar-thin::-webkit-scrollbar-thumb:hover {
|
||||
background-color: oklch(0.45 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-dialog {
|
||||
box-shadow: var(--shadow-pop);
|
||||
}
|
||||
}
|
||||
|
||||
/* Smooth page transitions */
|
||||
@keyframes fadeIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(6px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes messageIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(8px);
|
||||
@@ -271,121 +271,18 @@
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes slideInRight {
|
||||
/* The first prompt moves from the middle of the canvas into the panel */
|
||||
@keyframes panelIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateX(16px);
|
||||
transform: translateX(12px) scale(0.985);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateX(0);
|
||||
transform: translateX(0) scale(1);
|
||||
}
|
||||
}
|
||||
|
||||
.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(12px) scale(0.98);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0) scale(1);
|
||||
}
|
||||
}
|
||||
|
||||
.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.96);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translate(-50%, -50%) scale(1);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes dialog-out {
|
||||
from {
|
||||
opacity: 1;
|
||||
transform: translate(-50%, -50%) scale(1);
|
||||
}
|
||||
to {
|
||||
opacity: 0;
|
||||
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);
|
||||
@@ -400,14 +297,23 @@
|
||||
}
|
||||
}
|
||||
|
||||
.animate-fade-in {
|
||||
animation: fadeIn 0.25s 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-check-pop {
|
||||
animation: check-pop 0.25s var(--ease-spring) forwards;
|
||||
}
|
||||
|
||||
/* Reduced motion support */
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.animate-dialog-in,
|
||||
.animate-dialog-out,
|
||||
.animate-fade-in,
|
||||
.animate-message-in,
|
||||
.animate-panel-in,
|
||||
.animate-check-pop {
|
||||
animation: none;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,200 @@
|
||||
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 { STORAGE_KEYS } from "@/lib/storage"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { useCanvasStore } from "@/stores/canvas-store"
|
||||
import { useSettingsStore } from "@/stores/settings-store"
|
||||
|
||||
/**
|
||||
* 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,
|
||||
onOpenFile,
|
||||
}: {
|
||||
className?: string
|
||||
style?: React.CSSProperties
|
||||
compact?: boolean
|
||||
/** Opens the file picker for a .drawio file */
|
||||
onOpenFile: () => void
|
||||
/** 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 when the chat panel starts sliding in or out
|
||||
const slideStartWidthRef = 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 [size, setSize] = useState({ width: 0, height: 0 })
|
||||
|
||||
useEffect(() => {
|
||||
// The page is about to switch to the language picked last time
|
||||
// (useSavedLocale): load draw.io once, in that language
|
||||
const saved = localStorage.getItem(STORAGE_KEYS.locale)
|
||||
if (saved && saved !== lang && i18n.locales.includes(saved as Locale)) {
|
||||
return
|
||||
}
|
||||
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
|
||||
// React passes no transitionstart
|
||||
const onSlideStart = (event: TransitionEvent) => {
|
||||
if (event.target !== stage || event.propertyName !== "right") return
|
||||
slideStartWidthRef.current = stage.clientWidth
|
||||
}
|
||||
stage.addEventListener("transitionstart", onSlideStart)
|
||||
const observer = new ResizeObserver(([entry]) => {
|
||||
setSize({
|
||||
width: entry.contentRect.width,
|
||||
height: entry.contentRect.height,
|
||||
})
|
||||
refitIfRecent()
|
||||
})
|
||||
observer.observe(stage)
|
||||
return () => {
|
||||
observer.disconnect()
|
||||
stage.removeEventListener("transitionstart", onSlideStart)
|
||||
}
|
||||
}, [])
|
||||
|
||||
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(() => {
|
||||
if (refitIfRecent(4000)) return
|
||||
keepCenter(slideStartWidthRef.current, 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={onOpenFile}
|
||||
/>
|
||||
</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}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,405 @@
|
||||
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,
|
||||
focusCanvas,
|
||||
hasAction,
|
||||
keepFocusInCanvas,
|
||||
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"
|
||||
|
||||
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: `${modKey}F` },
|
||||
{ action: "layers", label: t.layers, shortcut: `${modKey}⇧L` },
|
||||
{ action: "outline", label: t.outline, shortcut: `${modKey}⇧O` },
|
||||
{ action: "grid", label: t.toggleGrid, shortcut: `${modKey}⇧G` },
|
||||
{ action: "pageView", label: t.togglePageView },
|
||||
{ action: "insertImage", label: t.insertImage },
|
||||
{ action: "insertLink", label: t.insertLink },
|
||||
{ action: "selectAll", label: t.selectAll, shortcut: `${modKey}A` },
|
||||
{ action: "editDiagram", label: t.editDiagram },
|
||||
{ action: "print", label: t.print, shortcut: `${modKey}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={`${modKey}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()
|
||||
focusCanvas()
|
||||
}}
|
||||
>
|
||||
<MousePointer2 />
|
||||
</IconButton>
|
||||
)}
|
||||
{available.has("toggleShapes") && (
|
||||
<IconButton
|
||||
label={t.shapes}
|
||||
shortcut={`${modKey}⇧K`}
|
||||
active={isShapesOpen}
|
||||
onClick={() => {
|
||||
if (isFreehand) stopFreehand()
|
||||
toggleShapesPanel()
|
||||
focusCanvas()
|
||||
}}
|
||||
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={`${modKey}−`}
|
||||
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"
|
||||
onCloseAutoFocus={keepFocusInCanvas}
|
||||
>
|
||||
{available.has("fitWindow") && (
|
||||
<MenuButton
|
||||
shortcut={`${modKey}⇧H`}
|
||||
onClick={() => {
|
||||
runAction("fitWindow")
|
||||
setZoomOpen(false)
|
||||
}}
|
||||
>
|
||||
{t.fitWindow}
|
||||
</MenuButton>
|
||||
)}
|
||||
{available.has("resetView") && (
|
||||
<MenuButton
|
||||
shortcut="Home"
|
||||
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={`${modKey}+`}
|
||||
onClick={tool("zoomIn")}
|
||||
>
|
||||
<Plus />
|
||||
</IconButton>
|
||||
)}
|
||||
{!compact && (
|
||||
<IconButton
|
||||
label={t.fitWindow}
|
||||
onClick={() => {
|
||||
fitDiagram()
|
||||
focusCanvas()
|
||||
}}
|
||||
>
|
||||
<Maximize2 />
|
||||
</IconButton>
|
||||
)}
|
||||
<ToolbarDivider />
|
||||
{available.has("format") && (
|
||||
<IconButton
|
||||
label={t.style}
|
||||
shortcut={`${modKey}⇧P`}
|
||||
active={isFormatOpen}
|
||||
onClick={tool("format")}
|
||||
>
|
||||
<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"
|
||||
onCloseAutoFocus={keepFocusInCanvas}
|
||||
>
|
||||
<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>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,233 @@
|
||||
"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"
|
||||
/>
|
||||
)
|
||||
},
|
||||
)
|
||||
@@ -0,0 +1,117 @@
|
||||
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,
|
||||
keepFocusInCanvas,
|
||||
removePage,
|
||||
renamePage,
|
||||
selectPage,
|
||||
} from "@/lib/drawio/editor-bridge"
|
||||
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="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"
|
||||
onCloseAutoFocus={keepFocusInCanvas}
|
||||
>
|
||||
{[
|
||||
{
|
||||
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>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
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>
|
||||
)
|
||||
}
|
||||
@@ -1,227 +0,0 @@
|
||||
"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>
|
||||
)
|
||||
}
|
||||
@@ -1,669 +0,0 @@
|
||||
"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
@@ -1,360 +0,0 @@
|
||||
"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>
|
||||
)
|
||||
}
|
||||
@@ -333,14 +333,6 @@ export function TemplatePanel({
|
||||
if (!loading && templates.length === 0) {
|
||||
return (
|
||||
<div className="py-6 px-2 animate-fade-in">
|
||||
<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" />
|
||||
|
||||
@@ -1,267 +0,0 @@
|
||||
"use client"
|
||||
|
||||
import { Check, ChevronDown, ChevronUp, Copy, Cpu } from "lucide-react"
|
||||
import type { Dispatch, SetStateAction } from "react"
|
||||
import { CodeBlock } from "@/components/code-block"
|
||||
import { isMxCellXmlComplete } from "@/lib/utils"
|
||||
import type { DiagramOperation, ToolPartLike } from "./types"
|
||||
|
||||
interface ToolCallCardProps {
|
||||
part: ToolPartLike
|
||||
expandedTools: Record<string, boolean>
|
||||
setExpandedTools: Dispatch<SetStateAction<Record<string, boolean>>>
|
||||
onCopy: (callId: string, text: string, isToolCall: boolean) => void
|
||||
copiedToolCallId: string | null
|
||||
copyFailedToolCallId: string | null
|
||||
dict: {
|
||||
tools: { complete: string }
|
||||
chat: { copied: string; failedToCopy: string; copyResponse: string }
|
||||
}
|
||||
}
|
||||
|
||||
function OperationsDisplay({ operations }: { operations: DiagramOperation[] }) {
|
||||
// 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>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
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
@@ -0,0 +1,192 @@
|
||||
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 { usePanelWidth } from "@/hooks/use-panel-width"
|
||||
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 = usePanelWidth()
|
||||
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 = usePanelWidth()
|
||||
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)}
|
||||
>
|
||||
<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>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,152 @@
|
||||
import { ChevronLeft, ChevronRight, RotateCcw } from "lucide-react"
|
||||
import { useEffect, useMemo, useState } from "react"
|
||||
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"
|
||||
import { VersionThumb } from "./tool-activity"
|
||||
|
||||
function Sheet({
|
||||
label,
|
||||
svg,
|
||||
}: {
|
||||
label: string
|
||||
svg: string | null | undefined
|
||||
}) {
|
||||
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">
|
||||
<VersionThumb svg={svg} className="max-h-full max-w-full" />
|
||||
</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, taken again once an answer that
|
||||
// was running is done ("" when none could be made)
|
||||
useEffect(() => {
|
||||
if (!versionId) return
|
||||
let cancelled = false
|
||||
setCurrentSvg(null)
|
||||
getVersionSvg().then((svg) => {
|
||||
if (!cancelled) setCurrentSvg(svg ?? "")
|
||||
})
|
||||
return () => {
|
||||
cancelled = true
|
||||
}
|
||||
}, [versionId, engine.isBusy])
|
||||
|
||||
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}
|
||||
/>
|
||||
<Sheet label={t.currentCanvas} svg={currentSvg} />
|
||||
</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>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,525 @@
|
||||
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 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. While
|
||||
// the chat is being left, what is typed could not be sent.
|
||||
const isDisabled = (isBusy && !error) || engine.isLeaving
|
||||
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)}
|
||||
>
|
||||
<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)}
|
||||
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
|
||||
setInput={(value) => {
|
||||
setInput(value)
|
||||
setTemplatesOpen(false)
|
||||
}}
|
||||
currentInput={input}
|
||||
onSendTemplate={(template) => {
|
||||
setTemplatesOpen(false)
|
||||
engine.sendTemplate(template.prompt)
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</form>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,284 @@
|
||||
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}
|
||||
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>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,634 @@
|
||||
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 { useCopy } 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>
|
||||
)
|
||||
}
|
||||
|
||||
/** Copies a message's text, with a short "copied" state */
|
||||
function CopyButton({ text }: { text: string }) {
|
||||
const dict = useDictionary()
|
||||
const { copied, copy } = useCopy(() =>
|
||||
toast.error(dict.chat.failedToCopyDetail),
|
||||
)
|
||||
return (
|
||||
<ActionButton
|
||||
label={copied ? dict.chat.copied : dict.chat.copyResponse}
|
||||
onClick={() => copy(text)}
|
||||
>
|
||||
{copied ? <Check /> : <Copy />}
|
||||
</ActionButton>
|
||||
)
|
||||
}
|
||||
|
||||
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 }: { text: string }) {
|
||||
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 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 [saveTemplate, setSaveTemplate] = useState(false)
|
||||
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>
|
||||
)}
|
||||
<CopyButton text={text} />
|
||||
<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} />}
|
||||
</div>
|
||||
{saveTemplate && (
|
||||
<TemplateCreateDialog
|
||||
open
|
||||
onOpenChange={(open) => !open && setSaveTemplate(false)}
|
||||
onSuccess={() => setSaveTemplate(false)}
|
||||
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 [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} />
|
||||
})}
|
||||
|
||||
{!(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 && <CopyButton text={text} />}
|
||||
{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>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
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()
|
||||
}
|
||||
@@ -0,0 +1,294 @@
|
||||
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 { 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, setOpen] = useState(false)
|
||||
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
|
||||
// (Safari: keyCode 229)
|
||||
if (
|
||||
e.nativeEvent.isComposing ||
|
||||
e.keyCode === 229
|
||||
) {
|
||||
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.nav.newChat}
|
||||
</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
|
||||
const choose = () => {
|
||||
if (locked) return
|
||||
setOpen(false)
|
||||
if (!isCurrent) {
|
||||
engine.selectSession(session.id)
|
||||
}
|
||||
}
|
||||
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={choose}
|
||||
onKeyDown={(e) => {
|
||||
if (e.target !== e.currentTarget)
|
||||
return
|
||||
if (
|
||||
e.key === "Enter" ||
|
||||
e.key === " "
|
||||
) {
|
||||
e.preventDefault()
|
||||
choose()
|
||||
}
|
||||
}}
|
||||
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>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,487 @@
|
||||
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 { useCopy } 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)
|
||||
}
|
||||
|
||||
/** Picture of a version (SVG data URL), or what stands in for it */
|
||||
export function VersionThumb({
|
||||
svg,
|
||||
dimmed,
|
||||
className,
|
||||
}: {
|
||||
/** undefined or null: still being made; "": none could be made */
|
||||
svg: string | null | undefined
|
||||
dimmed?: boolean
|
||||
className?: string
|
||||
}) {
|
||||
const t = useDictionary().versions
|
||||
if (svg) {
|
||||
return (
|
||||
// biome-ignore lint/performance/noImgElement: data URL thumbnail
|
||||
<img
|
||||
src={svg}
|
||||
alt=""
|
||||
className={cn(
|
||||
"object-contain transition-opacity",
|
||||
dimmed && "opacity-40",
|
||||
className,
|
||||
)}
|
||||
/>
|
||||
)
|
||||
}
|
||||
if (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, copy } = useCopy()
|
||||
|
||||
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}
|
||||
>
|
||||
<VersionThumb
|
||||
svg={version.svg}
|
||||
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
|
||||
svg={version.svg}
|
||||
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={() => copy(toolText(part))}
|
||||
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}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,223 @@
|
||||
"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,
|
||||
])
|
||||
}
|
||||
@@ -0,0 +1,169 @@
|
||||
import type { RefObject } from "react"
|
||||
import { useEffect, useRef } from "react"
|
||||
import { toast } from "sonner"
|
||||
import { useDiagram } from "@/contexts/diagram-context"
|
||||
import { useDictionary } from "@/hooks/use-dictionary"
|
||||
import { diffDiagrams, isSameDocument } 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 {
|
||||
/** Index of the user message whose turn is running */
|
||||
currentTurnRef: RefObject<number>
|
||||
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({
|
||||
currentTurnRef,
|
||||
isBusy,
|
||||
isTurnActive,
|
||||
getChatGeneration,
|
||||
}: UseVersionsParams) {
|
||||
const dict = useDictionary()
|
||||
// chartXML changes with hand edits, undo and loads; the ref is the
|
||||
// document on the canvas now
|
||||
const {
|
||||
loadDiagram,
|
||||
getVersionSvg,
|
||||
setCommitHandler,
|
||||
chartXML,
|
||||
chartXMLRef,
|
||||
} = useDiagram()
|
||||
// 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, fromScratch } = diffDiagrams(
|
||||
beforeXml,
|
||||
afterXml,
|
||||
)
|
||||
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)
|
||||
}, [])
|
||||
|
||||
// A version is on the canvas (or its change undone) while the canvas
|
||||
// holds that XML. Hand edits, Ctrl+Z or a new page end it, with an
|
||||
// external draw.io too. Checked once an answer is done: its previews
|
||||
// change the canvas on the way.
|
||||
useEffect(() => {
|
||||
if (isBusy || isTurnActive() || !chartXML) return
|
||||
const store = useVersionsStore.getState()
|
||||
const id = store.onCanvasVersionId ?? store.undoneVersionId
|
||||
const version = store.versions.find((v) => v.id === id)
|
||||
if (!version) return
|
||||
const shown =
|
||||
id === store.onCanvasVersionId
|
||||
? version.xml
|
||||
: version.beforeXml || BLANK_MXFILE
|
||||
if (isSameDocument(shown, chartXML)) return
|
||||
store.clearCanvasFlags()
|
||||
// Its Undo would also take the changes made since away
|
||||
toast.dismiss("version-restored")
|
||||
}, [chartXML, isBusy])
|
||||
|
||||
const apply = (xml: string) => {
|
||||
restoringRef.current = true
|
||||
try {
|
||||
loadDiagram(xml, true, "commit")
|
||||
} finally {
|
||||
restoringRef.current = false
|
||||
}
|
||||
clearHighlights()
|
||||
}
|
||||
|
||||
const restoreVersion = (versionId: string) => {
|
||||
if (isTurnActive()) 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 (isTurnActive()) 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 }
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
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, from
|
||||
// a turn that was retried, or drawn by a call that then failed its
|
||||
// screenshot check (a call still running gets its card when done)
|
||||
const hasCardless = versions.some(
|
||||
(v) =>
|
||||
!v.toolCallId ||
|
||||
!engine.messages.some((m) =>
|
||||
m.parts?.some((p) => {
|
||||
const part = p as { toolCallId?: string; state?: string }
|
||||
return (
|
||||
part.toolCallId === v.toolCallId &&
|
||||
part.state !== "output-error"
|
||||
)
|
||||
}),
|
||||
),
|
||||
)
|
||||
// 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>
|
||||
)
|
||||
}
|
||||
@@ -1,114 +0,0 @@
|
||||
"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>
|
||||
)
|
||||
}
|
||||
@@ -41,13 +41,6 @@ import {
|
||||
CommandItem,
|
||||
CommandList,
|
||||
} from "@/components/ui/command"
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import { Label } from "@/components/ui/label"
|
||||
import {
|
||||
@@ -80,7 +73,6 @@ import { cn } from "@/lib/utils"
|
||||
|
||||
interface ModelConfigDialogProps {
|
||||
open: boolean
|
||||
onOpenChange: (open: boolean) => void
|
||||
modelConfig: UseModelConfigReturn
|
||||
}
|
||||
|
||||
@@ -103,7 +95,7 @@ function ConfigSection({
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<Icon className="h-4 w-4 text-muted-foreground" />
|
||||
<span className="text-xs font-medium text-muted-foreground uppercase tracking-wider">
|
||||
<span className="text-xs font-medium text-muted-foreground">
|
||||
{title}
|
||||
</span>
|
||||
</div>
|
||||
@@ -123,9 +115,20 @@ function ConfigCard({ children }: { children: React.ReactNode }) {
|
||||
)
|
||||
}
|
||||
|
||||
/** Providers as last saved (the model config is saved on every change) */
|
||||
function savedProviders(): ProviderConfig[] {
|
||||
try {
|
||||
const stored = localStorage.getItem(STORAGE_KEYS.modelConfigs)
|
||||
return stored
|
||||
? (JSON.parse(stored) as { providers: ProviderConfig[] }).providers
|
||||
: []
|
||||
} catch {
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
export function ModelConfigDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
modelConfig,
|
||||
}: ModelConfigDialogProps) {
|
||||
const dict = useDictionary()
|
||||
@@ -192,10 +195,22 @@ export function ModelConfigDialog({
|
||||
// Number of the latest Test click: only that test may reset the busy
|
||||
// state when its credentials changed meanwhile
|
||||
const validationRunRef = useRef(0)
|
||||
// Set when this view goes away (settings closed or on another tab): its
|
||||
// config no longer follows edits, the saved one does
|
||||
const closedRef = useRef(false)
|
||||
useEffect(() => {
|
||||
closedRef.current = false
|
||||
return () => {
|
||||
closedRef.current = true
|
||||
}
|
||||
}, [])
|
||||
// A model list or test result belongs to the credentials it was asked
|
||||
// with; they can change meanwhile, here or in another tab
|
||||
const credentialsOf = (providerId: string) => {
|
||||
const p = configRef.current.providers.find((x) => x.id === providerId)
|
||||
const providers = closedRef.current
|
||||
? savedProviders()
|
||||
: configRef.current.providers
|
||||
const p = providers.find((x) => x.id === providerId)
|
||||
return JSON.stringify([
|
||||
p?.provider,
|
||||
p?.apiKey,
|
||||
@@ -266,7 +281,9 @@ export function ModelConfigDialog({
|
||||
const hints = dict.errors.llm as Record<string, string>
|
||||
setFetchModelsError(
|
||||
[hints[data.code], data.error].filter(Boolean).join(" ") ||
|
||||
`Request failed (${response.status})`,
|
||||
formatMessage(dict.modelConfig.requestFailed, {
|
||||
status: response.status,
|
||||
}),
|
||||
)
|
||||
}
|
||||
} catch {
|
||||
@@ -485,14 +502,17 @@ export function ModelConfigDialog({
|
||||
.filter(Boolean)
|
||||
.join(" ") ||
|
||||
(response.ok
|
||||
? "Validation failed"
|
||||
: `Request failed (${response.status})`),
|
||||
? dict.modelConfig.validationError
|
||||
: formatMessage(
|
||||
dict.modelConfig.requestFailed,
|
||||
{ status: response.status },
|
||||
)),
|
||||
validationWarning: undefined,
|
||||
}
|
||||
} catch {
|
||||
update = {
|
||||
validated: false,
|
||||
validationError: "Network error",
|
||||
validationError: dict.errors.networkError,
|
||||
validationWarning: undefined,
|
||||
}
|
||||
}
|
||||
@@ -565,7 +585,11 @@ export function ModelConfigDialog({
|
||||
}, 1500)
|
||||
} else {
|
||||
setValidationStatus("error")
|
||||
setValidationError(`${errorCount} model(s) failed validation`)
|
||||
setValidationError(
|
||||
formatMessage(dict.modelConfig.validationFailedCount, {
|
||||
count: errorCount,
|
||||
}),
|
||||
)
|
||||
}
|
||||
}, [
|
||||
selectedProvider,
|
||||
@@ -682,27 +706,13 @@ export function ModelConfigDialog({
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="sm:max-w-4xl h-[80vh] max-h-[800px] overflow-hidden flex flex-col gap-0 p-0">
|
||||
{/* Header */}
|
||||
<DialogHeader className="px-6 pt-6 pb-4 shrink-0">
|
||||
<DialogTitle className="flex items-center gap-3">
|
||||
<div className="p-2 rounded-xl bg-surface-2">
|
||||
<Server className="h-5 w-5 text-primary" />
|
||||
</div>
|
||||
{dict.modelConfig?.title || "AI Model Configuration"}
|
||||
</DialogTitle>
|
||||
<DialogDescription className="mt-1">
|
||||
{dict.modelConfig?.description ||
|
||||
"Configure multiple AI providers and models for your workspace"}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="flex flex-1 min-h-0 overflow-hidden border-t border-border-subtle">
|
||||
<>
|
||||
<div className="flex min-h-0 flex-1 flex-col">
|
||||
<div className="flex flex-1 min-h-0 overflow-hidden">
|
||||
{/* Provider List (Left Sidebar) */}
|
||||
<div className="w-60 shrink-0 flex flex-col bg-surface-1/50 border-r border-border-subtle">
|
||||
<div className="px-4 py-3">
|
||||
<span className="text-xs font-medium text-muted-foreground uppercase tracking-wider">
|
||||
<span className="text-xs font-medium text-muted-foreground">
|
||||
{dict.modelConfig.providers}
|
||||
</span>
|
||||
</div>
|
||||
@@ -1413,7 +1423,7 @@ export function ModelConfigDialog({
|
||||
model.id,
|
||||
)
|
||||
}
|
||||
aria-label={`Delete ${model.modelId}`}
|
||||
aria-label={`${dict.common.delete} ${model.modelId}`}
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</Button>
|
||||
@@ -1474,10 +1484,14 @@ export function ModelConfigDialog({
|
||||
<Server className="h-8 w-8 text-muted-foreground" />
|
||||
</div>
|
||||
<h3 className="font-semibold text-lg tracking-tight mb-1">
|
||||
{dict.modelConfig.configureProviders}
|
||||
{config.providers.length === 0
|
||||
? dict.modelConfig.noProvidersTitle
|
||||
: dict.modelConfig.configureProviders}
|
||||
</h3>
|
||||
<p className="text-sm text-muted-foreground max-w-xs">
|
||||
{dict.modelConfig.selectProviderHint}
|
||||
{config.providers.length === 0
|
||||
? dict.modelConfig.noProvidersHint
|
||||
: dict.modelConfig.selectProviderHint}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
@@ -1508,7 +1522,7 @@ export function ModelConfigDialog({
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</div>
|
||||
|
||||
{/* Delete Confirmation Dialog */}
|
||||
<AlertDialog
|
||||
@@ -1588,6 +1602,6 @@ export function ModelConfigDialog({
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</Dialog>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -26,6 +26,7 @@ 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,
|
||||
@@ -66,6 +67,26 @@ 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)
|
||||
const short = name.replace(
|
||||
/^(?:(?:global|us|eu|apac|jp|au|ca|us-gov)\.)?[a-z][a-z0-9-]*\.(?=[a-z])/i,
|
||||
"",
|
||||
)
|
||||
// Ids like "deepseek.r1-v1:0" name the vendor only in the prefix: drop
|
||||
// just the region
|
||||
if (!short || /^[a-z]\d/i.test(short)) {
|
||||
return name.replace(/^(?:global|us|eu|apac|jp|au|ca|us-gov)\./i, "")
|
||||
}
|
||||
return short
|
||||
}
|
||||
|
||||
export function ModelSelector({
|
||||
models,
|
||||
selectedModelId,
|
||||
@@ -158,7 +179,7 @@ export function ModelSelector({
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<div ref={wrapperRef} className="min-w-0 max-w-48">
|
||||
<div ref={wrapperRef} className="min-w-0 max-w-44">
|
||||
<ModelSelectorRoot open={open} onOpenChange={setOpen}>
|
||||
<ModelSelectorTrigger asChild>
|
||||
<ButtonWithTooltip
|
||||
@@ -167,18 +188,26 @@ 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 transition-[padding,background-color] duration-150 ease-in-out hover:bg-accent",
|
||||
"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",
|
||||
!showLabel && "px-1.5 justify-center",
|
||||
)}
|
||||
// accessibility: expose label to screen readers
|
||||
aria-label={tooltipContent}
|
||||
data-testid="model-selector"
|
||||
>
|
||||
<Bot className="h-4 w-4 flex-shrink-0 text-muted-foreground" />
|
||||
{selectedModel ? (
|
||||
<ProviderLogo
|
||||
provider={selectedModel.provider}
|
||||
className="size-3.5 flex-shrink-0 opacity-80"
|
||||
/>
|
||||
) : (
|
||||
<Bot className="h-4 w-4 flex-shrink-0" />
|
||||
)}
|
||||
{/* show/hide visible label based on measured width */}
|
||||
{showLabel ? (
|
||||
<span className="min-w-0 truncate text-xs">
|
||||
{selectedModel
|
||||
? selectedModel.modelId
|
||||
? shortModelName(selectedModel.modelId)
|
||||
: dict.modelConfig.default}
|
||||
</span>
|
||||
) : (
|
||||
|
||||
@@ -1,50 +0,0 @@
|
||||
"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>
|
||||
)
|
||||
}
|
||||
+54
-41
@@ -1,6 +1,6 @@
|
||||
"use client"
|
||||
|
||||
import { useEffect, useState } from "react"
|
||||
import { useEffect, useRef, useState } from "react"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import {
|
||||
Dialog,
|
||||
@@ -10,15 +10,8 @@ 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"
|
||||
|
||||
@@ -39,10 +32,12 @@ export function SaveDialog({
|
||||
const [filename, setFilename] = useState(defaultFilename)
|
||||
const [format, setFormat] = useState<ExportFormat>("drawio")
|
||||
|
||||
// The default name each time the dialog opens; a chat title that comes
|
||||
// while it is open (the chat is saved meanwhile) keeps what was typed
|
||||
const wasOpenRef = useRef(false)
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
setFilename(defaultFilename)
|
||||
}
|
||||
if (open && !wasOpenRef.current) setFilename(defaultFilename)
|
||||
wasOpenRef.current = open
|
||||
}, [open, defaultFilename])
|
||||
|
||||
const handleSave = () => {
|
||||
@@ -52,6 +47,8 @@ export function SaveDialog({
|
||||
}
|
||||
|
||||
const handleKeyDown = (e: React.KeyboardEvent) => {
|
||||
// Enter that picks an IME candidate (Safari: keyCode 229)
|
||||
if (e.nativeEvent.isComposing || e.keyCode === 229) return
|
||||
if (e.key === "Enter") {
|
||||
e.preventDefault()
|
||||
handleSave()
|
||||
@@ -62,21 +59,25 @@ 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",
|
||||
},
|
||||
]
|
||||
@@ -85,7 +86,7 @@ export function SaveDialog({
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="sm:max-w-md">
|
||||
<DialogContent className="sm:max-w-lg">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{dict.save.title}</DialogTitle>
|
||||
<DialogDescription>
|
||||
@@ -93,44 +94,54 @@ export function SaveDialog({
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="space-y-4">
|
||||
<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
|
||||
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>
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium">
|
||||
<label
|
||||
htmlFor="export-filename"
|
||||
className="text-sm font-medium"
|
||||
>
|
||||
{dict.save.filename}
|
||||
</label>
|
||||
<div className="flex items-stretch">
|
||||
<Input
|
||||
{/* 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"
|
||||
value={filename}
|
||||
onChange={(e) => setFilename(e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
placeholder={dict.save.filenamePlaceholder}
|
||||
autoFocus
|
||||
onFocus={(e) => e.target.select()}
|
||||
className="rounded-r-none border-r-0 focus-visible:z-10"
|
||||
className="h-full min-w-0 flex-1 bg-transparent px-3 text-sm outline-none"
|
||||
/>
|
||||
<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">
|
||||
<span className="shrink-0 pr-3 text-sm text-muted-foreground">
|
||||
{currentFormat?.extension || ".drawio"}
|
||||
</span>
|
||||
</div>
|
||||
@@ -143,7 +154,9 @@ export function SaveDialog({
|
||||
>
|
||||
{dict.common.cancel}
|
||||
</Button>
|
||||
<Button onClick={handleSave}>{dict.common.save}</Button>
|
||||
<Button onClick={handleSave}>
|
||||
{dict.workspace.export}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
+487
-651
File diff suppressed because it is too large
Load Diff
@@ -1,56 +0,0 @@
|
||||
"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 }
|
||||
@@ -0,0 +1,28 @@
|
||||
"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",
|
||||
},
|
||||
}}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
"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>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
"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 />
|
||||
}
|
||||
@@ -0,0 +1,422 @@
|
||||
"use client"
|
||||
|
||||
import { MessageSquare, PanelRightOpen, Settings2, Shapes } from "lucide-react"
|
||||
import { usePathname, useRouter } from "next/navigation"
|
||||
import { useEffect, useRef, useState } from "react"
|
||||
import { CanvasStage } 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-dialog"
|
||||
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 { usePanelWidth } from "@/hooks/use-panel-width"
|
||||
import {
|
||||
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 { useSettingsStore } from "@/stores/settings-store"
|
||||
import { useUiStore } from "@/stores/ui-store"
|
||||
import { useVersionsStore } from "@/stores/versions-store"
|
||||
|
||||
/** File types the "Open .drawio file" picker accepts */
|
||||
const DIAGRAM_FILE_ACCEPT = ".drawio,.xml,.svg,application/xml,image/svg+xml"
|
||||
|
||||
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}`)
|
||||
}, [])
|
||||
}
|
||||
|
||||
/** 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")}`
|
||||
}
|
||||
|
||||
/** App name and settings button above the start screen */
|
||||
function StartBar({
|
||||
className,
|
||||
nameClassName,
|
||||
}: {
|
||||
className: string
|
||||
nameClassName: string
|
||||
}) {
|
||||
const dict = useDictionary()
|
||||
const openSettings = useUiStore((s) => s.openSettings)
|
||||
return (
|
||||
<div className={className}>
|
||||
<BrandMark className="size-6" />
|
||||
<span
|
||||
className={cn(
|
||||
"text-[13px] font-semibold tracking-[-0.01em]",
|
||||
nameClassName,
|
||||
)}
|
||||
>
|
||||
Next AI Draw.io
|
||||
</span>
|
||||
<IconButton
|
||||
label={dict.nav.settings}
|
||||
onClick={() => openSettings()}
|
||||
data-testid="settings-button"
|
||||
>
|
||||
<Settings2 />
|
||||
</IconButton>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/** The whole editor: canvas, chat panel, dialogs */
|
||||
export function Workspace() {
|
||||
const hydrate = useSettingsStore((s) => s.hydrate)
|
||||
useEffect(() => {
|
||||
hydrate()
|
||||
}, [hydrate])
|
||||
useSavedLocale()
|
||||
|
||||
return (
|
||||
<ChatEngineProvider>
|
||||
<WorkspaceLayout />
|
||||
</ChatEngineProvider>
|
||||
)
|
||||
}
|
||||
|
||||
function WorkspaceLayout() {
|
||||
const dict = useDictionary()
|
||||
const engine = useChatEngine()
|
||||
const { chartXML, saveDiagramToFile } = useDiagram()
|
||||
const isMobile = useIsMobile()
|
||||
const panelWidth = usePanelWidth()
|
||||
const {
|
||||
panelOpen,
|
||||
togglePanel,
|
||||
setPanelOpen,
|
||||
mobileView,
|
||||
setMobileView,
|
||||
focusComposer,
|
||||
saveDialogOpen,
|
||||
setSaveDialogOpen,
|
||||
} = useUiStore()
|
||||
const heroDismissed = useUiStore((s) => s.heroDismissed)
|
||||
const setHeroDismissed = useUiStore((s) => s.setHeroDismissed)
|
||||
const fileInputRef = useRef<HTMLInputElement>(null)
|
||||
|
||||
// Start screen: nothing said and nothing drawn yet
|
||||
const showHero =
|
||||
engine.isRestored &&
|
||||
engine.messages.length === 0 &&
|
||||
!engine.isResending &&
|
||||
!isRealDiagram(chartXML) &&
|
||||
!heroDismissed
|
||||
|
||||
// An emptied conversation brings the start screen back (a new chat
|
||||
// does too, in the chat engine)
|
||||
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, setHeroDismissed])
|
||||
|
||||
// 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
|
||||
}
|
||||
// ⌘⇧B stays with the browser (Chrome's bookmarks bar)
|
||||
if (!event.shiftKey && (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 canvas edge slides with the panel as it opens or closes; a window
|
||||
// resize or a dragged panel edge moves it at once. Set in the render
|
||||
// that changes the edge, so the transition applies to that change.
|
||||
const [sliding, setSliding] = useState(false)
|
||||
const [lastPanelVisible, setLastPanelVisible] = useState(panelVisible)
|
||||
if (lastPanelVisible !== panelVisible) {
|
||||
setLastPanelVisible(panelVisible)
|
||||
setSliding(true)
|
||||
}
|
||||
useEffect(() => {
|
||||
if (!sliding) return
|
||||
const timer = setTimeout(() => setSliding(false), 400)
|
||||
return () => clearTimeout(timer)
|
||||
}, [sliding])
|
||||
// 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",
|
||||
)
|
||||
: cn(
|
||||
"inset-y-0 left-0",
|
||||
sliding &&
|
||||
"transition-[right] duration-300 ease-[var(--ease-out)]",
|
||||
)
|
||||
}
|
||||
style={isMobile ? undefined : { right: canvasRight }}
|
||||
compact={isMobile}
|
||||
hideOverlays={!isMobile && showHero}
|
||||
onOpenFile={openFile}
|
||||
/>
|
||||
{isMobile ? (
|
||||
mobileView === "chat" &&
|
||||
(showHero ? (
|
||||
<div className="absolute inset-0 overflow-y-auto bg-canvas">
|
||||
<StartBar
|
||||
className="flex items-center gap-2 px-4 pt-3"
|
||||
nameClassName="mr-auto"
|
||||
/>
|
||||
<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>
|
||||
<StartBar
|
||||
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"
|
||||
nameClassName="pr-1"
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</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) => {
|
||||
// The opened diagram hides the start screen; a file
|
||||
// that is not one leaves it
|
||||
const file = event.target.files?.[0]
|
||||
if (file) 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>
|
||||
)
|
||||
}
|
||||
+151
-83
@@ -1,11 +1,29 @@
|
||||
"use client"
|
||||
|
||||
import type React from "react"
|
||||
import { createContext, useCallback, useContext, useRef, useState } from "react"
|
||||
import type { DrawIoEmbedRef, EventExport } from "react-drawio"
|
||||
import {
|
||||
createContext,
|
||||
useCallback,
|
||||
useContext,
|
||||
useEffect,
|
||||
useRef,
|
||||
useState,
|
||||
} from "react"
|
||||
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,
|
||||
@@ -13,24 +31,51 @@ 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"
|
||||
|
||||
/** The document without where its view was scrolled to (dx, dy) */
|
||||
function withoutView(xml: string): string {
|
||||
return xml.replace(/<mxGraphModel\b[^>]*>/g, (tag) =>
|
||||
tag.replace(/\s(?:dx|dy)="[^"]*"/g, ""),
|
||||
)
|
||||
}
|
||||
|
||||
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.MutableRefObject<string>
|
||||
latestSvg: string
|
||||
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)
|
||||
loadDiagram: (
|
||||
chart: string,
|
||||
skipValidation?: boolean,
|
||||
mode?: LoadMode,
|
||||
meta?: { toolCallId?: string },
|
||||
) => string | null
|
||||
// Returns the export's tag (empty when draw.io is not there yet)
|
||||
handleExport: () => string
|
||||
handleExportWithoutHistory: () => string
|
||||
// Pending exports by tag; a history or plain export's resolver gets the
|
||||
// first page's XML
|
||||
// Pending exports by tag; a plain export's resolver gets the first
|
||||
// page's XML
|
||||
exportResolversRef: React.MutableRefObject<
|
||||
Record<string, (data: string, xml?: string) => void>
|
||||
>
|
||||
drawioRef: React.MutableRefObject<DrawIoEmbedRef | null>
|
||||
handleDiagramExport: (data: EventExport) => void
|
||||
drawioRef: React.MutableRefObject<DrawioFrameHandle | null>
|
||||
handleDiagramExport: (data: DrawioExportEvent) => void
|
||||
handleDiagramAutoSave: (data: { xml?: string }) => void
|
||||
clearDiagram: () => void
|
||||
saveDiagramToFile: (
|
||||
@@ -40,12 +85,17 @@ interface DiagramContextType {
|
||||
successMessage?: string,
|
||||
) => void
|
||||
getThumbnailSvg: () => Promise<string | null>
|
||||
getVersionSvg: () => Promise<string | null>
|
||||
captureValidationPng: () => Promise<string | null>
|
||||
isDrawioReady: boolean
|
||||
onDrawioLoad: () => void
|
||||
resetDrawioReady: () => void
|
||||
showSaveDialog: boolean
|
||||
setShowSaveDialog: (show: boolean) => 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
|
||||
}
|
||||
|
||||
const DiagramContext = createContext<DiagramContextType | undefined>(undefined)
|
||||
@@ -54,35 +104,42 @@ 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"
|
||||
type ExportTag = "thumbnail" | "validation" | "version"
|
||||
|
||||
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<DrawIoEmbedRef | null>(null)
|
||||
const mountedRef = useRef(true)
|
||||
useEffect(() => {
|
||||
mountedRef.current = true
|
||||
return () => {
|
||||
mountedRef.current = false
|
||||
}
|
||||
}, [])
|
||||
const drawioRef = useRef<DrawioFrameHandle | 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., theme/UI change)
|
||||
if (drawioRef.current && isRealDiagram(chartXMLRef.current)) {
|
||||
// Restore diagram after remount (e.g., theme/UI change), or a file
|
||||
// opened while draw.io loaded (its page settings count too)
|
||||
if (drawioRef.current && chartXMLRef.current) {
|
||||
drawioRef.current.load({ xml: chartXMLRef.current })
|
||||
}
|
||||
}
|
||||
@@ -101,20 +158,7 @@ export function DiagramProvider({ children }: { children: React.ReactNode }) {
|
||||
|
||||
const handleExport = () => {
|
||||
if (!drawioRef.current) return ""
|
||||
// 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 = () => {
|
||||
if (!drawioRef.current) return ""
|
||||
// Export without saving to history (for edit_diagram fetching current state)
|
||||
// Export of the current state (for the chat and edit_diagram)
|
||||
const tag = `fetch-${++exportSeqRef.current}`
|
||||
drawioRef.current.exportDiagram({
|
||||
format: "xmlsvg",
|
||||
@@ -128,7 +172,7 @@ export function DiagramProvider({ children }: { children: React.ReactNode }) {
|
||||
// expected occasionally.
|
||||
// (Reads refs only, so it keeps one identity)
|
||||
const requestTaggedExport = useCallback(
|
||||
(tag: ExportTag, format: "xmlsvg" | "png", timeoutMs: number) =>
|
||||
(tag: ExportTag, format: "xmlsvg" | "svg" | "png", timeoutMs: number) =>
|
||||
new Promise<string | null>((resolve) => {
|
||||
const id = `${tag}-${++exportSeqRef.current}`
|
||||
const finish = (value: string | null) => {
|
||||
@@ -162,6 +206,13 @@ export function DiagramProvider({ children }: { children: React.ReactNode }) {
|
||||
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
|
||||
@@ -176,7 +227,12 @@ export function DiagramProvider({ children }: { children: React.ReactNode }) {
|
||||
const loadDiagram = (
|
||||
chart: string,
|
||||
skipValidation?: boolean,
|
||||
mode: LoadMode = "load",
|
||||
meta?: { toolCallId?: string },
|
||||
): string | null => {
|
||||
// The editor bridge is shared: a page that is gone (another language
|
||||
// mounted a new one) must not change the new page's canvas
|
||||
if (!mountedRef.current) return null
|
||||
let xmlToLoad = chart
|
||||
|
||||
// Validate XML structure before loading (unless skipped for internal
|
||||
@@ -201,23 +257,56 @@ 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)
|
||||
|
||||
if (drawioRef.current) {
|
||||
drawioRef.current.load({
|
||||
xml: 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,
|
||||
})
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
const handleDiagramExport = (data: EventExport) => {
|
||||
// Thumbnail, validation PNG and file save exports go only to their
|
||||
// own caller
|
||||
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 tag = data.message?.message
|
||||
if (/^(thumbnail|validation|save)-/.test(tag ?? "")) {
|
||||
if (/^(thumbnail|validation|version|save)-/.test(tag ?? "")) {
|
||||
exportResolversRef.current[tag as string]?.(data.data, data.xml)
|
||||
return
|
||||
}
|
||||
@@ -229,29 +318,6 @@ 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
|
||||
@@ -261,15 +327,17 @@ export function DiagramProvider({ children }: { children: React.ReactNode }) {
|
||||
}
|
||||
}
|
||||
|
||||
// react-drawio registers this callback once per iframe mount, so it must
|
||||
// The frame 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
|
||||
// Don't overwrite a pending restore - if we have a real diagram but
|
||||
// Don't overwrite a pending restore - if we have a diagram but
|
||||
// DrawIO hasn't loaded yet, it means we're waiting to restore
|
||||
if (!hasCalledOnLoadRef.current && isRealDiagram(chartXMLRef.current)) {
|
||||
return
|
||||
}
|
||||
if (!hasCalledOnLoadRef.current && chartXMLRef.current) return
|
||||
// Only the view moved, or draw.io saved what it just loaded: nothing
|
||||
// changed, and a save would put a chat that was only opened first
|
||||
// in the list
|
||||
if (withoutView(data.xml) === withoutView(chartXMLRef.current)) return
|
||||
updateChartXML(data.xml)
|
||||
}
|
||||
|
||||
@@ -278,7 +346,6 @@ export function DiagramProvider({ children }: { children: React.ReactNode }) {
|
||||
// Skip validation for trusted internal template (loadDiagram also sets chartXML)
|
||||
loadDiagram(emptyDiagram, true)
|
||||
setLatestSvg("")
|
||||
setDiagramHistory([])
|
||||
}
|
||||
|
||||
const saveDiagramToFile = (
|
||||
@@ -361,10 +428,7 @@ export function DiagramProvider({ children }: { children: React.ReactNode }) {
|
||||
|
||||
// Show success toast after download is initiated
|
||||
if (successMessage) {
|
||||
toast.success(successMessage, {
|
||||
position: "bottom-left",
|
||||
duration: 2500,
|
||||
})
|
||||
toast.success(successMessage, { duration: 2500 })
|
||||
}
|
||||
|
||||
// Delay URL revocation to ensure download completes
|
||||
@@ -397,17 +461,20 @@ 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,
|
||||
exportResolversRef,
|
||||
drawioRef,
|
||||
handleDiagramExport,
|
||||
@@ -415,12 +482,13 @@ export function DiagramProvider({ children }: { children: React.ReactNode }) {
|
||||
clearDiagram,
|
||||
saveDiagramToFile,
|
||||
getThumbnailSvg,
|
||||
getVersionSvg,
|
||||
captureValidationPng,
|
||||
isDrawioReady,
|
||||
onDrawioLoad,
|
||||
resetDrawioReady,
|
||||
showSaveDialog,
|
||||
setShowSaveDialog,
|
||||
setCommitHandler,
|
||||
startTurn,
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
|
||||
+4
-8
@@ -1,14 +1,11 @@
|
||||
services:
|
||||
drawio:
|
||||
image: jgraph/drawio:latest
|
||||
ports: ["8080:8080"]
|
||||
next-ai-draw-io:
|
||||
build:
|
||||
context: .
|
||||
args:
|
||||
- NEXT_PUBLIC_DRAWIO_BASE_URL=http://localhost:8080
|
||||
# Uncomment below for subdirectory deployment
|
||||
# - NEXT_PUBLIC_BASE_PATH=/nextaidrawio
|
||||
# draw.io is bundled into the image (public/drawio), no separate container needed
|
||||
# Uncomment below for subdirectory deployment
|
||||
# args:
|
||||
# - NEXT_PUBLIC_BASE_PATH=/nextaidrawio
|
||||
ports: ["3000:3000"]
|
||||
env_file: .env
|
||||
volumes:
|
||||
@@ -17,4 +14,3 @@ services:
|
||||
# environment:
|
||||
# # For subdirectory deployment, uncomment and set your path:
|
||||
# NEXT_PUBLIC_BASE_PATH: /nextaidrawio
|
||||
depends_on: [drawio]
|
||||
|
||||
+4
-2
@@ -18,9 +18,11 @@
|
||||
|
||||
**问题**: 内网环境提示"找不到 embed.diagrams.net 的服务器 IP 地址"
|
||||
|
||||
**关键点**: `NEXT_PUBLIC_*` 环境变量是**构建时**变量,会被打包到 JS 代码中,**运行时设置无效**!
|
||||
**关键点**: 应用现在自带一份 draw.io,从 `/drawio` 提供,浏览器不再需要访问 `embed.diagrams.net`。如果仍然看到这个错误,说明镜像是用 `NEXT_PUBLIC_DRAWIO_BASE_URL=https://embed.diagrams.net`(旧版 Dockerfile 的默认值)构建的,去掉这个构建参数重新构建即可。详见[离线部署](./offline-deployment.md)。
|
||||
|
||||
**解决方案**: 必须在构建时通过 `args` 传入:
|
||||
`NEXT_PUBLIC_*` 环境变量是**构建时**变量,会被打包到 JS 代码中,**运行时设置无效**!
|
||||
|
||||
**如果仍想使用单独的 draw.io 服务器**,在构建时通过 `args` 传入:
|
||||
|
||||
```yaml
|
||||
# docker-compose.yml
|
||||
|
||||
@@ -93,7 +93,8 @@ https://github.com/user-attachments/assets/b2eef5f3-b335-4e71-a755-dc2e80931979
|
||||
- **基于图像的图表复制**:上传现有图表或图像,让AI自动复制和增强
|
||||
- **PDF和文本文件上传**:上传PDF文档和文本文件,提取内容并从现有文档生成图表
|
||||
- **AI推理过程显示**:查看支持模型的AI思考过程(OpenAI o1/o3、Gemini、Claude等)
|
||||
- **图表历史记录**:全面的版本控制,跟踪所有更改,允许您查看和恢复AI编辑前的图表版本
|
||||
- **版本与撤销**:AI 每改一次图,聊天里就多一张带缩略图的版本卡片,可以和画布对比、恢复,也可以撤销这次修改;在画布上按 Ctrl+Z 也能一步撤回 AI 的修改。AI 刚改过的图形会在画布上标出来
|
||||
- **针对选中图形提问**:在画布上选中图形,让 AI 只改这几个
|
||||
- **交互式聊天界面**:与AI实时对话来完善您的图表
|
||||
- **云架构图支持**:专门支持生成云架构图(AWS、GCP、Azure)
|
||||
- **动画连接器**:在图表元素之间创建动态动画连接器,实现更好的可视化效果
|
||||
@@ -243,7 +244,7 @@ npm run dev
|
||||
|
||||
- **Next.js**:用于前端框架和路由
|
||||
- **Vercel AI SDK**(`ai` + `@ai-sdk/*`):用于流式AI响应和多提供商支持
|
||||
- **react-drawio**:用于图表表示和操作
|
||||
- **draw.io**:编辑器使用打包在 `public/drawio` 里的副本(由 `npm run dev` / `npm run build` 自动下载),应用可以直接控制它
|
||||
|
||||
图表以XML格式表示,可在draw.io中渲染。AI处理您的命令并相应地生成或修改此XML。
|
||||
|
||||
|
||||
@@ -46,6 +46,8 @@ 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 等模型。
|
||||
@@ -192,7 +194,7 @@ MODELSCOPE_BASE_URL=https://your-custom-endpoint
|
||||
可选的自定义 URL:
|
||||
|
||||
```bash
|
||||
OLLAMA_BASE_URL=http://localhost:11434
|
||||
OLLAMA_BASE_URL=http://localhost:11434/api
|
||||
```
|
||||
|
||||
### Vercel AI Gateway
|
||||
|
||||
@@ -1,13 +1,25 @@
|
||||
# 离线部署
|
||||
|
||||
通过自托管 draw.io 来替代 `embed.diagrams.net`,从而离线部署 Next AI Draw.io。
|
||||
|
||||
**注意:** `NEXT_PUBLIC_DRAWIO_BASE_URL` 是一个**构建时**变量。修改它需要重新构建 Docker 镜像。
|
||||
Next AI Draw.io 自带一份 draw.io。`npm run build`(包括 Docker 构建)会把 draw.io 的发布包下载到 `public/drawio`,只下载一次,应用从 `/drawio` 提供它。使用时浏览器不会访问 `embed.diagrams.net`,所以只要构建时能联网,应用就能在离线网络或内网里使用。
|
||||
|
||||
## Docker Compose 设置
|
||||
|
||||
1. 克隆仓库并在 `.env` 文件中定义 API 密钥。
|
||||
2. 创建 `docker-compose.yml`:
|
||||
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` 镜像:
|
||||
|
||||
```yaml
|
||||
services:
|
||||
@@ -24,9 +36,7 @@ services:
|
||||
depends_on: [drawio]
|
||||
```
|
||||
|
||||
3. 运行 `docker compose up -d` 并打开 `http://localhost:3000`。
|
||||
|
||||
## 配置与重要警告
|
||||
使用外部 draw.io 时,应用改用 draw.io 自己的工具栏,以下功能不可用:标出 AI 改了哪些图形、针对选中的图形提问、应用自己的画布工具栏、用 Ctrl+Z 撤回 AI 的修改。浏览器不允许页面控制来自其他域名的编辑器。
|
||||
|
||||
**`NEXT_PUBLIC_DRAWIO_BASE_URL` 必须是用户浏览器可访问的地址。**
|
||||
|
||||
|
||||
+4
-2
@@ -18,9 +18,11 @@
|
||||
|
||||
**Problem**: Intranet environment shows "Cannot find server IP address for embed.diagrams.net"
|
||||
|
||||
**Key Point**: `NEXT_PUBLIC_*` environment variables are **build-time** variables, they get bundled into JS code. **Runtime settings don't work!**
|
||||
**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).
|
||||
|
||||
**Solution**: Must pass via `args` at build time:
|
||||
`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:
|
||||
|
||||
```yaml
|
||||
# docker-compose.yml
|
||||
|
||||
@@ -61,6 +61,8 @@ 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.
|
||||
@@ -194,7 +196,7 @@ AI_MODEL=llama3.2
|
||||
Optional custom URL:
|
||||
|
||||
```bash
|
||||
OLLAMA_BASE_URL=http://localhost:11434
|
||||
OLLAMA_BASE_URL=http://localhost:11434/api
|
||||
```
|
||||
|
||||
### ModelScope
|
||||
|
||||
@@ -1,13 +1,25 @@
|
||||
# Offline Deployment
|
||||
|
||||
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.
|
||||
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.
|
||||
|
||||
## Docker Compose Setup
|
||||
|
||||
1. Clone the repository and define API keys in `.env`.
|
||||
2. Create `docker-compose.yml`:
|
||||
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`:
|
||||
|
||||
```yaml
|
||||
services:
|
||||
@@ -24,9 +36,7 @@ services:
|
||||
depends_on: [drawio]
|
||||
```
|
||||
|
||||
3. Run `docker compose up -d` and open `http://localhost:3000`.
|
||||
|
||||
## Configuration & Critical Warning
|
||||
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.
|
||||
|
||||
**The `NEXT_PUBLIC_DRAWIO_BASE_URL` must be accessible from the user's browser.**
|
||||
|
||||
|
||||
+4
-2
@@ -18,9 +18,11 @@
|
||||
|
||||
**問題**: イントラネット環境で「embed.diagrams.netのサーバーIPアドレスが見つかりません」と表示される
|
||||
|
||||
**重要**: `NEXT_PUBLIC_*` 環境変数は**ビルド時**変数であり、JSコードにバンドルされます。**実行時の設定は無効です!**
|
||||
**重要**: アプリには draw.io のコピーが同梱され、`/drawio` から配信されるため、ブラウザは `embed.diagrams.net` にアクセスしなくなりました。それでもこのエラーが出る場合は、イメージが `NEXT_PUBLIC_DRAWIO_BASE_URL=https://embed.diagrams.net`(以前の Dockerfile の既定値)でビルドされています。このビルド引数を外して再ビルドしてください。詳しくは[オフラインデプロイ](./offline-deployment.md)を参照してください。
|
||||
|
||||
**解決策**: ビルド時に `args` で渡す必要があります:
|
||||
`NEXT_PUBLIC_*` 環境変数は**ビルド時**変数であり、JSコードにバンドルされます。**実行時の設定は無効です!**
|
||||
|
||||
**別の draw.io サーバーを使い続ける場合**は、ビルド時に `args` で渡します:
|
||||
|
||||
```yaml
|
||||
# docker-compose.yml
|
||||
|
||||
@@ -91,7 +91,8 @@ https://github.com/user-attachments/assets/b2eef5f3-b335-4e71-a755-dc2e80931979
|
||||
- **画像ベースのダイアグラム複製**:既存のダイアグラムや画像をアップロードし、AIが自動的に複製・強化
|
||||
- **PDFとテキストファイルのアップロード**:PDFドキュメントやテキストファイルをアップロードして、既存のドキュメントからコンテンツを抽出し、ダイアグラムを生成
|
||||
- **AI推論プロセス表示**:サポートされているモデル(OpenAI o1/o3、Gemini、Claudeなど)のAIの思考プロセスを表示
|
||||
- **ダイアグラム履歴**:すべての変更を追跡する包括的なバージョン管理。AI編集前のダイアグラムの以前のバージョンを表示・復元可能
|
||||
- **バージョンと取り消し**:AI が図を変更するたびに、サムネイル付きのバージョンカードがチャットに表示されます。キャンバスと比較したり、復元したり、その変更を取り消したりできます。キャンバスで Ctrl+Z を押しても AI の変更を一度に元に戻せます。AI が変更した図形はキャンバス上で強調表示されます
|
||||
- **選択した図形について依頼**:キャンバスで図形を選択し、その図形だけを AI に直してもらえます
|
||||
- **インタラクティブなチャットインターフェース**:AIとリアルタイムでコミュニケーションしてダイアグラムを改善
|
||||
- **クラウドアーキテクチャダイアグラムサポート**:クラウドアーキテクチャダイアグラムの生成を専門的にサポート(AWS、GCP、Azure)
|
||||
- **アニメーションコネクタ**:より良い可視化のためにダイアグラム要素間に動的でアニメーション化されたコネクタを作成
|
||||
@@ -242,7 +243,7 @@ AWS BedrockとOpenRouter以外のすべてのプロバイダーはカスタム
|
||||
|
||||
- **Next.js**:フロントエンドフレームワークとルーティング
|
||||
- **Vercel AI SDK**(`ai` + `@ai-sdk/*`):ストリーミングAIレスポンスとマルチプロバイダーサポート
|
||||
- **react-drawio**:ダイアグラムの表現と操作
|
||||
- **draw.io**:エディターは `public/drawio` に同梱したコピー(`npm run dev` / `npm run build` で自動ダウンロード)で動作し、アプリから直接操作できます
|
||||
|
||||
ダイアグラムはdraw.ioでレンダリングできるXMLとして表現されます。AIがコマンドを処理し、それに応じてこのXMLを生成または変更します。
|
||||
|
||||
|
||||
@@ -46,6 +46,8 @@ 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 などのモデルへのアクセスを提供します。
|
||||
@@ -179,7 +181,7 @@ AI_MODEL=llama3.2
|
||||
任意のカスタム URL:
|
||||
|
||||
```bash
|
||||
OLLAMA_BASE_URL=http://localhost:11434
|
||||
OLLAMA_BASE_URL=http://localhost:11434/api
|
||||
```
|
||||
|
||||
### ModelScope
|
||||
|
||||
@@ -1,13 +1,25 @@
|
||||
# オフラインデプロイ
|
||||
|
||||
`embed.diagrams.net` の代わりに draw.io をセルフホストすることで、Next AI Draw.io をオフライン環境にデプロイできます。
|
||||
|
||||
**注:** `NEXT_PUBLIC_DRAWIO_BASE_URL` は**ビルド時**の変数です。これを変更する場合は、Docker イメージの再ビルドが必要です。
|
||||
Next AI Draw.io には draw.io のコピーが同梱されています。`npm run build`(Docker ビルドを含む)が draw.io のリリースを一度だけ `public/drawio` にダウンロードし、アプリは `/drawio` からそれを配信します。利用中のブラウザは `embed.diagrams.net` にアクセスしないため、ビルド時にインターネットに接続できれば、オフライン環境やイントラネットでも動作します。
|
||||
|
||||
## Docker Compose のセットアップ
|
||||
|
||||
1. リポジトリをクローンし、`.env` ファイルに API キーを定義します。
|
||||
2. `docker-compose.yml` を作成します。
|
||||
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 を使うこともできます。
|
||||
|
||||
```yaml
|
||||
services:
|
||||
@@ -24,9 +36,7 @@ services:
|
||||
depends_on: [drawio]
|
||||
```
|
||||
|
||||
3. `docker compose up -d` を実行し、`http://localhost:3000` にアクセスします。
|
||||
|
||||
## 設定と重要な警告
|
||||
外部の draw.io を使うと、アプリは draw.io 自身のツールバーを使い、次の機能は使えなくなります:AI が変更した図形の強調表示、選択した図形についての依頼、アプリのキャンバスツールバー、Ctrl+Z による AI の変更の取り消し。ブラウザは、別のオリジンから配信されたエディターをページから操作することを許可しないためです。
|
||||
|
||||
**`NEXT_PUBLIC_DRAWIO_BASE_URL` は、ユーザーのブラウザからアクセスできる必要があります。**
|
||||
|
||||
|
||||
+17
-4
@@ -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)
|
||||
# OLLAMA_BASE_URL=https://ollama.com/api # Optional: Ollama Cloud; defaults to local Ollama (http://127.0.0.1:11434/api)
|
||||
# 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,8 +143,11 @@ 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)
|
||||
# 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
|
||||
# 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.
|
||||
|
||||
# Subdirectory Deployment (Optional)
|
||||
# For deploying to a subdirectory (e.g., https://example.com/nextaidrawio)
|
||||
@@ -163,12 +166,22 @@ 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 the chat examples.
|
||||
# It also turns off the MCP server advertisement in Settings > About.
|
||||
# NEXT_PUBLIC_SELFHOSTED=true
|
||||
|
||||
# Minimax Configuration (Optional)
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import type { MutableRefObject } from "react"
|
||||
import type { RefObject } 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"
|
||||
@@ -50,16 +51,23 @@ type ValidateDiagramFn = (
|
||||
) => Promise<ValidationResult>
|
||||
|
||||
interface UseDiagramToolHandlersParams {
|
||||
partialXmlRef: MutableRefObject<string>
|
||||
editDiagramOriginalXmlRef: MutableRefObject<Map<string, string>>
|
||||
partialXmlRef: RefObject<string>
|
||||
// Diagram before a cut off display_diagram whose half drawn preview
|
||||
// stays while append_diagram finishes it; null when none is pending
|
||||
continuationOriginalRef: RefObject<string | null>
|
||||
editDiagramOriginalXmlRef: RefObject<Map<string, string>>
|
||||
// Tool calls the streaming preview must leave alone (shared with it)
|
||||
processedToolCallsRef: MutableRefObject<Set<string>>
|
||||
processedToolCallsRef: RefObject<Set<string>>
|
||||
// Failed VLM validations in the current user turn (reset on each user message)
|
||||
validationRetryCountRef: MutableRefObject<number>
|
||||
chartXMLRef: MutableRefObject<string>
|
||||
onDisplayChart: (xml: string, skipValidation?: boolean) => string | null
|
||||
onFetchChart: (saveToHistory?: boolean) => Promise<string>
|
||||
onExport: () => void
|
||||
validationRetryCountRef: RefObject<number>
|
||||
chartXMLRef: RefObject<string>
|
||||
onDisplayChart: (
|
||||
xml: string,
|
||||
skipValidation?: boolean,
|
||||
mode?: LoadMode,
|
||||
meta?: { toolCallId?: string },
|
||||
) => string | null
|
||||
onFetchChart: () => Promise<string>
|
||||
captureValidationPng?: () => Promise<string | null>
|
||||
validateDiagram?: ValidateDiagramFn
|
||||
enableVlmValidation?: boolean
|
||||
@@ -84,13 +92,13 @@ interface UseDiagramToolHandlersParams {
|
||||
*/
|
||||
export function useDiagramToolHandlers({
|
||||
partialXmlRef,
|
||||
continuationOriginalRef,
|
||||
editDiagramOriginalXmlRef,
|
||||
processedToolCallsRef,
|
||||
validationRetryCountRef,
|
||||
chartXMLRef,
|
||||
onDisplayChart,
|
||||
onFetchChart,
|
||||
onExport,
|
||||
captureValidationPng,
|
||||
validateDiagram,
|
||||
enableVlmValidation = true,
|
||||
@@ -146,6 +154,17 @@ export function useDiagramToolHandlers({
|
||||
// handler. The first is the diagram before all of them. A call that
|
||||
// draws its result replaces those previews, so the preview code must
|
||||
// neither draw them again nor undo them later. Returns that first one.
|
||||
// The final result as one undo step. A cut off drawing it replaces goes
|
||||
// first, so the step (and the version) starts from the diagram before
|
||||
// it, also when a failed call in between was undone to the cut off one.
|
||||
const commit = (xml: string, toolCallId: string) => {
|
||||
const original = continuationOriginalRef.current
|
||||
if (original !== null) onDisplayChart(original, true, "revert")
|
||||
const error = onDisplayChart(xml, true, "commit", { toolCallId })
|
||||
if (!error) continuationOriginalRef.current = null
|
||||
return error
|
||||
}
|
||||
|
||||
const takeOriginals = (): string | undefined => {
|
||||
const [originalXml] = editDiagramOriginalXmlRef.current.values()
|
||||
for (const id of editDiagramOriginalXmlRef.current.keys()) {
|
||||
@@ -181,6 +200,15 @@ export function useDiagramToolHandlers({
|
||||
if (isTruncated) {
|
||||
// Store the partial XML for continuation via append_diagram
|
||||
partialXmlRef.current = xml
|
||||
// The half drawn preview stays while append_diagram finishes
|
||||
// it; the chat engine brings this diagram back if that never
|
||||
// happens
|
||||
if (
|
||||
originalXml !== undefined &&
|
||||
continuationOriginalRef.current === null
|
||||
) {
|
||||
continuationOriginalRef.current = originalXml
|
||||
}
|
||||
|
||||
// Tell LLM to use append_diagram to continue
|
||||
const partialEnding = partialXmlRef.current.slice(-500)
|
||||
@@ -210,16 +238,19 @@ 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(prepared.xml, toolCall.toolCallId)
|
||||
: 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)
|
||||
if (originalXml) onDisplayChart(originalXml, true, "revert")
|
||||
// Return error to model - sendAutomaticallyWhen will trigger retry
|
||||
if (DEBUG) {
|
||||
console.log(
|
||||
@@ -430,7 +461,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)
|
||||
if (originalXml) onDisplayChart(originalXml, true, "revert")
|
||||
}
|
||||
try {
|
||||
if (originalXml) {
|
||||
@@ -442,7 +473,7 @@ ${finalXml}
|
||||
currentXml = cachedXML
|
||||
} else {
|
||||
// Last resort: export from iframe
|
||||
currentXml = await onFetchChart(false)
|
||||
currentXml = await onFetchChart()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -470,8 +501,7 @@ Please check the cell IDs and retry.`,
|
||||
return
|
||||
}
|
||||
|
||||
onDisplayChart(outcome.xml, true)
|
||||
onExport()
|
||||
commit(outcome.xml, toolCall.toolCallId)
|
||||
addToolOutput({
|
||||
tool: "edit_diagram",
|
||||
toolCallId: toolCall.toolCallId,
|
||||
@@ -561,14 +591,15 @@ 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(prepared.xml, toolCall.toolCallId)
|
||||
: prepared.error
|
||||
|
||||
if (validationError) {
|
||||
// Loading failed: back to the diagram before the previews
|
||||
if (prepared.ok && originalXml) {
|
||||
onDisplayChart(originalXml, true)
|
||||
}
|
||||
// Loading failed: back to the diagram before the previews,
|
||||
// and before the cut off drawing
|
||||
const before = originalXml ?? continuationOriginalRef.current
|
||||
continuationOriginalRef.current = null
|
||||
if (before) onDisplayChart(before, true, "revert")
|
||||
addToolOutput({
|
||||
tool: "append_diagram",
|
||||
toolCallId: toolCall.toolCallId,
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
import { useEffect, useState } from "react"
|
||||
import { PANEL_MIN_WIDTH, useSettingsStore } from "@/stores/settings-store"
|
||||
|
||||
// Room the canvas keeps next to the chat panel in a small window
|
||||
const MIN_CANVAS_WIDTH = 400
|
||||
|
||||
/** The chat panel's width, narrower when the window has no room for it */
|
||||
export function usePanelWidth(): number {
|
||||
const width = useSettingsStore((s) => s.panelWidth)
|
||||
// Set after mounting, so the server render and the first one agree
|
||||
const [windowWidth, setWindowWidth] = useState(Number.POSITIVE_INFINITY)
|
||||
useEffect(() => {
|
||||
const update = () => setWindowWidth(window.innerWidth)
|
||||
update()
|
||||
window.addEventListener("resize", update)
|
||||
return () => window.removeEventListener("resize", update)
|
||||
}, [])
|
||||
return Math.max(
|
||||
PANEL_MIN_WIDTH,
|
||||
Math.min(width, windowWidth - MIN_CANVAS_WIDTH),
|
||||
)
|
||||
}
|
||||
@@ -14,18 +14,32 @@ 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
|
||||
diagramHistory?: { svg: string; xml: 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: Pick<ChatSession, "versions" | "diagramHistory">,
|
||||
): DiagramVersion[] {
|
||||
return session.versions ?? versionsFromLegacyHistory(session.diagramHistory)
|
||||
}
|
||||
|
||||
// Taken right before a save's data is read: the chat on screen then, and
|
||||
@@ -57,7 +71,10 @@ export interface UseSessionManagerReturn {
|
||||
refreshSessions: () => Promise<void>
|
||||
clearCurrentSession: () => void
|
||||
getChatGeneration: () => number
|
||||
/** The chat on screen now, also right after a save created it */
|
||||
getCurrentSessionId: () => string | null
|
||||
getSaveTicket: () => SaveTicket
|
||||
renameSession: (id: string, title: string) => Promise<void>
|
||||
}
|
||||
|
||||
// Reading the session list loads every stored session in full, and window
|
||||
@@ -267,7 +284,7 @@ export function useSessionManager(
|
||||
xmlSnapshots: session.xmlSnapshots,
|
||||
diagramXml: session.diagramXml,
|
||||
thumbnailDataUrl: session.thumbnailDataUrl,
|
||||
diagramHistory: session.diagramHistory,
|
||||
versions: getSessionVersions(session),
|
||||
}
|
||||
},
|
||||
[currentSessionId, changeChat],
|
||||
@@ -319,8 +336,8 @@ export function useSessionManager(
|
||||
xmlSnapshots: data.xmlSnapshots,
|
||||
diagramXml: data.diagramXml,
|
||||
thumbnailDataUrl: data.thumbnailDataUrl,
|
||||
diagramHistory: data.diagramHistory,
|
||||
title: extractTitle(data.messages),
|
||||
versions: data.versions,
|
||||
title: data.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)
|
||||
@@ -348,8 +365,9 @@ export function useSessionManager(
|
||||
thumbnailDataUrl:
|
||||
data.thumbnailDataUrl ??
|
||||
currentSession.thumbnailDataUrl,
|
||||
diagramHistory:
|
||||
data.diagramHistory ?? currentSession.diagramHistory,
|
||||
versions:
|
||||
data.versions ?? getSessionVersions(currentSession),
|
||||
diagramHistory: undefined,
|
||||
updatedAt: Date.now(),
|
||||
// Update title if it's still default and we have messages
|
||||
title:
|
||||
@@ -403,6 +421,10 @@ export function useSessionManager(
|
||||
}, [changeChat])
|
||||
|
||||
const getChatGeneration = useCallback(() => chatGenerationRef.current, [])
|
||||
const getCurrentSessionId = useCallback(
|
||||
() => currentSessionRef.current?.id ?? null,
|
||||
[],
|
||||
)
|
||||
|
||||
const getSaveTicket = useCallback(
|
||||
(): SaveTicket => ({
|
||||
@@ -412,6 +434,36 @@ 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,
|
||||
@@ -424,6 +476,8 @@ export function useSessionManager(
|
||||
refreshSessions,
|
||||
clearCurrentSession,
|
||||
getChatGeneration,
|
||||
getCurrentSessionId,
|
||||
getSaveTicket,
|
||||
renameSession,
|
||||
}
|
||||
}
|
||||
|
||||
+2
-1
@@ -25,6 +25,7 @@ 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"
|
||||
@@ -1025,7 +1026,7 @@ export function getAIModel(clientOverrides?: ClientOverrides): ModelConfig {
|
||||
? PROVIDER_INFO.ollama.defaultBaseUrl
|
||||
: resolveBaseUrlEnv(overrides, "OLLAMA_BASE_URL"))
|
||||
model = createOllama({
|
||||
...(baseURL && { baseURL }),
|
||||
...(baseURL && { baseURL: ollamaApiUrl(baseURL) }),
|
||||
...(apiKey && {
|
||||
headers: { Authorization: `Bearer ${apiKey}` },
|
||||
}),
|
||||
|
||||
@@ -133,3 +133,37 @@ 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 ""
|
||||
// Past the limit the model must know the list is cut short
|
||||
const more = selectedCells.length - MAX_SELECTED_CELLS
|
||||
if (more > 0) lines.push(`- and ${more} more selected shapes not listed`)
|
||||
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.`
|
||||
}
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
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
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
import { useState } from "react"
|
||||
|
||||
/** 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Copy text and show "copied" for a moment; onFail runs when it failed */
|
||||
export function useCopy(onFail?: () => void) {
|
||||
const [copied, setCopied] = useState(false)
|
||||
const copy = async (text: string) => {
|
||||
const ok = await copyText(text)
|
||||
if (!ok) onFail?.()
|
||||
setCopied(ok)
|
||||
setTimeout(() => setCopied(false), 1500)
|
||||
}
|
||||
return { copied, copy }
|
||||
}
|
||||
@@ -0,0 +1,273 @@
|
||||
import { decompressPageContent } from "@/packages/mcp-server/src/load-diagram.ts"
|
||||
|
||||
/** 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[]
|
||||
/** The diagram before had no shapes or connectors */
|
||||
fromScratch: boolean
|
||||
}
|
||||
|
||||
export const EMPTY_SUMMARY: ChangeSummary = {
|
||||
shapesAdded: 0,
|
||||
shapesRemoved: 0,
|
||||
shapesChanged: 0,
|
||||
edgesAdded: 0,
|
||||
edgesRemoved: 0,
|
||||
edgesChanged: 0,
|
||||
}
|
||||
|
||||
interface CellInfo {
|
||||
isEdge: boolean
|
||||
signature: string
|
||||
}
|
||||
|
||||
/** 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 = decompressPageContent(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
|
||||
}
|
||||
|
||||
// Values draw.io leaves out when it saves: the model may write them
|
||||
const DEFAULT_VALUES: Record<string, Record<string, string>> = {
|
||||
mxCell: {
|
||||
vertex: "0",
|
||||
edge: "0",
|
||||
connectable: "1",
|
||||
visible: "1",
|
||||
collapsed: "0",
|
||||
style: "",
|
||||
},
|
||||
mxGeometry: { x: "0", y: "0", width: "0", height: "0", relative: "0" },
|
||||
mxPoint: { x: "0", y: "0" },
|
||||
}
|
||||
// Numbers draw.io writes back in their shortest form ("40.0" is "40")
|
||||
const NUMBERS = new Set(["x", "y", "width", "height"])
|
||||
|
||||
/** A style as draw.io reads it: named styles in order, a later key wins */
|
||||
function normalStyle(style: string): string {
|
||||
const named: string[] = []
|
||||
const keys = new Map<string, string>()
|
||||
for (const entry of style.split(";").map((e) => e.trim())) {
|
||||
if (!entry) continue
|
||||
const eq = entry.indexOf("=")
|
||||
if (eq < 0) named.push(entry)
|
||||
else keys.set(entry.slice(0, eq), entry.slice(eq + 1))
|
||||
}
|
||||
const pairs = [...keys].map(([k, v]) => `${k}=${v}`).sort()
|
||||
return [...named, ...pairs].join(";")
|
||||
}
|
||||
|
||||
/**
|
||||
* 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, and draw.io drops values that are the default
|
||||
*/
|
||||
function signatureOf(node: Element): string {
|
||||
const defaults = DEFAULT_VALUES[node.tagName] ?? {}
|
||||
const attrs = Array.from(node.attributes)
|
||||
.flatMap((a) => {
|
||||
let value = a.value
|
||||
if (a.name === "style") value = normalStyle(value)
|
||||
else if (NUMBERS.has(a.name) && value.trim() !== "") {
|
||||
const n = Number(value)
|
||||
if (Number.isFinite(n)) value = String(n)
|
||||
}
|
||||
if (defaults[a.name] === value) return []
|
||||
return [`${a.name}=${value}`]
|
||||
})
|
||||
.sort()
|
||||
const children = Array.from(node.children).map(signatureOf)
|
||||
return `<${node.tagName} ${attrs.join(" ")}>${children.join("")}</>`
|
||||
}
|
||||
|
||||
/** The cells of a page, layers included, with the element that holds each */
|
||||
function cellsIn(model: Element | null) {
|
||||
if (!model) return []
|
||||
return Array.from(model.getElementsByTagName("mxCell")).flatMap((cell) => {
|
||||
// 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) return []
|
||||
return [{ id, cell, node: isWrapped && wrapper ? wrapper : cell }]
|
||||
})
|
||||
}
|
||||
|
||||
/** Shapes and connectors of the first page */
|
||||
function collectCells(xml: string): Map<string, CellInfo> {
|
||||
const cells = new Map<string, CellInfo>()
|
||||
for (const { id, cell, node } of cellsIn(firstPageModel(xml))) {
|
||||
if (id === "0" || id === "1") continue
|
||||
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, fromScratch: before.size === 0 }
|
||||
}
|
||||
|
||||
// Where the view was scrolled to, saved with the page; not a change
|
||||
const VIEW_ATTRIBUTES = new Set(["dx", "dy"])
|
||||
|
||||
// A page setting written on one side only is draw.io's default being
|
||||
// filled in when the other side has that default; an empty list: any value
|
||||
// is a change. Settings not listed are not compared then.
|
||||
const PAGE_DEFAULTS: Record<string, string[]> = {
|
||||
background: ["none"],
|
||||
backgroundImage: [],
|
||||
extFonts: [],
|
||||
math: ["0"],
|
||||
shadow: ["0"],
|
||||
page: ["1"],
|
||||
pageScale: ["1"],
|
||||
// Letter or A4, after the browser's language
|
||||
pageWidth: ["850", "827"],
|
||||
pageHeight: ["1100", "1169"],
|
||||
grid: ["1"],
|
||||
gridSize: ["10"],
|
||||
guides: ["1"],
|
||||
tooltips: ["1"],
|
||||
connect: ["1"],
|
||||
arrows: ["1"],
|
||||
fold: ["1"],
|
||||
}
|
||||
|
||||
function samePageSettings(a: Element, b: Element): boolean {
|
||||
const names = new Set(
|
||||
[...Array.from(a.attributes), ...Array.from(b.attributes)].map(
|
||||
(attr) => attr.name,
|
||||
),
|
||||
)
|
||||
for (const name of names) {
|
||||
if (VIEW_ATTRIBUTES.has(name)) continue
|
||||
const va = a.getAttribute(name)
|
||||
const vb = b.getAttribute(name)
|
||||
if (va !== null && vb !== null) {
|
||||
if (va !== vb) return false
|
||||
continue
|
||||
}
|
||||
const defaults = PAGE_DEFAULTS[name]
|
||||
if (defaults && !defaults.includes((va ?? vb) as string)) return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
/**
|
||||
* Every cell of a page, layers included, and the order of each parent's
|
||||
* children (which shape is in front)
|
||||
*/
|
||||
function contentOf(model: Element | null) {
|
||||
const cells = new Map<string, string>()
|
||||
const children = new Map<string, string[]>()
|
||||
for (const { id, cell, node } of cellsIn(model)) {
|
||||
cells.set(id, signatureOf(node))
|
||||
const parent = cell.getAttribute("parent") ?? ""
|
||||
children.set(parent, [...(children.get(parent) ?? []), id])
|
||||
}
|
||||
return { cells, children }
|
||||
}
|
||||
|
||||
function sameContent(a: Element | null, b: Element | null): boolean {
|
||||
const ca = contentOf(a)
|
||||
const cb = contentOf(b)
|
||||
if (ca.cells.size !== cb.cells.size) return false
|
||||
for (const [id, signature] of ca.cells) {
|
||||
if (cb.cells.get(id) !== signature) return false
|
||||
}
|
||||
for (const [parent, ids] of ca.children) {
|
||||
if (cb.children.get(parent)?.join("\n") !== ids.join("\n")) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether two documents hold the same pages (names, cells, their order)
|
||||
* and page settings
|
||||
*/
|
||||
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) {
|
||||
if (!samePageSettings(pageA.model, pageB.model)) return false
|
||||
}
|
||||
return sameContent(pageA.model, pageB.model)
|
||||
})
|
||||
}
|
||||
@@ -1,17 +0,0 @@
|
||||
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)
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
import { getAssetUrl } from "@/lib/base-path"
|
||||
import type { Locale } from "@/lib/i18n/config"
|
||||
import { BLANK_MXFILE } from "@/packages/mcp-server/src/pages.ts"
|
||||
|
||||
/** External draw.io configured at build time (cross-origin, limited features) */
|
||||
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 {
|
||||
// index.html: 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(BLANK_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,
|
||||
body > .geToolbarContainer, .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
|
||||
}
|
||||
@@ -0,0 +1,745 @@
|
||||
/**
|
||||
* 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 { hasCells } from "@/packages/mcp-server/src/pages.ts"
|
||||
import { type SelectedCell, useCanvasStore } from "@/stores/canvas-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,
|
||||
})
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
// Actions that open a dialog or start text editing, which takes the focus
|
||||
const FOCUS_TAKING_ACTIONS = new Set([
|
||||
"findReplace",
|
||||
"editDiagram",
|
||||
"insertImage",
|
||||
"insertLink",
|
||||
"print",
|
||||
"insertText",
|
||||
])
|
||||
|
||||
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)
|
||||
}
|
||||
// The app's buttons are outside the frame: keys go to draw.io again
|
||||
if (!FOCUS_TAKING_ACTIONS.has(name)) focusCanvas()
|
||||
// Some actions change state without firing an event we listen to
|
||||
setTimeout(syncAll, 0)
|
||||
}
|
||||
|
||||
/**
|
||||
* For the app's menus: when their item handed the focus to draw.io (its
|
||||
* dialog or the canvas), closing the menu must not take it back
|
||||
*/
|
||||
export function keepFocusInCanvas(event: Event) {
|
||||
if (frameElement && document.activeElement === frameElement) {
|
||||
event.preventDefault()
|
||||
}
|
||||
}
|
||||
|
||||
/** 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
|
||||
}
|
||||
|
||||
// Page settings draw.io applies on a full load only: replacing the page in
|
||||
// place keeps the old ones
|
||||
const LOAD_ONLY_SETTINGS =
|
||||
/<mxGraphModel\b[^>]*\s(?:backgroundImage=|extFonts=|math="1"|shadow="1")/
|
||||
|
||||
function canvasHasLoadOnlySettings(): boolean {
|
||||
const g = graph()
|
||||
return (
|
||||
!!g &&
|
||||
(!!g.backgroundImage ||
|
||||
!!g.mathEnabled ||
|
||||
!!g.shadowVisible ||
|
||||
(g.extFonts?.length ?? 0) > 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
|
||||
}
|
||||
// A document with them, or replacing one with them, loads in full
|
||||
if (LOAD_ONLY_SETTINGS.test(xml) || canvasHasLoadOnlySettings()) {
|
||||
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 && !hasCells(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) {
|
||||
previewBase = null
|
||||
withoutUndo(() => replace(targetXml))
|
||||
}
|
||||
|
||||
/** Forget the preview base (new user turn) without changing the canvas */
|
||||
export function resetPreview() {
|
||||
previewBase = null
|
||||
}
|
||||
|
||||
// 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(/ /g, " ")
|
||||
.replace(/&/g, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/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,
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 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)
|
||||
focusCanvas()
|
||||
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") {
|
||||
// Named as draw.io's own menu names it ("Copy of …")
|
||||
const name = win?.mxResources?.get?.("copyOf", [page.getName()])
|
||||
ui.duplicatePage(page, name)
|
||||
}
|
||||
focusCanvas()
|
||||
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
|
||||
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()
|
||||
}
|
||||
// Drawing stopped (Escape, X or the Select button): the toolbar shows
|
||||
// it, and draw.io's freehand settings window goes away
|
||||
const onFreehand = () => {
|
||||
if (!g.freehand?.isDrawing?.()) {
|
||||
ui?.freehandWindow?.window?.setVisible?.(false)
|
||||
}
|
||||
syncAll()
|
||||
}
|
||||
const onView = () => {
|
||||
syncZoom()
|
||||
syncSelectionRect()
|
||||
}
|
||||
const onScale = () => {
|
||||
zoomChanged()
|
||||
onView()
|
||||
}
|
||||
|
||||
listen(g.getSelectionModel(), mxEvent.CHANGE, onSelection)
|
||||
listen(g.model, mxEvent.CHANGE, syncAll)
|
||||
listen(ui.editor?.undoManager, mxEvent.ADD, syncAll)
|
||||
listen(ui.editor?.undoManager, mxEvent.UNDO, syncAll)
|
||||
listen(ui.editor?.undoManager, mxEvent.REDO, syncAll)
|
||||
listen(ui.editor?.undoManager, mxEvent.CLEAR, syncAll)
|
||||
listen(g, "freehandStateChanged", onFreehand)
|
||||
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, "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
|
||||
}
|
||||
+201
-111
@@ -3,22 +3,19 @@
|
||||
"save": "Save",
|
||||
"cancel": "Cancel",
|
||||
"close": "Close",
|
||||
"confirm": "Confirm",
|
||||
"clear": "Clear",
|
||||
"edit": "Edit",
|
||||
"delete": "Delete",
|
||||
"loading": "Loading..",
|
||||
"new": "NEW"
|
||||
"loading": "Loading.."
|
||||
},
|
||||
"nav": {
|
||||
"about": "About",
|
||||
"editor": "Editor",
|
||||
"newChat": "Start fresh chat",
|
||||
"newChat": "New diagram",
|
||||
"github": "GitHub",
|
||||
"settings": "Settings",
|
||||
"hidePanel": "Hide chat panel (Ctrl+B)",
|
||||
"showPanel": "Show chat panel (Ctrl+B)",
|
||||
"aiChat": "AI Chat"
|
||||
"hidePanel": "Hide chat panel",
|
||||
"showPanel": "Show chat panel",
|
||||
"aiChat": "AI chat"
|
||||
},
|
||||
"providers": {
|
||||
"useServerDefault": "Use Server Default",
|
||||
@@ -39,12 +36,11 @@
|
||||
},
|
||||
"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",
|
||||
@@ -52,39 +48,38 @@
|
||||
"regenerate": "Regenerate response",
|
||||
"copyResponse": "Copy response",
|
||||
"copied": "Copied!",
|
||||
"failedToCopy": "Failed to copy",
|
||||
"failedToCopyDetail": "Failed to copy message. Please copy manually or check clipboard permissions.",
|
||||
"goodResponse": "Good response",
|
||||
"badResponse": "Bad response",
|
||||
"clickToEdit": "Click to edit",
|
||||
"editMessage": "Edit message",
|
||||
"saveAndSubmit": "Save & Submit",
|
||||
"ExtractURL": "Extract from URL"
|
||||
"saveAndSubmit": "Save & submit",
|
||||
"ExtractURL": "Extract from URL",
|
||||
"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"
|
||||
},
|
||||
"examples": {
|
||||
"title": "Create diagrams with AI",
|
||||
"subtitle": "Describe what you want to create or upload an image to replicate",
|
||||
"quickExamples": "Quick Examples",
|
||||
"paperToDiagram": "Paper to Diagram",
|
||||
"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 Flowchart",
|
||||
"replicateFlowchart": "Replicate a flowchart",
|
||||
"replicateDescription": "Upload and replicate an existing flowchart",
|
||||
"creativeDrawing": "Creative Drawing",
|
||||
"creativeDrawing": "Creative drawing",
|
||||
"creativeDescription": "Draw something fun and creative",
|
||||
"cachedNote": "Examples are cached for instant response",
|
||||
"cachedNote": "Examples open instantly and don't use your model.",
|
||||
"mcpServer": "MCP Server",
|
||||
"mcpDescription": "Use in Claude Desktop, VS Code & Cursor"
|
||||
},
|
||||
"settings": {
|
||||
"title": "Settings",
|
||||
"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",
|
||||
@@ -101,17 +96,10 @@
|
||||
"language": "Language",
|
||||
"languageDescription": "Choose your interface language.",
|
||||
"theme": "Theme",
|
||||
"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",
|
||||
"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",
|
||||
"sendShortcutDescription": "Choose how to send messages.",
|
||||
"enterToSend": "Enter to send",
|
||||
"ctrlEnterToSend": "Cmd/Ctrl+Enter to send",
|
||||
@@ -119,52 +107,53 @@
|
||||
"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": "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 System Message",
|
||||
"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.",
|
||||
"customSystemMessage": "Custom instructions",
|
||||
"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.",
|
||||
"panelVisibility": "Lobby Panels",
|
||||
"panelVisibilityDescription": "Choose which panels to show on the chat lobby.",
|
||||
"showRecentChats": "Recent Chats",
|
||||
"showMyTemplates": "My Templates",
|
||||
"showQuickExamples": "Quick Examples"
|
||||
"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"
|
||||
},
|
||||
"save": {
|
||||
"title": "Save Diagram",
|
||||
"description": "Choose a format and filename to save your diagram.",
|
||||
"title": "Export diagram",
|
||||
"description": "Pick a format and a file name.",
|
||||
"format": "Format",
|
||||
"filename": "Filename",
|
||||
"filenamePlaceholder": "Enter filename",
|
||||
"formats": {
|
||||
"drawio": "Draw.io XML",
|
||||
"png": "PNG Image",
|
||||
"svg": "SVG Image",
|
||||
"drawio": "draw.io file",
|
||||
"png": "PNG image",
|
||||
"svg": "SVG image",
|
||||
"xmlsvg": "Editable SVG"
|
||||
},
|
||||
"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}?"
|
||||
"savedSuccessfully": "Diagram exported",
|
||||
"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"
|
||||
}
|
||||
},
|
||||
"dialogs": {
|
||||
"clearTitle": "Clear Everything?",
|
||||
"clearDescription": "This will clear the current conversation and reset the diagram. This action cannot be undone.",
|
||||
"clearEverything": "Clear Everything",
|
||||
"clearSuccess": "Started a fresh chat"
|
||||
},
|
||||
"errors": {
|
||||
@@ -189,6 +178,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.",
|
||||
@@ -227,11 +219,6 @@
|
||||
"usedOf": "{used}/{limit}"
|
||||
},
|
||||
"tools": {
|
||||
"generateDiagram": "Generate Diagram",
|
||||
"editDiagram": "Edit Diagram",
|
||||
"appendDiagram": "Continue Diagram",
|
||||
"complete": "Complete",
|
||||
"error": "Error",
|
||||
"truncated": "Truncated"
|
||||
},
|
||||
"file": {
|
||||
@@ -246,7 +233,8 @@
|
||||
"extract": "Extract",
|
||||
"Cancel": "Cancel",
|
||||
"enterUrl": "Please enter a URL",
|
||||
"invalidFormat": "Invalid URL format"
|
||||
"invalidFormat": "Invalid URL format",
|
||||
"failed": "Couldn't read that page"
|
||||
},
|
||||
"reasoning": {
|
||||
"thinking": "Thinking...",
|
||||
@@ -279,30 +267,29 @@
|
||||
"sessionHistory": {
|
||||
"tooltip": "Chat History",
|
||||
"newChat": "New Chat",
|
||||
"empty": "No chat history yet",
|
||||
"empty": "No diagrams yet",
|
||||
"emptyHint": "Start a conversation to begin",
|
||||
"today": "Today",
|
||||
"yesterday": "Yesterday",
|
||||
"thisWeek": "This Week",
|
||||
"earlier": "Earlier",
|
||||
"deleteTitle": "Delete this chat?",
|
||||
"deleteDescription": "This will permanently delete this chat session and its diagram. This action cannot be undone.",
|
||||
"recentChats": "Recent Chats",
|
||||
"deleteTitle": "Delete this diagram?",
|
||||
"deleteDescription": "The diagram and its conversation are deleted for good.",
|
||||
"justNow": "Just now",
|
||||
"searchPlaceholder": "Search chats...",
|
||||
"noResults": "No chats found"
|
||||
"searchPlaceholder": "Search diagrams…",
|
||||
"noResults": "No chats found",
|
||||
"rename": "Rename",
|
||||
"busyHint": "Available when the answer is finished"
|
||||
},
|
||||
"templates": {
|
||||
"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",
|
||||
"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",
|
||||
"neverUsed": "Not used yet",
|
||||
"usedCount": "{count} uses",
|
||||
"myTemplates": "My Templates",
|
||||
"createTitle": "Create Template",
|
||||
"createDescription": "Save a prompt for repeated use. Templates help you quickly start common workflows.",
|
||||
"myTemplates": "Saved prompts",
|
||||
"createTitle": "Save a prompt",
|
||||
"createDescription": "Save a prompt to use again later.",
|
||||
"promptLabel": "Prompt",
|
||||
"promptPlaceholder": "Describe the diagram you want to create...",
|
||||
"promptRequired": "Prompt is required",
|
||||
@@ -310,33 +297,34 @@
|
||||
"titlePlaceholder": "Enter a title",
|
||||
"titleHint": "Leave empty to use the first 20 characters of prompt",
|
||||
"descriptionLabel": "Description",
|
||||
"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.",
|
||||
"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.",
|
||||
"duplicate": "Duplicate",
|
||||
"copySuffix": "(copy)",
|
||||
"deleteTitle": "Delete this template?",
|
||||
"deleteDescription": "This will permanently delete this template. This action cannot be undone.",
|
||||
"deleteTitle": "Delete this saved prompt?",
|
||||
"deleteDescription": "The prompt is deleted for good.",
|
||||
"confirmSendTitle": "Replace current input?",
|
||||
"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",
|
||||
"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",
|
||||
"pin": "Pin to top",
|
||||
"unpin": "Unpin from top",
|
||||
"saveAsTemplate": "Save as Template",
|
||||
"exportTemplates": "Export Templates",
|
||||
"importTemplates": "Import Templates",
|
||||
"exportEmpty": "No templates to export",
|
||||
"exportSuccess": "Exported {count} template(s) successfully",
|
||||
"saveAsTemplate": "Save this prompt",
|
||||
"exportTemplates": "Export saved prompts",
|
||||
"importTemplates": "Import saved prompts",
|
||||
"exportEmpty": "No saved prompts to export",
|
||||
"exportSuccess": "Exported {count} saved prompt(s)",
|
||||
"importNoFile": "Please select a JSON file",
|
||||
"importFailed": "Import failed: {error}",
|
||||
"importSuccess": "Imported {imported} template(s), skipped {skipped} duplicate(s)"
|
||||
"importSuccess": "Imported {imported} saved prompt(s), skipped {skipped} duplicate(s)",
|
||||
"saveDisabledHint": "Type a prompt first"
|
||||
},
|
||||
"validation": {
|
||||
"title": "Validate Diagram",
|
||||
@@ -357,8 +345,6 @@
|
||||
"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",
|
||||
@@ -375,7 +361,7 @@
|
||||
"validationError": "Validation failed",
|
||||
"addModelFirst": "Add at least one model to validate",
|
||||
"providers": "Providers",
|
||||
"addProviderHint": "Add a provider to get started",
|
||||
"addProviderHint": "No providers yet",
|
||||
"verified": "Verified",
|
||||
"configuration": "Configuration",
|
||||
"displayName": "Display Name",
|
||||
@@ -404,13 +390,14 @@
|
||||
"noModelsConfigured": "No models configured",
|
||||
"modelIdEmpty": "Model ID cannot be empty",
|
||||
"modelIdExists": "This model ID already exists",
|
||||
"configureProviders": "Configure AI Providers",
|
||||
"configureProviders": "Set up 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",
|
||||
"typeProviderName": "Type provider name...",
|
||||
"modelsConfiguredCount": "{count} model(s) configured",
|
||||
"validationFailedCount": "{count} model(s) failed validation",
|
||||
"requestFailed": "Request failed ({status})",
|
||||
"cancel": "Cancel",
|
||||
"delete": "Delete",
|
||||
"clickToChange": "(click to change)",
|
||||
@@ -430,7 +417,9 @@
|
||||
"unvalidatedModelWarning": "This model has not been validated",
|
||||
"serverDefaultModel": "Server default model",
|
||||
"showValue": "Show value",
|
||||
"hideValue": "Hide 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."
|
||||
},
|
||||
"admin": {
|
||||
"title": "Admin Settings",
|
||||
@@ -575,5 +564,106 @@
|
||||
"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",
|
||||
"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",
|
||||
"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 shapes and connectors on the first page are the same as in version {n}; their order, other pages or the page settings differ."
|
||||
},
|
||||
"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"
|
||||
}
|
||||
}
|
||||
|
||||
+192
-102
@@ -3,21 +3,18 @@
|
||||
"save": "保存",
|
||||
"cancel": "キャンセル",
|
||||
"close": "閉じる",
|
||||
"confirm": "確認",
|
||||
"clear": "クリア",
|
||||
"edit": "編集",
|
||||
"delete": "削除",
|
||||
"loading": "読み込み中..",
|
||||
"new": "新規"
|
||||
"loading": "読み込み中.."
|
||||
},
|
||||
"nav": {
|
||||
"about": "概要",
|
||||
"editor": "エディタ",
|
||||
"newChat": "新しいチャットを開始",
|
||||
"newChat": "新しい図",
|
||||
"github": "GitHub",
|
||||
"settings": "設定",
|
||||
"hidePanel": "チャットパネルを非表示 (Ctrl+B)",
|
||||
"showPanel": "チャットパネルを表示 (Ctrl+B)",
|
||||
"hidePanel": "チャットパネルを非表示",
|
||||
"showPanel": "チャットパネルを表示",
|
||||
"aiChat": "AI チャット"
|
||||
},
|
||||
"providers": {
|
||||
@@ -39,12 +36,11 @@
|
||||
},
|
||||
"chat": {
|
||||
"placeholder": "ダイアグラムを説明するか、ファイルをアップロード...",
|
||||
"inputLabel": "チャット入力",
|
||||
"send": "送信",
|
||||
"stopGeneration": "生成を停止",
|
||||
"sendMessage": "メッセージを送信",
|
||||
"clearConversation": "会話をクリア",
|
||||
"diagramHistory": "ダイアグラム履歴",
|
||||
"saveDiagram": "ダイアグラムを保存",
|
||||
"uploadFile": "ファイルをアップロード(画像、PDF、テキスト)",
|
||||
"minimalStyle": "ミニマル",
|
||||
"styledMode": "スタイル付き",
|
||||
@@ -52,38 +48,37 @@
|
||||
"regenerate": "応答を再生成",
|
||||
"copyResponse": "応答をコピー",
|
||||
"copied": "コピーしました!",
|
||||
"failedToCopy": "コピーに失敗しました",
|
||||
"failedToCopyDetail": "メッセージのコピーに失敗しました。手動でコピーするか、クリップボードの権限を確認してください。",
|
||||
"goodResponse": "良い応答",
|
||||
"badResponse": "悪い応答",
|
||||
"clickToEdit": "クリックして編集",
|
||||
"editMessage": "メッセージを編集",
|
||||
"saveAndSubmit": "保存して送信",
|
||||
"ExtractURL": "URLから抽出"
|
||||
"ExtractURL": "URLから抽出",
|
||||
"attach": "追加",
|
||||
"placeholderFollowUp": "変更したい内容を入力。図形を選んでから頼むこともできます",
|
||||
"emptyThread": "キャンバスに直接描くか、下で描きたいものを伝えてください。",
|
||||
"attachedImage": "添付画像",
|
||||
"retry": "再試行"
|
||||
},
|
||||
"examples": {
|
||||
"title": "AI でダイアグラムを作成",
|
||||
"subtitle": "作成したいものを説明するか、画像をアップロードして複製",
|
||||
"quickExamples": "クイック例",
|
||||
"paperToDiagram": "論文からダイアグラムへ",
|
||||
"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 で使用"
|
||||
},
|
||||
"settings": {
|
||||
"title": "設定",
|
||||
"description": "アプリケーション設定を構成します。",
|
||||
"apiKeysModels": "API キーとモデル",
|
||||
"apiKeysModelsDescription": "AI プロバイダーと API キーを設定します。",
|
||||
"accessCode": "アクセスコード",
|
||||
"accessCodePlaceholder": "アクセスコードを入力",
|
||||
"accessCodeDescription": "このアプリケーションを使用するために必要です。",
|
||||
@@ -101,17 +96,10 @@
|
||||
"language": "言語",
|
||||
"languageDescription": "インターフェース言語を選択します。",
|
||||
"theme": "テーマ",
|
||||
"themeDescription": "インターフェースと DrawIO キャンバスのダーク/ライトモード。",
|
||||
"drawioStyle": "DrawIO スタイル",
|
||||
"drawioStyleDescription": "キャンバススタイル",
|
||||
"themeDefault": "デフォルト",
|
||||
"themeDark": "ダーク",
|
||||
"themeMinimal": "ミニマル",
|
||||
"themeSketch": "スケッチ",
|
||||
"themeSimple": "シンプル",
|
||||
"diagramStyle": "ダイアグラムスタイル",
|
||||
"diagramStyleDescription": "ミニマルとスタイル付きの出力を切り替えます。",
|
||||
"sendShortcut": "送信ショートカット",
|
||||
"themeDescription": "画面をライトにするかダークにするか。図はエクスポートしたときと同じく、常に白い用紙の上に表示されます。",
|
||||
"diagramStyle": "図のスタイル",
|
||||
"diagramStyleDescription": "AI が新しい図を描くときのスタイル。スタイル付きは色やアイコンを使い、ミニマルは素朴な図形だけにします。",
|
||||
"sendShortcut": "送信のショートカット",
|
||||
"sendShortcutDescription": "メッセージの送信方法を選択します。",
|
||||
"enterToSend": "Enterで送信",
|
||||
"ctrlEnterToSend": "Cmd/Ctrl+Enterで送信",
|
||||
@@ -125,46 +113,47 @@
|
||||
"httpsProxy": "HTTPS プロキシ",
|
||||
"applyProxy": "適用",
|
||||
"proxyApplied": "プロキシ設定が適用されました",
|
||||
"diagramValidation": "ダイアグラム検証(実験的)",
|
||||
"diagramValidationDescription": "視覚言語モデルを使用して生成されたダイアグラムを検証します。GPT-5.2 や Sonnet-4.5 などの VLM が必要です。",
|
||||
"enabled": "有効",
|
||||
"disabled": "無効",
|
||||
"customSystemMessage": "カスタムシステムメッセージ",
|
||||
"diagramValidation": "スクリーンショットで図をチェック(実験的)",
|
||||
"diagramValidationDescription": "描くたびに、画像を読めるモデルがスクリーンショットを確認し、修正を求めます。画像を読めるモデルが必要です。",
|
||||
"customSystemMessage": "カスタム指示",
|
||||
"customSystemMessageDescription": "AIのシステムプロンプトに追加されるカスタム指示を入力します。",
|
||||
"customSystemMessagePlaceholder": "例:ダイアグラムには常に青色のカラースキームを使用...",
|
||||
"maxOutputTokens": "最大出力トークン数",
|
||||
"maxOutputTokensDescription": "1回の応答の予算で、思考過程とダイアグラムの XML が共有します。AI が考え続けてダイアグラムが生成されない場合は大きくしてください。空欄ならデフォルト値を使います。",
|
||||
"panelVisibility": "ロビーパネル",
|
||||
"panelVisibilityDescription": "チャットロビーに表示するパネルを選択します。",
|
||||
"showRecentChats": "最近のチャット",
|
||||
"showMyTemplates": "マイテンプレート",
|
||||
"showQuickExamples": "クイック例"
|
||||
"tabModels": "モデルとキー",
|
||||
"tabAppearance": "外観",
|
||||
"tabAdvanced": "詳細",
|
||||
"tabAbout": "このアプリについて",
|
||||
"themeLight": "ライト",
|
||||
"themeDarkMode": "ダーク",
|
||||
"themeSystem": "システムに合わせる",
|
||||
"accessCodeSaved": "アクセスコードを保存しました",
|
||||
"proxyInvalid": "プロキシのアドレスは http:// または https:// で始めてください",
|
||||
"proxyFailed": "プロキシ設定を適用できませんでした",
|
||||
"appVersion": "バージョン",
|
||||
"projectWebsite": "プロジェクトのサイト"
|
||||
},
|
||||
"save": {
|
||||
"title": "ダイアグラムを保存",
|
||||
"description": "形式とファイル名を選択してダイアグラムを保存します。",
|
||||
"title": "図をエクスポート",
|
||||
"description": "形式とファイル名を選んでください。",
|
||||
"format": "形式",
|
||||
"filename": "ファイル名",
|
||||
"filenamePlaceholder": "ファイル名を入力",
|
||||
"formats": {
|
||||
"drawio": "Draw.io XML",
|
||||
"drawio": "draw.io ファイル",
|
||||
"png": "PNG 画像",
|
||||
"svg": "SVG 画像",
|
||||
"xmlsvg": "編集可能 SVG"
|
||||
"xmlsvg": "編集できる SVG"
|
||||
},
|
||||
"savedSuccessfully": "保存完了!"
|
||||
},
|
||||
"history": {
|
||||
"title": "ダイアグラム履歴",
|
||||
"description": "AI 修正前に保存された各ダイアグラム。\nダイアグラムをクリックして復元",
|
||||
"noHistory": "まだ履歴がありません。メッセージを送信してダイアグラム履歴を作成してください。",
|
||||
"version": "バージョン",
|
||||
"restoreTo": "バージョン {version} に復元しますか?"
|
||||
"savedSuccessfully": "エクスポートしました",
|
||||
"hints": {
|
||||
"drawio": "すべて編集できるまま保存",
|
||||
"png": "スライドや文書向けの画像",
|
||||
"svg": "拡大してもぼやけない画像",
|
||||
"xmlsvg": "draw.io でも開ける画像"
|
||||
}
|
||||
},
|
||||
"dialogs": {
|
||||
"clearTitle": "すべてクリアしますか?",
|
||||
"clearDescription": "現在の会話をクリアし、ダイアグラムをリセットします。この操作は元に戻せません。",
|
||||
"clearEverything": "すべてクリア",
|
||||
"clearSuccess": "新しいチャットを開始しました"
|
||||
},
|
||||
"errors": {
|
||||
@@ -189,6 +178,9 @@
|
||||
"sessionSaveFailed": "このチャットを保存できませんでした。ブラウザのストレージがいっぱいの可能性があります。履歴から古いチャットを削除して、もう一度お試しください。",
|
||||
"sessionSaveFailedLeave": "このチャットを保存できませんでした。ブラウザのストレージがいっぱいの可能性があります。保存せずに続けて、新しいチャットの一覧から古いチャットを削除できます。",
|
||||
"continueWithoutSaving": "保存せずに続ける",
|
||||
"notADiagramFile": "{name} は draw.io の図ではありません。",
|
||||
"fileTooLong": "{name}:内容が上限の {limit}k 文字を超えています({size}k 文字)",
|
||||
"fileReadFailed": "ファイルを読み込めませんでした:{name}",
|
||||
"llm": {
|
||||
"invalid_api_key": "プロバイダーが API キーを拒否しました。モデル設定で確認してください。",
|
||||
"forbidden": "プロバイダーがリクエストを拒否しました。このキーにはこのモデルまたはリージョンの利用権限がない可能性があります。",
|
||||
@@ -227,11 +219,6 @@
|
||||
"usedOf": "{used}/{limit}"
|
||||
},
|
||||
"tools": {
|
||||
"generateDiagram": "ダイアグラムを生成",
|
||||
"editDiagram": "ダイアグラムを編集",
|
||||
"appendDiagram": "ダイアグラムに追加",
|
||||
"complete": "完了",
|
||||
"error": "エラー",
|
||||
"truncated": "切り捨て"
|
||||
},
|
||||
"file": {
|
||||
@@ -246,7 +233,8 @@
|
||||
"extract": "抽出",
|
||||
"Cancel": "キャンセル",
|
||||
"enterUrl": "URLを入力してください",
|
||||
"invalidFormat": "無効なURL形式です"
|
||||
"invalidFormat": "無効なURL形式です",
|
||||
"failed": "このページを読み込めませんでした"
|
||||
},
|
||||
"reasoning": {
|
||||
"thinking": "考え中...",
|
||||
@@ -279,18 +267,19 @@
|
||||
"sessionHistory": {
|
||||
"tooltip": "チャット履歴",
|
||||
"newChat": "新しいチャット",
|
||||
"empty": "チャット履歴はまだありません",
|
||||
"empty": "まだ図はありません",
|
||||
"emptyHint": "会話を始めてください",
|
||||
"today": "今日",
|
||||
"yesterday": "昨日",
|
||||
"thisWeek": "今週",
|
||||
"earlier": "それ以前",
|
||||
"deleteTitle": "このチャットを削除しますか?",
|
||||
"deleteDescription": "このチャットセッションとダイアグラムは完全に削除されます。この操作は取り消せません。",
|
||||
"recentChats": "最近のチャット",
|
||||
"deleteTitle": "この図を削除しますか?",
|
||||
"deleteDescription": "図とその会話は完全に削除されます。",
|
||||
"justNow": "たった今",
|
||||
"searchPlaceholder": "チャットを検索...",
|
||||
"noResults": "チャットが見つかりません"
|
||||
"searchPlaceholder": "図を検索…",
|
||||
"noResults": "チャットが見つかりません",
|
||||
"rename": "名前を変更",
|
||||
"busyHint": "回答が終わると選べます"
|
||||
},
|
||||
"validation": {
|
||||
"title": "ダイアグラムを検証",
|
||||
@@ -311,8 +300,6 @@
|
||||
"regenerateWithFeedback": "検証フィードバックを使用してダイアグラムを再生成"
|
||||
},
|
||||
"modelConfig": {
|
||||
"title": "AIモデル設定",
|
||||
"description": "複数のAIプロバイダーとモデルを設定",
|
||||
"configure": "設定",
|
||||
"addProvider": "プロバイダーを追加",
|
||||
"addModel": "モデルを追加",
|
||||
@@ -329,7 +316,7 @@
|
||||
"validationError": "検証に失敗しました",
|
||||
"addModelFirst": "検証するには少なくとも1つのモデルを追加してください",
|
||||
"providers": "プロバイダー",
|
||||
"addProviderHint": "プロバイダーを追加して開始",
|
||||
"addProviderHint": "プロバイダーはまだありません",
|
||||
"verified": "検証済み",
|
||||
"configuration": "設定",
|
||||
"displayName": "表示名",
|
||||
@@ -358,13 +345,14 @@
|
||||
"noModelsConfigured": "モデルが設定されていません",
|
||||
"modelIdEmpty": "モデル ID は空にできません",
|
||||
"modelIdExists": "このモデル ID は既に存在します",
|
||||
"configureProviders": "AI プロバイダーを設定",
|
||||
"configureProviders": "AI プロバイダーの設定",
|
||||
"selectProviderHint": "リストからプロバイダーを選択するか、新規追加して API キーとモデルを設定",
|
||||
"deleteConfirmDesc": "{name} を削除してもよろしいですか?設定されたすべてのモデルが削除され、元に戻せません。",
|
||||
"typeToConfirm": "確認のため「{name}」と入力",
|
||||
"typeProviderName": "プロバイダー名を入力...",
|
||||
"modelsConfiguredCount": "{count} 個のモデルを設定済み",
|
||||
"validationFailedCount": "{count} 個のモデルの検証に失敗",
|
||||
"requestFailed": "リクエストに失敗しました({status})",
|
||||
"cancel": "キャンセル",
|
||||
"delete": "削除",
|
||||
"clickToChange": "(クリックして変更)",
|
||||
@@ -384,19 +372,19 @@
|
||||
"unvalidatedModelWarning": "このモデルは検証されていません",
|
||||
"serverDefaultModel": "サーバーデフォルトモデル",
|
||||
"showValue": "値を表示",
|
||||
"hideValue": "値を非表示"
|
||||
"hideValue": "値を非表示",
|
||||
"noProvidersTitle": "このサーバーで設定されたモデルを使用中",
|
||||
"noProvidersHint": "ほかのモデルを使うには、左のリストの下でプロバイダーを追加し、自分の API キーを入力してください。"
|
||||
},
|
||||
"templates": {
|
||||
"title": "マイテンプレート",
|
||||
"subtitle": "素早いダイアグラム作成のための個人的なプロンプトライブラリ",
|
||||
"emptyTitle": "テンプレートがありません",
|
||||
"emptyDescription": "最初のテンプレートを作成して、個人的なプロンプトライブラリを構築しましょう",
|
||||
"createFirst": "最初のテンプレートを作成",
|
||||
"emptyTitle": "保存したプロンプトはまだありません",
|
||||
"emptyDescription": "「…の AWS 構成図を描いて」のようによく使うプロンプトを保存すると、ここに表示されます。",
|
||||
"createFirst": "最初のプロンプトを保存",
|
||||
"neverUsed": "未使用",
|
||||
"usedCount": "{count} 回使用",
|
||||
"myTemplates": "マイテンプレート",
|
||||
"createTitle": "テンプレート作成",
|
||||
"createDescription": "再利用のためにプロンプトを保存します。テンプレートを使用すると、一般的なワークフローを素早く開始できます。",
|
||||
"myTemplates": "保存したプロンプト",
|
||||
"createTitle": "プロンプトを保存",
|
||||
"createDescription": "あとで使えるようにプロンプトを保存します。",
|
||||
"promptLabel": "プロンプト",
|
||||
"promptPlaceholder": "作成するダイアグラムを説明してください...",
|
||||
"promptRequired": "プロンプトは必須です",
|
||||
@@ -404,33 +392,34 @@
|
||||
"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} 件の重複をスキップしました"
|
||||
"importSuccess": "{imported} 件のプロンプトをインポートし、重複 {skipped} 件をスキップしました",
|
||||
"saveDisabledHint": "先にプロンプトを入力してください"
|
||||
},
|
||||
"admin": {
|
||||
"title": "管理者設定",
|
||||
@@ -575,5 +564,106 @@
|
||||
"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": "無題の図",
|
||||
"mobileCanvas": "キャンバス",
|
||||
"mobileChat": "チャット"
|
||||
},
|
||||
"selection": {
|
||||
"one": "選択中:{label}",
|
||||
"many": "{count} 個の図形を選択中",
|
||||
"shape": "図形",
|
||||
"connector": "コネクタ",
|
||||
"remove": "選択した図形を送らない",
|
||||
"placeholder": "選択した図形をどう変えたいか書いてください"
|
||||
},
|
||||
"versions": {
|
||||
"strip": "バージョン",
|
||||
"created": "図を描きました",
|
||||
"edited": "図を修正しました",
|
||||
"undone": "この変更は取り消しました",
|
||||
"undo": "変更を取り消す",
|
||||
"redo": "変更をやり直す",
|
||||
"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": "手元の図の続きを編集"
|
||||
}
|
||||
}
|
||||
|
||||
+193
-103
@@ -3,22 +3,19 @@
|
||||
"save": "儲存",
|
||||
"cancel": "取消",
|
||||
"close": "關閉",
|
||||
"confirm": "確認",
|
||||
"clear": "清除",
|
||||
"edit": "編輯",
|
||||
"delete": "刪除",
|
||||
"loading": "載入中...",
|
||||
"new": "新建"
|
||||
"loading": "載入中..."
|
||||
},
|
||||
"nav": {
|
||||
"about": "關於",
|
||||
"editor": "編輯器",
|
||||
"newChat": "開始新對話",
|
||||
"newChat": "新增圖紙",
|
||||
"github": "GitHub",
|
||||
"settings": "設定",
|
||||
"hidePanel": "隱藏聊天面板 (Ctrl+B)",
|
||||
"showPanel": "顯示聊天面板 (Ctrl+B)",
|
||||
"aiChat": "AI 聊天"
|
||||
"hidePanel": "隱藏聊天面板",
|
||||
"showPanel": "顯示聊天面板",
|
||||
"aiChat": "AI 對話"
|
||||
},
|
||||
"providers": {
|
||||
"useServerDefault": "使用伺服器預設值",
|
||||
@@ -39,12 +36,11 @@
|
||||
},
|
||||
"chat": {
|
||||
"placeholder": "描述您的圖表或上傳檔案...",
|
||||
"inputLabel": "聊天輸入框",
|
||||
"send": "傳送",
|
||||
"stopGeneration": "停止產生",
|
||||
"sendMessage": "傳送訊息",
|
||||
"clearConversation": "清除對話",
|
||||
"diagramHistory": "圖表歷史",
|
||||
"saveDiagram": "儲存圖表",
|
||||
"uploadFile": "上傳檔案(圖片、PDF、文字)",
|
||||
"minimalStyle": "簡約",
|
||||
"styledMode": "精緻",
|
||||
@@ -52,38 +48,37 @@
|
||||
"regenerate": "重新產生回應",
|
||||
"copyResponse": "複製回應",
|
||||
"copied": "已複製!",
|
||||
"failedToCopy": "複製失敗",
|
||||
"failedToCopyDetail": "複製訊息失敗。請手動複製或檢查剪貼簿權限。",
|
||||
"goodResponse": "有幫助",
|
||||
"badResponse": "無幫助",
|
||||
"clickToEdit": "點擊編輯",
|
||||
"editMessage": "編輯訊息",
|
||||
"saveAndSubmit": "儲存並提交",
|
||||
"ExtractURL": "從 URL 擷取"
|
||||
"saveAndSubmit": "儲存並送出",
|
||||
"ExtractURL": "從 URL 擷取",
|
||||
"attach": "新增",
|
||||
"placeholderFollowUp": "說說想怎麼改,也可以先選取要改的圖形",
|
||||
"emptyThread": "可以直接在畫布上畫,也可以在下面說說要畫什麼。",
|
||||
"attachedImage": "附加的圖片",
|
||||
"retry": "重試"
|
||||
},
|
||||
"examples": {
|
||||
"title": "用 AI 建立圖表",
|
||||
"subtitle": "描述您想要建立的內容或上傳圖片進行複製",
|
||||
"quickExamples": "快速範例",
|
||||
"paperToDiagram": "文件轉圖表",
|
||||
"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 中使用"
|
||||
},
|
||||
"settings": {
|
||||
"title": "設定",
|
||||
"description": "配置您的應用程式設定。",
|
||||
"apiKeysModels": "API 金鑰和模型",
|
||||
"apiKeysModelsDescription": "配置 AI 提供商和 API 金鑰。",
|
||||
"accessCode": "存取碼",
|
||||
"accessCodePlaceholder": "輸入存取碼",
|
||||
"accessCodeDescription": "使用此應用程式需要存取碼。",
|
||||
@@ -101,17 +96,10 @@
|
||||
"language": "語言",
|
||||
"languageDescription": "選擇介面語言。",
|
||||
"theme": "主題",
|
||||
"themeDescription": "介面和 DrawIO 畫布的深色/淺色模式。",
|
||||
"drawioStyle": "DrawIO 樣式",
|
||||
"drawioStyleDescription": "畫布樣式",
|
||||
"themeDefault": "預設",
|
||||
"themeDark": "深色",
|
||||
"themeMinimal": "簡約",
|
||||
"themeSketch": "草圖",
|
||||
"themeSimple": "簡單",
|
||||
"diagramStyle": "圖表樣式",
|
||||
"diagramStyleDescription": "切換簡約與精緻圖表輸出模式。",
|
||||
"sendShortcut": "傳送快捷鍵",
|
||||
"themeDescription": "介面用淺色還是深色。圖紙始終是白底,和匯出後的樣子一致。",
|
||||
"diagramStyle": "圖形風格",
|
||||
"diagramStyleDescription": "AI 畫新圖時的風格:精緻會用顏色和圖示,簡約只用樸素的圖形。",
|
||||
"sendShortcut": "傳送快速鍵",
|
||||
"sendShortcutDescription": "選擇傳送訊息的方式。",
|
||||
"enterToSend": "Enter 傳送",
|
||||
"ctrlEnterToSend": "Cmd/Ctrl+Enter 傳送",
|
||||
@@ -125,46 +113,47 @@
|
||||
"httpsProxy": "HTTPS 代理",
|
||||
"applyProxy": "套用",
|
||||
"proxyApplied": "代理設定已套用",
|
||||
"diagramValidation": "圖表驗證(實驗性)",
|
||||
"diagramValidationDescription": "使用視覺語言模型驗證產生的圖表。需要支援視覺的模型,如 GPT-5.2 或 Sonnet-4.5。",
|
||||
"enabled": "已啟用",
|
||||
"disabled": "已停用",
|
||||
"customSystemMessage": "自訂系統訊息",
|
||||
"diagramValidation": "用截圖檢查圖表(實驗性)",
|
||||
"diagramValidationDescription": "每次畫完後,讓能看懂圖片的模型檢查截圖並要求修正。需要能讀圖片的模型。",
|
||||
"customSystemMessage": "自訂指令",
|
||||
"customSystemMessageDescription": "新增自訂指示,將附加到 AI 的系統提示末尾。",
|
||||
"customSystemMessagePlaceholder": "例如:圖表始終使用藍色配色方案...",
|
||||
"maxOutputTokens": "最大輸出 token 數",
|
||||
"maxOutputTokensDescription": "單次回覆的額度,思考過程與圖表 XML 共用。若 AI 一直在思考卻沒有產生圖表,請將它調大。留空則使用預設值。",
|
||||
"panelVisibility": "大廳面板",
|
||||
"panelVisibilityDescription": "選擇在聊天大廳顯示哪些面板。",
|
||||
"showRecentChats": "最近聊天",
|
||||
"showMyTemplates": "我的範本",
|
||||
"showQuickExamples": "快速範例"
|
||||
"tabModels": "模型與金鑰",
|
||||
"tabAppearance": "外觀",
|
||||
"tabAdvanced": "進階",
|
||||
"tabAbout": "關於",
|
||||
"themeLight": "淺色",
|
||||
"themeDarkMode": "深色",
|
||||
"themeSystem": "跟隨系統",
|
||||
"accessCodeSaved": "存取碼已儲存",
|
||||
"proxyInvalid": "代理位址必須以 http:// 或 https:// 開頭",
|
||||
"proxyFailed": "代理設定沒有生效",
|
||||
"appVersion": "版本",
|
||||
"projectWebsite": "專案網站"
|
||||
},
|
||||
"save": {
|
||||
"title": "儲存圖表",
|
||||
"description": "選擇格式和檔案名稱以儲存您的圖表。",
|
||||
"title": "匯出圖紙",
|
||||
"description": "選擇格式和檔案名稱。",
|
||||
"format": "格式",
|
||||
"filename": "檔案名稱",
|
||||
"filenamePlaceholder": "輸入檔案名稱",
|
||||
"formats": {
|
||||
"drawio": "Draw.io XML",
|
||||
"drawio": "draw.io 檔案",
|
||||
"png": "PNG 圖片",
|
||||
"svg": "SVG 圖片",
|
||||
"xmlsvg": "可編輯 SVG"
|
||||
"xmlsvg": "可編輯的 SVG"
|
||||
},
|
||||
"savedSuccessfully": "儲存成功!"
|
||||
},
|
||||
"history": {
|
||||
"title": "圖表歷史",
|
||||
"description": "在 AI 修改之前儲存的每個圖表。\n點擊圖表以還原它",
|
||||
"noHistory": "尚無歷史紀錄。傳送訊息以建立圖表歷史。",
|
||||
"version": "版本",
|
||||
"restoreTo": "還原到版本 {version}?"
|
||||
"savedSuccessfully": "已匯出",
|
||||
"hints": {
|
||||
"drawio": "所有內容都能繼續編輯",
|
||||
"png": "用於簡報和文件的圖片",
|
||||
"svg": "放大也不會模糊的圖片",
|
||||
"xmlsvg": "圖片,也能在 draw.io 裡開啟編輯"
|
||||
}
|
||||
},
|
||||
"dialogs": {
|
||||
"clearTitle": "清除所有內容?",
|
||||
"clearDescription": "這將清除目前對話並重設圖表。此操作無法復原。",
|
||||
"clearEverything": "清除所有內容",
|
||||
"clearSuccess": "已開始新對話"
|
||||
},
|
||||
"errors": {
|
||||
@@ -189,6 +178,9 @@
|
||||
"sessionSaveFailed": "無法儲存這個對話。瀏覽器儲存空間可能已滿,請在歷史紀錄裡刪除舊對話後重試。",
|
||||
"sessionSaveFailedLeave": "無法儲存這個對話,瀏覽器儲存空間可能已滿。可以不儲存它、直接繼續,再在新對話的列表裡刪除舊對話。",
|
||||
"continueWithoutSaving": "不儲存,繼續",
|
||||
"notADiagramFile": "{name} 不是 draw.io 圖表檔案。",
|
||||
"fileTooLong": "{name}:內容超過了 {limit}k 字元的上限({size}k 字元)",
|
||||
"fileReadFailed": "無法讀取檔案:{name}",
|
||||
"llm": {
|
||||
"invalid_api_key": "服務商拒絕了這個 API Key,請在模型設定中檢查。",
|
||||
"forbidden": "服務商拒絕了這次請求。這個 Key 可能沒有使用該模型或該地區的權限。",
|
||||
@@ -227,11 +219,6 @@
|
||||
"usedOf": "{used}/{limit}"
|
||||
},
|
||||
"tools": {
|
||||
"generateDiagram": "產生圖表",
|
||||
"editDiagram": "編輯圖表",
|
||||
"appendDiagram": "繼續圖表",
|
||||
"complete": "完成",
|
||||
"error": "錯誤",
|
||||
"truncated": "已截斷"
|
||||
},
|
||||
"file": {
|
||||
@@ -246,7 +233,8 @@
|
||||
"extract": "擷取",
|
||||
"Cancel": "取消",
|
||||
"enterUrl": "請輸入 URL",
|
||||
"invalidFormat": "URL 格式無效"
|
||||
"invalidFormat": "URL 格式無效",
|
||||
"failed": "無法讀取這個網頁"
|
||||
},
|
||||
"reasoning": {
|
||||
"thinking": "思考中...",
|
||||
@@ -279,30 +267,29 @@
|
||||
"sessionHistory": {
|
||||
"tooltip": "聊天歷史",
|
||||
"newChat": "新對話",
|
||||
"empty": "暫無聊天紀錄",
|
||||
"empty": "還沒有圖紙",
|
||||
"emptyHint": "開始對話吧",
|
||||
"today": "今天",
|
||||
"yesterday": "昨天",
|
||||
"thisWeek": "本週",
|
||||
"earlier": "更早",
|
||||
"deleteTitle": "刪除此對話?",
|
||||
"deleteDescription": "這將永久刪除此聊天工作階段及其圖表。此操作無法復原。",
|
||||
"recentChats": "最近對話",
|
||||
"deleteTitle": "刪除這張圖紙?",
|
||||
"deleteDescription": "圖紙和它的對話會被永久刪除。",
|
||||
"justNow": "剛剛",
|
||||
"searchPlaceholder": "搜尋對話...",
|
||||
"noResults": "未找到對話"
|
||||
"searchPlaceholder": "搜尋圖紙…",
|
||||
"noResults": "未找到對話",
|
||||
"rename": "重新命名",
|
||||
"busyHint": "回答完成後才能切換"
|
||||
},
|
||||
"templates": {
|
||||
"title": "我的範本",
|
||||
"subtitle": "快速建立圖表的個人提示庫",
|
||||
"emptyTitle": "尚無範本",
|
||||
"emptyDescription": "建立您的第一個範本,開始構建您的個人提示庫",
|
||||
"createFirst": "建立第一個範本",
|
||||
"emptyTitle": "還沒有常用提示詞",
|
||||
"emptyDescription": "把經常用的提示詞存起來,比如「畫一張……的 AWS 架構圖」,之後會出現在這裡。",
|
||||
"createFirst": "儲存第一個提示詞",
|
||||
"neverUsed": "未使用過",
|
||||
"usedCount": "使用 {count} 次",
|
||||
"myTemplates": "我的範本",
|
||||
"createTitle": "建立範本",
|
||||
"createDescription": "儲存常用提示以便重複使用。範本幫助您快速開始常用工作流程。",
|
||||
"myTemplates": "常用提示詞",
|
||||
"createTitle": "儲存提示詞",
|
||||
"createDescription": "把提示詞存起來,以後再用。",
|
||||
"promptLabel": "提示",
|
||||
"promptPlaceholder": "描述您想建立的圖表...",
|
||||
"promptRequired": "提示為必填項",
|
||||
@@ -310,33 +297,34 @@
|
||||
"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} 個重複"
|
||||
"importSuccess": "已匯入 {imported} 個提示詞,略過 {skipped} 個重複",
|
||||
"saveDisabledHint": "先在輸入框裡寫好提示詞"
|
||||
},
|
||||
"validation": {
|
||||
"title": "驗證圖表",
|
||||
@@ -357,8 +345,6 @@
|
||||
"regenerateWithFeedback": "使用驗證回饋重新產生圖表"
|
||||
},
|
||||
"modelConfig": {
|
||||
"title": "AI 模型配置",
|
||||
"description": "配置多個 AI 提供商和模型",
|
||||
"configure": "配置",
|
||||
"addProvider": "新增提供商",
|
||||
"addModel": "新增模型",
|
||||
@@ -375,7 +361,7 @@
|
||||
"validationError": "驗證失敗",
|
||||
"addModelFirst": "請先新增至少一個模型以進行驗證",
|
||||
"providers": "提供商",
|
||||
"addProviderHint": "新增提供商即可開始使用",
|
||||
"addProviderHint": "還沒有服務商",
|
||||
"verified": "已驗證",
|
||||
"configuration": "配置",
|
||||
"displayName": "顯示名稱",
|
||||
@@ -404,13 +390,14 @@
|
||||
"noModelsConfigured": "尚未配置模型",
|
||||
"modelIdEmpty": "模型 ID 不能為空",
|
||||
"modelIdExists": "此模型 ID 已存在",
|
||||
"configureProviders": "配置 AI 提供商",
|
||||
"configureProviders": "設定 AI 服務商",
|
||||
"selectProviderHint": "從列表中選擇提供商或新增以配置 API 金鑰和模型",
|
||||
"deleteConfirmDesc": "確定要刪除 {name} 嗎?這將移除所有配置的模型且無法復原。",
|
||||
"typeToConfirm": "輸入「{name}」以確認",
|
||||
"typeProviderName": "輸入提供商名稱...",
|
||||
"modelsConfiguredCount": "已配置 {count} 個模型",
|
||||
"validationFailedCount": "{count} 個模型驗證失敗",
|
||||
"requestFailed": "請求失敗({status})",
|
||||
"cancel": "取消",
|
||||
"delete": "刪除",
|
||||
"clickToChange": "(點擊變更)",
|
||||
@@ -430,7 +417,9 @@
|
||||
"unvalidatedModelWarning": "此模型尚未驗證",
|
||||
"serverDefaultModel": "伺服器預設模型",
|
||||
"showValue": "顯示值",
|
||||
"hideValue": "隱藏值"
|
||||
"hideValue": "隱藏值",
|
||||
"noProvidersTitle": "正在使用伺服器上設定的模型",
|
||||
"noProvidersHint": "想用其他模型,可以在左側清單下方新增服務商並填寫自己的 API Key。"
|
||||
},
|
||||
"admin": {
|
||||
"title": "管理員設定",
|
||||
@@ -575,5 +564,106 @@
|
||||
"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": "未命名圖紙",
|
||||
"mobileCanvas": "畫布",
|
||||
"mobileChat": "對話"
|
||||
},
|
||||
"selection": {
|
||||
"one": "已選取:{label}",
|
||||
"many": "已選取 {count} 個圖形",
|
||||
"shape": "圖形",
|
||||
"connector": "連線",
|
||||
"remove": "不傳送選取的圖形",
|
||||
"placeholder": "說說要怎麼改選取的圖形"
|
||||
},
|
||||
"versions": {
|
||||
"strip": "版本",
|
||||
"created": "畫好了圖表",
|
||||
"edited": "改好了圖表",
|
||||
"undone": "這次修改已復原",
|
||||
"undo": "復原修改",
|
||||
"redo": "重做修改",
|
||||
"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": "接著編輯已有的圖"
|
||||
}
|
||||
}
|
||||
|
||||
+191
-101
@@ -3,22 +3,19 @@
|
||||
"save": "保存",
|
||||
"cancel": "取消",
|
||||
"close": "关闭",
|
||||
"confirm": "确认",
|
||||
"clear": "清除",
|
||||
"edit": "编辑",
|
||||
"delete": "删除",
|
||||
"loading": "加载中...",
|
||||
"new": "新建"
|
||||
"loading": "加载中..."
|
||||
},
|
||||
"nav": {
|
||||
"about": "关于",
|
||||
"editor": "编辑器",
|
||||
"newChat": "开始新对话",
|
||||
"newChat": "新建图纸",
|
||||
"github": "GitHub",
|
||||
"settings": "设置",
|
||||
"hidePanel": "隐藏聊天面板 (Ctrl+B)",
|
||||
"showPanel": "显示聊天面板 (Ctrl+B)",
|
||||
"aiChat": "AI 聊天"
|
||||
"hidePanel": "隐藏聊天面板",
|
||||
"showPanel": "显示聊天面板",
|
||||
"aiChat": "AI 对话"
|
||||
},
|
||||
"providers": {
|
||||
"useServerDefault": "使用服务器默认值",
|
||||
@@ -39,12 +36,11 @@
|
||||
},
|
||||
"chat": {
|
||||
"placeholder": "描述您的图表或上传文件...",
|
||||
"inputLabel": "聊天输入框",
|
||||
"send": "发送",
|
||||
"stopGeneration": "停止生成",
|
||||
"sendMessage": "发送消息",
|
||||
"clearConversation": "清除对话",
|
||||
"diagramHistory": "图表历史",
|
||||
"saveDiagram": "保存图表",
|
||||
"uploadFile": "上传文件(图片、PDF、文本)",
|
||||
"minimalStyle": "简约",
|
||||
"styledMode": "精致",
|
||||
@@ -52,38 +48,37 @@
|
||||
"regenerate": "重新生成响应",
|
||||
"copyResponse": "复制响应",
|
||||
"copied": "已复制!",
|
||||
"failedToCopy": "复制失败",
|
||||
"failedToCopyDetail": "复制消息失败。请手动复制或检查剪贴板权限。",
|
||||
"goodResponse": "有帮助",
|
||||
"badResponse": "无帮助",
|
||||
"clickToEdit": "点击编辑",
|
||||
"editMessage": "编辑消息",
|
||||
"saveAndSubmit": "保存并提交",
|
||||
"ExtractURL": "从 URL 提取"
|
||||
"ExtractURL": "从 URL 提取",
|
||||
"attach": "添加",
|
||||
"placeholderFollowUp": "说说想怎么改,也可以先选中要改的图形",
|
||||
"emptyThread": "可以直接在画布上画,也可以在下面说说要画什么。",
|
||||
"attachedImage": "附带的图片",
|
||||
"retry": "重试"
|
||||
},
|
||||
"examples": {
|
||||
"title": "用 AI 创建图表",
|
||||
"subtitle": "描述您想要创建的内容或上传图片进行复制",
|
||||
"quickExamples": "快速示例",
|
||||
"paperToDiagram": "文档转图表",
|
||||
"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 中使用"
|
||||
},
|
||||
"settings": {
|
||||
"title": "设置",
|
||||
"description": "配置您的应用程序设置。",
|
||||
"apiKeysModels": "API 密钥和模型",
|
||||
"apiKeysModelsDescription": "配置 AI 提供商和 API 密钥。",
|
||||
"accessCode": "访问码",
|
||||
"accessCodePlaceholder": "输入访问码",
|
||||
"accessCodeDescription": "使用此应用程序需要访问码。",
|
||||
@@ -101,16 +96,9 @@
|
||||
"language": "语言",
|
||||
"languageDescription": "选择界面语言。",
|
||||
"theme": "主题",
|
||||
"themeDescription": "界面和 DrawIO 画布的深色/浅色模式。",
|
||||
"drawioStyle": "DrawIO 样式",
|
||||
"drawioStyleDescription": "画布样式",
|
||||
"themeDefault": "默认",
|
||||
"themeDark": "深色",
|
||||
"themeMinimal": "简约",
|
||||
"themeSketch": "草图",
|
||||
"themeSimple": "简单",
|
||||
"diagramStyle": "图表样式",
|
||||
"diagramStyleDescription": "切换简约与精致图表输出模式。",
|
||||
"themeDescription": "界面用浅色还是深色。图纸始终是白底,和导出后的样子一致。",
|
||||
"diagramStyle": "图形风格",
|
||||
"diagramStyleDescription": "AI 画新图时的风格:精致会用颜色和图标,简约只用朴素的图形。",
|
||||
"sendShortcut": "发送快捷键",
|
||||
"sendShortcutDescription": "选择发送消息的方式。",
|
||||
"enterToSend": "回车发送",
|
||||
@@ -125,46 +113,47 @@
|
||||
"httpsProxy": "HTTPS 代理",
|
||||
"applyProxy": "应用",
|
||||
"proxyApplied": "代理设置已应用",
|
||||
"diagramValidation": "图表验证(实验性)",
|
||||
"diagramValidationDescription": "使用视觉语言模型验证生成的图表。需要支持视觉的模型,如 GPT-5.2 或 Sonnet-4.5。",
|
||||
"enabled": "已启用",
|
||||
"disabled": "已禁用",
|
||||
"customSystemMessage": "自定义系统消息",
|
||||
"diagramValidation": "用截图检查图表(实验性)",
|
||||
"diagramValidationDescription": "每次画完后,让能看懂图片的模型检查截图并要求修正。需要能读图片的模型。",
|
||||
"customSystemMessage": "自定义指令",
|
||||
"customSystemMessageDescription": "添加自定义指令,将附加到 AI 的系统提示末尾。",
|
||||
"customSystemMessagePlaceholder": "例如:图表始终使用蓝色配色方案...",
|
||||
"maxOutputTokens": "最大输出 token 数",
|
||||
"maxOutputTokensDescription": "单次回复的额度,思考过程和图表 XML 共用。如果 AI 一直在思考却没有生成图表,请把它调大。留空则使用默认值。",
|
||||
"panelVisibility": "大厅面板",
|
||||
"panelVisibilityDescription": "选择在聊天大厅显示哪些面板。",
|
||||
"showRecentChats": "最近聊天",
|
||||
"showMyTemplates": "我的模板",
|
||||
"showQuickExamples": "快速示例"
|
||||
"tabModels": "模型和密钥",
|
||||
"tabAppearance": "外观",
|
||||
"tabAdvanced": "高级",
|
||||
"tabAbout": "关于",
|
||||
"themeLight": "浅色",
|
||||
"themeDarkMode": "深色",
|
||||
"themeSystem": "跟随系统",
|
||||
"accessCodeSaved": "访问码已保存",
|
||||
"proxyInvalid": "代理地址必须以 http:// 或 https:// 开头",
|
||||
"proxyFailed": "代理设置没有生效",
|
||||
"appVersion": "版本",
|
||||
"projectWebsite": "项目网站"
|
||||
},
|
||||
"save": {
|
||||
"title": "保存图表",
|
||||
"description": "选择格式和文件名以保存您的图表。",
|
||||
"title": "导出图纸",
|
||||
"description": "选择格式和文件名。",
|
||||
"format": "格式",
|
||||
"filename": "文件名",
|
||||
"filenamePlaceholder": "输入文件名",
|
||||
"formats": {
|
||||
"drawio": "Draw.io XML",
|
||||
"drawio": "draw.io 文件",
|
||||
"png": "PNG 图片",
|
||||
"svg": "SVG 图片",
|
||||
"xmlsvg": "可编辑 SVG"
|
||||
"xmlsvg": "可编辑的 SVG"
|
||||
},
|
||||
"savedSuccessfully": "保存成功!"
|
||||
},
|
||||
"history": {
|
||||
"title": "图表历史",
|
||||
"description": "在 AI 修改之前保存的每个图表。\n点击图表以恢复它",
|
||||
"noHistory": "尚无历史记录。发送消息以创建图表历史。",
|
||||
"version": "版本",
|
||||
"restoreTo": "恢复到版本 {version}?"
|
||||
"savedSuccessfully": "已导出",
|
||||
"hints": {
|
||||
"drawio": "所有内容都能继续编辑",
|
||||
"png": "用于幻灯片和文档的图片",
|
||||
"svg": "放大也不会模糊的图片",
|
||||
"xmlsvg": "图片,也能在 draw.io 里打开编辑"
|
||||
}
|
||||
},
|
||||
"dialogs": {
|
||||
"clearTitle": "清除所有内容?",
|
||||
"clearDescription": "这将清除当前对话并重置图表。此操作无法撤消。",
|
||||
"clearEverything": "清除所有内容",
|
||||
"clearSuccess": "已开始新对话"
|
||||
},
|
||||
"errors": {
|
||||
@@ -189,6 +178,9 @@
|
||||
"sessionSaveFailed": "无法保存这个对话。浏览器存储空间可能已满,请在历史记录里删除旧对话后重试。",
|
||||
"sessionSaveFailedLeave": "无法保存这个对话,浏览器存储空间可能已满。可以不保存它、直接继续,再在新对话的列表里删除旧对话。",
|
||||
"continueWithoutSaving": "不保存,继续",
|
||||
"notADiagramFile": "{name} 不是 draw.io 图表文件。",
|
||||
"fileTooLong": "{name}:内容超过了 {limit}k 字符的上限({size}k 字符)",
|
||||
"fileReadFailed": "无法读取文件:{name}",
|
||||
"llm": {
|
||||
"invalid_api_key": "服务商拒绝了这个 API Key,请在模型设置里检查。",
|
||||
"forbidden": "服务商拒绝了这次请求。这个 Key 可能没有使用该模型或该地区的权限。",
|
||||
@@ -227,11 +219,6 @@
|
||||
"usedOf": "{used}/{limit}"
|
||||
},
|
||||
"tools": {
|
||||
"generateDiagram": "生成图表",
|
||||
"editDiagram": "编辑图表",
|
||||
"appendDiagram": "继续图表",
|
||||
"complete": "完成",
|
||||
"error": "错误",
|
||||
"truncated": "已截断"
|
||||
},
|
||||
"file": {
|
||||
@@ -246,7 +233,8 @@
|
||||
"extract": "提取",
|
||||
"Cancel": "取消",
|
||||
"enterUrl": "请输入 URL",
|
||||
"invalidFormat": "URL 格式无效"
|
||||
"invalidFormat": "URL 格式无效",
|
||||
"failed": "无法读取这个网页"
|
||||
},
|
||||
"reasoning": {
|
||||
"thinking": "思考中...",
|
||||
@@ -279,30 +267,29 @@
|
||||
"sessionHistory": {
|
||||
"tooltip": "聊天历史",
|
||||
"newChat": "新对话",
|
||||
"empty": "暂无聊天记录",
|
||||
"empty": "还没有图纸",
|
||||
"emptyHint": "开始对话吧",
|
||||
"today": "今天",
|
||||
"yesterday": "昨天",
|
||||
"thisWeek": "本周",
|
||||
"earlier": "更早",
|
||||
"deleteTitle": "删除此对话?",
|
||||
"deleteDescription": "这将永久删除此聊天会话及其图表。此操作无法撤消。",
|
||||
"recentChats": "最近对话",
|
||||
"deleteTitle": "删除这张图纸?",
|
||||
"deleteDescription": "图纸和它的对话会被永久删除。",
|
||||
"justNow": "刚刚",
|
||||
"searchPlaceholder": "搜索对话...",
|
||||
"noResults": "未找到对话"
|
||||
"searchPlaceholder": "搜索图纸…",
|
||||
"noResults": "未找到对话",
|
||||
"rename": "重命名",
|
||||
"busyHint": "回答完成后才能切换"
|
||||
},
|
||||
"templates": {
|
||||
"title": "我的模板库",
|
||||
"subtitle": "您的个人 prompt 库,快速创建图表",
|
||||
"emptyTitle": "暂无模板",
|
||||
"emptyDescription": "创建您的第一个模板,开始构建个人 prompt 库",
|
||||
"createFirst": "创建第一个模板",
|
||||
"emptyTitle": "还没有常用提示词",
|
||||
"emptyDescription": "把经常用的提示词存起来,比如“画一张……的 AWS 架构图”,之后会出现在这里。",
|
||||
"createFirst": "保存第一个提示词",
|
||||
"neverUsed": "未使用过",
|
||||
"usedCount": "使用 {count} 次",
|
||||
"myTemplates": "我的模板",
|
||||
"createTitle": "创建模板",
|
||||
"createDescription": "保存常用 prompt 以便重复使用。模板帮助您快速开始常用工作流。",
|
||||
"myTemplates": "常用提示词",
|
||||
"createTitle": "保存提示词",
|
||||
"createDescription": "把提示词存起来,以后再用。",
|
||||
"promptLabel": "提示词",
|
||||
"promptPlaceholder": "描述您想要创建的图表...",
|
||||
"promptRequired": "提示词不能为空",
|
||||
@@ -310,33 +297,34 @@
|
||||
"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} 个重复"
|
||||
"importSuccess": "已导入 {imported} 个提示词,跳过 {skipped} 个重复",
|
||||
"saveDisabledHint": "先在输入框里写好提示词"
|
||||
},
|
||||
"validation": {
|
||||
"title": "验证图表",
|
||||
@@ -357,8 +345,6 @@
|
||||
"regenerateWithFeedback": "使用验证反馈重新生成图表"
|
||||
},
|
||||
"modelConfig": {
|
||||
"title": "AI 模型配置",
|
||||
"description": "配置多个 AI 提供商和模型",
|
||||
"configure": "配置",
|
||||
"addProvider": "添加提供商",
|
||||
"addModel": "添加模型",
|
||||
@@ -375,7 +361,7 @@
|
||||
"validationError": "验证失败",
|
||||
"addModelFirst": "请先添加至少一个模型以进行验证",
|
||||
"providers": "提供商",
|
||||
"addProviderHint": "添加提供商即可开始使用",
|
||||
"addProviderHint": "还没有服务商",
|
||||
"verified": "已验证",
|
||||
"configuration": "配置",
|
||||
"displayName": "显示名称",
|
||||
@@ -404,13 +390,14 @@
|
||||
"noModelsConfigured": "尚未配置模型",
|
||||
"modelIdEmpty": "模型 ID 不能为空",
|
||||
"modelIdExists": "此模型 ID 已存在",
|
||||
"configureProviders": "配置 AI 提供商",
|
||||
"configureProviders": "设置 AI 服务商",
|
||||
"selectProviderHint": "从列表中选择提供商或添加新的以配置 API 密钥和模型",
|
||||
"deleteConfirmDesc": "确定要删除 {name} 吗?这将移除所有配置的模型且无法撤销。",
|
||||
"typeToConfirm": "输入 \"{name}\" 以确认",
|
||||
"typeProviderName": "输入提供商名称...",
|
||||
"modelsConfiguredCount": "已配置 {count} 个模型",
|
||||
"validationFailedCount": "{count} 个模型验证失败",
|
||||
"requestFailed": "请求失败({status})",
|
||||
"cancel": "取消",
|
||||
"delete": "删除",
|
||||
"clickToChange": "(点击更改)",
|
||||
@@ -430,7 +417,9 @@
|
||||
"unvalidatedModelWarning": "此模型尚未验证",
|
||||
"serverDefaultModel": "服务器默认模型",
|
||||
"showValue": "显示值",
|
||||
"hideValue": "隐藏值"
|
||||
"hideValue": "隐藏值",
|
||||
"noProvidersTitle": "正在使用服务器上配置的模型",
|
||||
"noProvidersHint": "想用其他模型,可以在左侧列表下方添加服务商并填写自己的 API Key。"
|
||||
},
|
||||
"admin": {
|
||||
"title": "管理员设置",
|
||||
@@ -575,5 +564,106 @@
|
||||
"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": "未命名图纸",
|
||||
"mobileCanvas": "画布",
|
||||
"mobileChat": "对话"
|
||||
},
|
||||
"selection": {
|
||||
"one": "已选:{label}",
|
||||
"many": "已选 {count} 个图形",
|
||||
"shape": "图形",
|
||||
"connector": "连线",
|
||||
"remove": "不发送选中的图形",
|
||||
"placeholder": "说说要怎么改选中的图形"
|
||||
},
|
||||
"versions": {
|
||||
"strip": "版本",
|
||||
"created": "画好了图表",
|
||||
"edited": "改好了图表",
|
||||
"undone": "这次修改已撤销",
|
||||
"undo": "撤销修改",
|
||||
"redo": "重做修改",
|
||||
"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": "接着编辑已有的图"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
/** 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+"
|
||||
@@ -3,6 +3,7 @@ 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"
|
||||
@@ -200,8 +201,11 @@ export async function listProviderModels(
|
||||
break
|
||||
}
|
||||
case "ollama": {
|
||||
const api = base.endsWith("/api") ? base : `${base}/api`
|
||||
const data = await getJson(`${api}/tags`, bearer, fetchFn)
|
||||
const data = await getJson(
|
||||
`${ollamaApiUrl(base)}/tags`,
|
||||
bearer,
|
||||
fetchFn,
|
||||
)
|
||||
models = (data.models ?? []).map((m: { name: string }) => ({
|
||||
id: m.name,
|
||||
}))
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
/** "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",
|
||||
})
|
||||
}
|
||||
+18
-1
@@ -1,6 +1,7 @@
|
||||
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
|
||||
@@ -20,13 +21,18 @@ export interface ChatSession {
|
||||
xmlSnapshots: [number, string][]
|
||||
diagramXml: string
|
||||
thumbnailDataUrl?: string // Small PNG preview of the diagram
|
||||
diagramHistory?: { svg: string; xml: string }[] // Version history of diagram edits
|
||||
// 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[]
|
||||
}
|
||||
|
||||
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 {
|
||||
@@ -241,6 +247,16 @@ 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 {
|
||||
@@ -300,6 +316,7 @@ 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 }),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+11
-20
@@ -1,26 +1,19 @@
|
||||
// Centralized localStorage keys for quota tracking and settings
|
||||
// Chat data is now stored in IndexedDB via session-storage.ts
|
||||
// Centralized localStorage keys for settings.
|
||||
// Chat data is stored in IndexedDB via session-storage.ts
|
||||
|
||||
export const STORAGE_KEYS = {
|
||||
// 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
|
||||
// Access code for password-protected deployments
|
||||
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",
|
||||
selectedModelId: "next-ai-draw-io-selected-model-id",
|
||||
|
||||
// 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",
|
||||
|
||||
// Chat input preferences
|
||||
sendShortcut: "next-ai-draw-io-send-shortcut",
|
||||
@@ -34,8 +27,6 @@ export const STORAGE_KEYS = {
|
||||
// Output token budget per turn (empty = server default)
|
||||
maxOutputTokens: "next-ai-draw-io-max-output-tokens",
|
||||
|
||||
// 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",
|
||||
// Langfuse session id
|
||||
sessionId: "next-ai-draw-io-session-id",
|
||||
} as const
|
||||
|
||||
@@ -622,6 +622,15 @@ 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,
|
||||
@@ -630,6 +639,7 @@ 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[] = [
|
||||
@@ -637,7 +647,6 @@ export function chatRequestUrl(
|
||||
"vertexai",
|
||||
"azure",
|
||||
"bedrock",
|
||||
"ollama",
|
||||
"gateway",
|
||||
"minimax",
|
||||
"edgeone",
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
import { useState } from "react"
|
||||
import { toast } from "sonner"
|
||||
import { formatMessage } from "@/lib/i18n/utils"
|
||||
import {
|
||||
extractPdfText,
|
||||
extractTextFileContent,
|
||||
@@ -16,11 +17,24 @@ 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() {
|
||||
export function useFileProcessor(
|
||||
messages: FileProcessorMessages = DEFAULT_MESSAGES,
|
||||
) {
|
||||
const [files, setFiles] = useState<File[]>([])
|
||||
const [pdfData, setPdfData] = useState<Map<File, FileData>>(new Map())
|
||||
|
||||
@@ -58,9 +72,12 @@ export function useFileProcessor() {
|
||||
|
||||
// Check character limit
|
||||
if (text.length > MAX_EXTRACTED_CHARS) {
|
||||
const limitK = MAX_EXTRACTED_CHARS / 1000
|
||||
toast.error(
|
||||
`${file.name}: Content exceeds ${limitK}k character limit (${(text.length / 1000).toFixed(1)}k chars)`,
|
||||
formatMessage(messages.tooLong, {
|
||||
name: file.name,
|
||||
limit: MAX_EXTRACTED_CHARS / 1000,
|
||||
size: (text.length / 1000).toFixed(1),
|
||||
}),
|
||||
)
|
||||
setPdfData((prev) => {
|
||||
const next = new Map(prev)
|
||||
@@ -85,7 +102,9 @@ export function useFileProcessor() {
|
||||
})
|
||||
} catch (error) {
|
||||
console.error("Failed to extract text:", error)
|
||||
toast.error(`Failed to read file: ${file.name}`)
|
||||
toast.error(
|
||||
formatMessage(messages.readFailed, { name: file.name }),
|
||||
)
|
||||
setPdfData((prev) => {
|
||||
const next = new Map(prev)
|
||||
next.delete(file)
|
||||
|
||||
+11
-2
@@ -2,10 +2,19 @@
|
||||
* 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 forwardedFor = req.headers.get("x-forwarded-for")
|
||||
const rawIp = forwardedFor?.split(",")[0]?.trim() || "anonymous"
|
||||
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"
|
||||
return rawIp === "anonymous"
|
||||
? rawIp
|
||||
: `user-${Buffer.from(rawIp).toString("base64url")}`
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
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)
|
||||
}
|
||||
@@ -4,6 +4,9 @@ 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 || "",
|
||||
|
||||
Generated
+846
-1210
File diff suppressed because it is too large
Load Diff
+4
-3
@@ -5,7 +5,9 @@
|
||||
"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 .",
|
||||
@@ -82,10 +84,8 @@
|
||||
"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,7 +94,8 @@
|
||||
"tailwindcss-animate": "^1.0.7",
|
||||
"turndown": "^7.2.0",
|
||||
"unpdf": "^1.4.0",
|
||||
"zod": "^4.1.12"
|
||||
"zod": "^4.1.12",
|
||||
"zustand": "^5.0.15"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@tailwindcss/oxide-linux-x64-gnu": "^4.1.18",
|
||||
|
||||
Generated
+102
-70
@@ -507,12 +507,12 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@hono/node-server": {
|
||||
"version": "1.19.9",
|
||||
"resolved": "https://registry.npmjs.org/@hono/node-server/-/node-server-1.19.9.tgz",
|
||||
"integrity": "sha512-vHL6w3ecZsky+8P5MD+eFfaGTyCeOHUIFYMGpQGbrBTSmNNoxv0if69rEZ5giu36weC5saFuznL411gRX7bJDw==",
|
||||
"version": "2.1.3",
|
||||
"resolved": "https://registry.npmjs.org/@hono/node-server/-/node-server-2.1.3.tgz",
|
||||
"integrity": "sha512-TA//nWMqPhbfdfneACk6t5a9eqbS9lABEPyKn0/xZTah3H3U2XaVg85rJFl0/Fyit0I552YDHgXGVSf3GwqbUw==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=18.14.1"
|
||||
"node": ">=20"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"hono": "^4"
|
||||
@@ -1045,9 +1045,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/ajv": {
|
||||
"version": "8.17.1",
|
||||
"resolved": "https://registry.npmjs.org/ajv/-/ajv-8.17.1.tgz",
|
||||
"integrity": "sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g==",
|
||||
"version": "8.20.0",
|
||||
"resolved": "https://registry.npmjs.org/ajv/-/ajv-8.20.0.tgz",
|
||||
"integrity": "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"fast-deep-equal": "^3.1.3",
|
||||
@@ -1088,20 +1088,20 @@
|
||||
}
|
||||
},
|
||||
"node_modules/body-parser": {
|
||||
"version": "2.2.1",
|
||||
"resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.2.1.tgz",
|
||||
"integrity": "sha512-nfDwkulwiZYQIGwxdy0RUmowMhKcFVcYXUU7m4QlKYim1rUtg83xm2yjZ40QjDuc291AJjjeSc9b++AWHSgSHw==",
|
||||
"version": "2.3.0",
|
||||
"resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.3.0.tgz",
|
||||
"integrity": "sha512-2cGmJupaNgg+QUwVLAucDuWuoMZ6EX9iHDRswZ5lsNYEmwPaRknMPCLZz07yTzVq/83p4o/wzbDZbBrTvGGTIw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"bytes": "^3.1.2",
|
||||
"content-type": "^1.0.5",
|
||||
"content-type": "^2.0.0",
|
||||
"debug": "^4.4.3",
|
||||
"http-errors": "^2.0.0",
|
||||
"iconv-lite": "^0.7.0",
|
||||
"http-errors": "^2.0.1",
|
||||
"iconv-lite": "^0.7.2",
|
||||
"on-finished": "^2.4.1",
|
||||
"qs": "^6.14.0",
|
||||
"raw-body": "^3.0.1",
|
||||
"type-is": "^2.0.1"
|
||||
"qs": "^6.15.2",
|
||||
"raw-body": "^3.0.2",
|
||||
"type-is": "^2.1.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
@@ -1111,6 +1111,19 @@
|
||||
"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",
|
||||
@@ -1577,9 +1590,9 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/es-object-atoms": {
|
||||
"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==",
|
||||
"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==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"es-errors": "^1.3.0"
|
||||
@@ -1730,12 +1743,13 @@
|
||||
}
|
||||
},
|
||||
"node_modules/express-rate-limit": {
|
||||
"version": "8.2.1",
|
||||
"resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-8.2.1.tgz",
|
||||
"integrity": "sha512-PCZEIEIxqwhzw4KF0n7QF4QqruVTcF73O5kFKUnGOyjbCCgizBBiFaYpd/fnBLUMPw/BWw9OsiN7GgrNYr7j6g==",
|
||||
"version": "8.7.0",
|
||||
"resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-8.7.0.tgz",
|
||||
"integrity": "sha512-hOwV7WOxXfjRpAM1DSJWZDXx3GhplwD8IfwuwvogD8i1Qnkgosw/H45s4ZnFAUHDAhPjlY9hLBvJhKmGMyY26g==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"ip-address": "10.0.1"
|
||||
"debug": "^4.4.3",
|
||||
"ip-address": "^10.2.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 16"
|
||||
@@ -1754,9 +1768,9 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/fast-uri": {
|
||||
"version": "3.1.0",
|
||||
"resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.0.tgz",
|
||||
"integrity": "sha512-iPeeDKJSWf4IEOasVVrknXpaBV0IApz/gp7S2bb7Z4Lljbl2MGJRqInZiUrQwV16cpzw/D3S5j5Julj/gT52AA==",
|
||||
"version": "3.1.8",
|
||||
"resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.8.tgz",
|
||||
"integrity": "sha512-GZMtZUTNRpOVIECoXwLNZS5xUGE+mVNbTB8h/7Rwh2TFWcBQiPzTgyZi05BF9UMZKkLJv8XBRJTlU7zg8+ZfMg==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
@@ -1912,9 +1926,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/hasown": {
|
||||
"version": "2.0.2",
|
||||
"resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz",
|
||||
"integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==",
|
||||
"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==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"function-bind": "^1.1.2"
|
||||
@@ -1924,9 +1938,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/hono": {
|
||||
"version": "4.11.9",
|
||||
"resolved": "https://registry.npmjs.org/hono/-/hono-4.11.9.tgz",
|
||||
"integrity": "sha512-Eaw2YTGM6WOxA6CXbckaEvslr2Ne4NFsKrvc0v97JD5awbmeBLO5w9Ho9L9kmKonrwF9RJlW6BxT1PVv/agBHQ==",
|
||||
"version": "4.13.13",
|
||||
"resolved": "https://registry.npmjs.org/hono/-/hono-4.13.13.tgz",
|
||||
"integrity": "sha512-CQ46U0ZkAGmbT/4UxdzzGJpacP2IeKgY4a5/tOI9AABbpOMfK739wfDXmv1usCk+3RkKj1hQy4/fjhiwa2xlrA==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=16.9.0"
|
||||
@@ -1990,9 +2004,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/iconv-lite": {
|
||||
"version": "0.7.1",
|
||||
"resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.1.tgz",
|
||||
"integrity": "sha512-2Tth85cXwGFHfvRgZWszZSvdo+0Xsqmw8k8ZwxScfcBneNUraK+dxRxRm24nszx80Y0TVio8kKLt5sLE7ZCLlw==",
|
||||
"version": "0.7.3",
|
||||
"resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.3.tgz",
|
||||
"integrity": "sha512-IKXpvIzjnC9XTAUbVBcMfGS0EPaIXtW6v+zr+RRp+hqULEpo0owZax6wyRwPOJbWbzjYspQwusTsfVr0ifh4uQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"safer-buffer": ">= 2.1.2 < 3.0.0"
|
||||
@@ -2012,9 +2026,9 @@
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/ip-address": {
|
||||
"version": "10.0.1",
|
||||
"resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.0.1.tgz",
|
||||
"integrity": "sha512-NWv9YLW4PoW2B7xtzaS3NCot75m6nK7Icdv0o3lfMceJVRfSoQwqD4wEH5rLwoKJwUiZ/rfpiVBhnaF0FK4HoA==",
|
||||
"version": "10.7.3",
|
||||
"resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.7.3.tgz",
|
||||
"integrity": "sha512-A1kdq/tSb5QjvKvAMgIoEvDBIgL7qaqVP/jkvSwYYRZ9iEzvPpopxp2wQfu3SuZRHtpHNxMn8Fs0bS+gf5Xmwg==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 12"
|
||||
@@ -2479,9 +2493,9 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/nanoid": {
|
||||
"version": "3.3.12",
|
||||
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.12.tgz",
|
||||
"integrity": "sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ==",
|
||||
"version": "3.3.20",
|
||||
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.20.tgz",
|
||||
"integrity": "sha512-uKdg2G3GNCKQn9byYOpxbGqrT2fGO5KRt5J/8b3pok8rT6qxGWF6hxMyJiEYtAf+FVyYuD9hRaDqX5uPFYJ4ZQ==",
|
||||
"dev": true,
|
||||
"funding": [
|
||||
{
|
||||
@@ -2645,9 +2659,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/path-to-regexp": {
|
||||
"version": "8.3.0",
|
||||
"resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-8.3.0.tgz",
|
||||
"integrity": "sha512-7jdwVIRtsP8MYpdXSwOS0YdD0Du+qOoF/AEPIt88PcCFrZCzx41oxku1jD88hZBwbNUIEfpqvuhjFaMAqMTWnA==",
|
||||
"version": "8.4.2",
|
||||
"resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-8.4.2.tgz",
|
||||
"integrity": "sha512-qRcuIdP69NPm4qbACK+aDogI5CBDMi1jKe0ry5rSQJz8JVLsC7jV8XpiJjGRLLol3N+R5ihGYcrPLTno6pAdBA==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
@@ -2691,9 +2705,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/postcss": {
|
||||
"version": "8.5.15",
|
||||
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.15.tgz",
|
||||
"integrity": "sha512-FfR8sjd4em2T6fb3I2MwAJU7HWVMr9zba+enmQeeWFfCbm+UOC/0X4DS8XtpUTMwWMGbjKYP7xjfNekzyGmB3A==",
|
||||
"version": "8.5.29",
|
||||
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.29.tgz",
|
||||
"integrity": "sha512-49cGhUbXj8Qenv0iTMxA1cFBzxXoctpC9Ujd77t1WcbJIr6nF/eI7g/8MgxrYldFRuAXvja7xQRwavoW7kgrxQ==",
|
||||
"dev": true,
|
||||
"funding": [
|
||||
{
|
||||
@@ -2711,9 +2725,9 @@
|
||||
],
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"nanoid": "^3.3.12",
|
||||
"nanoid": "^3.3.19",
|
||||
"picocolors": "^1.1.1",
|
||||
"source-map-js": "^1.2.1"
|
||||
"source-map-js": "^1.2.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^10 || ^12 || >=14"
|
||||
@@ -2745,12 +2759,13 @@
|
||||
}
|
||||
},
|
||||
"node_modules/qs": {
|
||||
"version": "6.14.0",
|
||||
"resolved": "https://registry.npmjs.org/qs/-/qs-6.14.0.tgz",
|
||||
"integrity": "sha512-YWWTjgABSKcvs/nWBi9PycY/JiPJqOD4JA6o9Sej2AtvSGarXxKC3OQSk4pAarbdQlKAh5D4FCQkJNkW+GAn3w==",
|
||||
"version": "6.16.0",
|
||||
"resolved": "https://registry.npmjs.org/qs/-/qs-6.16.0.tgz",
|
||||
"integrity": "sha512-h6fhOIaRrID2CbEY2fqs+7t+UXZo+MLAnU5gRIq85uFtdiUPCdsApMlHhXogKVM4HM2DVbIjGNTTYH2OcmP1vA==",
|
||||
"license": "BSD-3-Clause",
|
||||
"dependencies": {
|
||||
"side-channel": "^1.1.0"
|
||||
"es-define-property": "^1.0.1",
|
||||
"side-channel": "^1.1.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=0.6"
|
||||
@@ -2945,14 +2960,14 @@
|
||||
}
|
||||
},
|
||||
"node_modules/side-channel": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz",
|
||||
"integrity": "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==",
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.1.tgz",
|
||||
"integrity": "sha512-6x6dK6zJdpTzF4sQeNYxwtvBzf6Eg4GtlesS94HOvTudUeyK2WXAaIfmDgsyslYrRBeFIlsi54AYsFGUuhmvrQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"es-errors": "^1.3.0",
|
||||
"object-inspect": "^1.13.3",
|
||||
"side-channel-list": "^1.0.0",
|
||||
"object-inspect": "^1.13.4",
|
||||
"side-channel-list": "^1.0.1",
|
||||
"side-channel-map": "^1.0.1",
|
||||
"side-channel-weakmap": "^1.0.2"
|
||||
},
|
||||
@@ -2964,13 +2979,13 @@
|
||||
}
|
||||
},
|
||||
"node_modules/side-channel-list": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.0.tgz",
|
||||
"integrity": "sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA==",
|
||||
"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==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"es-errors": "^1.3.0",
|
||||
"object-inspect": "^1.13.3"
|
||||
"object-inspect": "^1.13.4"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
@@ -3024,9 +3039,9 @@
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/source-map-js": {
|
||||
"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==",
|
||||
"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==",
|
||||
"dev": true,
|
||||
"license": "BSD-3-Clause",
|
||||
"engines": {
|
||||
@@ -3137,17 +3152,34 @@
|
||||
}
|
||||
},
|
||||
"node_modules/type-is": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/type-is/-/type-is-2.0.1.tgz",
|
||||
"integrity": "sha512-OZs6gsjF4vMp32qrCbiVSkrFmXtG/AZhY3t0iAMrMBiAZyV9oALtXO8hsrHbMXF9x6L3grlFuwW2oAz7cav+Gw==",
|
||||
"version": "2.1.0",
|
||||
"resolved": "https://registry.npmjs.org/type-is/-/type-is-2.1.0.tgz",
|
||||
"integrity": "sha512-faYHw0anBbc/kWF3zFTEnxSFOAGUX9GFbOBthvDdLsIlEoWOFOtS0zgCiQYwIskL9iGXZL3kAXD8OoZ4GmMATA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"content-type": "^1.0.5",
|
||||
"content-type": "^2.0.0",
|
||||
"media-typer": "^1.1.0",
|
||||
"mime-types": "^3.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.6"
|
||||
"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_modules/typescript": {
|
||||
|
||||
@@ -27,9 +27,19 @@ function getLocale(request: NextRequest): string | undefined {
|
||||
export function proxy(request: NextRequest) {
|
||||
const pathname = request.nextUrl.pathname
|
||||
|
||||
// Skip API routes, static files, and Next.js internals
|
||||
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
|
||||
if (
|
||||
pathname.startsWith("/api/") ||
|
||||
pathname.startsWith("/_next/") ||
|
||||
pathname.startsWith("/drawio") ||
|
||||
pathname.includes("/favicon") ||
|
||||
@@ -58,6 +68,9 @@ export function proxy(request: NextRequest) {
|
||||
}
|
||||
|
||||
export const config = {
|
||||
// Matcher ignoring `/_next/` and `/api/`
|
||||
matcher: ["/((?!api|_next/static|_next/image|favicon.ico).*)"],
|
||||
// API routes (for ORIGIN_SECRET), and pages without `/_next/` assets
|
||||
matcher: [
|
||||
"/api/:path*",
|
||||
"/((?!api|_next/static|_next/image|favicon.ico).*)",
|
||||
],
|
||||
}
|
||||
|
||||
@@ -0,0 +1,148 @@
|
||||
#!/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 both deliberately
|
||||
const DRAWIO_VERSION = "v32.0.2"
|
||||
// SHA-256 of that release's draw.war, as GitHub lists it
|
||||
const DRAWIO_SHA256 =
|
||||
"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 (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 actual = crypto.createHash("sha256").update(buf).digest("hex")
|
||||
if (actual !== DRAWIO_SHA256) {
|
||||
throw new Error(`draw.war checksum mismatch (got ${actual})`)
|
||||
}
|
||||
} 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)
|
||||
})
|
||||
@@ -0,0 +1,54 @@
|
||||
import { create } from "zustand"
|
||||
|
||||
export interface SelectedCell {
|
||||
id: string
|
||||
label: string
|
||||
isEdge: boolean
|
||||
}
|
||||
|
||||
/** Rectangle relative to the draw.io iframe's top-left corner */
|
||||
interface CanvasRect {
|
||||
x: number
|
||||
y: number
|
||||
width: number
|
||||
height: number
|
||||
}
|
||||
|
||||
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),
|
||||
}))
|
||||
@@ -0,0 +1,159 @@
|
||||
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){}})()`
|
||||
@@ -0,0 +1,62 @@
|
||||
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
|
||||
/** The start screen was put away (drawing by hand, an opened file) */
|
||||
heroDismissed: 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
|
||||
saveDialogOpen: boolean
|
||||
/** Bumped to ask the composer to take focus */
|
||||
focusComposerToken: number
|
||||
setPanelOpen: (open: boolean) => void
|
||||
setHeroDismissed: (dismissed: 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
|
||||
setSaveDialogOpen: (open: boolean) => void
|
||||
focusComposer: () => void
|
||||
}
|
||||
|
||||
export const useUiStore = create<UiState>((set) => ({
|
||||
panelOpen: true,
|
||||
heroDismissed: false,
|
||||
mobileView: "chat",
|
||||
settingsOpen: false,
|
||||
settingsTab: "models",
|
||||
compareVersionId: null,
|
||||
saveDialogOpen: false,
|
||||
focusComposerToken: 0,
|
||||
setPanelOpen: (open) => set({ panelOpen: open }),
|
||||
setHeroDismissed: (dismissed) => set({ heroDismissed: dismissed }),
|
||||
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 }),
|
||||
setSaveDialogOpen: (open) => set({ saveDialogOpen: open }),
|
||||
focusComposer: () =>
|
||||
set((state) => ({
|
||||
panelOpen: true,
|
||||
mobileView: "chat",
|
||||
focusComposerToken: state.focusComposerToken + 1,
|
||||
})),
|
||||
}))
|
||||
@@ -0,0 +1,145 @@
|
||||
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
|
||||
/** 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">) => 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) =>
|
||||
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,
|
||||
onCanvasVersionId: versions.find((v) => v.onCanvas)?.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 }].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,
|
||||
turnIndex: -1,
|
||||
summary: { ...EMPTY_SUMMARY },
|
||||
}))
|
||||
}
|
||||
@@ -32,9 +32,10 @@ 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()
|
||||
await expect(
|
||||
page.getByRole("dialog", { name: "AI Model Configuration" }),
|
||||
).toBeVisible()
|
||||
// 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()
|
||||
})
|
||||
|
||||
test("an error before the stream shows its text, not raw JSON", async ({
|
||||
@@ -98,7 +99,8 @@ test("a refused server key shows only the quota hint and a settings button", asy
|
||||
0,
|
||||
)
|
||||
await page.getByRole("button", { name: "Open model settings" }).click()
|
||||
await expect(
|
||||
page.getByRole("dialog", { name: "AI Model Configuration" }),
|
||||
).toBeVisible()
|
||||
// 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()
|
||||
})
|
||||
|
||||
+8
-11
@@ -71,38 +71,35 @@ 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("opens a chat collapsed on desktop", async ({ page }) => {
|
||||
await page.locator("button:has(svg.lucide-panel-right-close)").click()
|
||||
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()
|
||||
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()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,5 +1,11 @@
|
||||
import zlib from "node:zlib"
|
||||
import { expect, type Page, test } from "@playwright/test"
|
||||
import { getIframe, sendMessage, waitForCompleteCount } from "./lib/fixtures"
|
||||
import {
|
||||
getIframe,
|
||||
openDrawioFile,
|
||||
sendMessage,
|
||||
waitForCompleteCount,
|
||||
} from "./lib/fixtures"
|
||||
|
||||
/**
|
||||
* Checks what draw.io actually shows after the diagram tools, not only the
|
||||
@@ -45,6 +51,15 @@ const cell = (id: string, label: string, x: number) =>
|
||||
const page = (id: string, cells: string) =>
|
||||
`<diagram id="${id}" name="${id}"><mxGraphModel><root><mxCell id="0"/><mxCell id="1" parent="0"/>${cells}</root></mxGraphModel></diagram>`
|
||||
|
||||
// A screenshot check that finds a problem
|
||||
const FAILED_CHECK = {
|
||||
valid: false,
|
||||
issues: [
|
||||
{ type: "overlap", severity: "critical", description: "Boxes overlap" },
|
||||
],
|
||||
suggestions: ["Move them apart"],
|
||||
}
|
||||
|
||||
const sse = (events: object[]) =>
|
||||
events.map((e) => `data: ${JSON.stringify(e)}\n\n`).join("")
|
||||
const EDIT_GAMMA = {
|
||||
@@ -127,7 +142,9 @@ 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 })
|
||||
await expect(canvas.getByText("Second", { exact: true })).toBeVisible()
|
||||
// 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 sendMessage(p, "Start over with three boxes")
|
||||
await waitForCompleteCount(p, 2)
|
||||
@@ -140,7 +157,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(canvas.getByText("Second", { exact: true })).toHaveCount(0)
|
||||
await expect(pageTabs.getByText("Second", { exact: true })).toHaveCount(0)
|
||||
})
|
||||
|
||||
test("an edit with a fixable cell is fixed, not rejected", async ({
|
||||
@@ -221,9 +238,14 @@ 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")
|
||||
await expect(p.getByText(/No changes were made/).first()).toBeAttached({
|
||||
timeout: 15000,
|
||||
})
|
||||
// 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 p.waitForTimeout(1000)
|
||||
await expect(canvas.getByText("Gamma", { exact: true })).toBeVisible()
|
||||
await expect(canvas.getByText("Broken", { exact: true })).toHaveCount(0)
|
||||
@@ -609,9 +631,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.getByText("Get Shape Library").first()).toBeVisible({
|
||||
timeout: 15000,
|
||||
})
|
||||
await expect(
|
||||
p.locator('[data-tool-name="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()
|
||||
@@ -640,10 +662,12 @@ test("a drawing rejected by the checks undoes its preview", async ({
|
||||
await expect(canvas.getByText("Beta", { exact: true })).toBeVisible({
|
||||
timeout: 15000,
|
||||
})
|
||||
// The tool card shows the rejection
|
||||
await expect(p.locator('text="Error"').first()).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 })
|
||||
await p.waitForTimeout(1000)
|
||||
await expect(canvas.getByText("Beta", { exact: true })).toHaveCount(0)
|
||||
await expect(canvas.getByText("Alpha", { exact: true })).toBeVisible()
|
||||
@@ -735,3 +759,345 @@ 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
|
||||
? FAILED_CHECK
|
||||
: { 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])
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
// A display_diagram cut off by the output limit: its last cell is half
|
||||
// written, so 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 cutReply = [
|
||||
sse([{ type: "start" }, CUT.start, ...CUT.deltas]),
|
||||
1500,
|
||||
`${sse([CUT.done, { type: "finish" }])}data: [DONE]\n\n`,
|
||||
]
|
||||
|
||||
test("a cut off drawing stays while it is continued, and goes if that fails", async ({
|
||||
page: p,
|
||||
}) => {
|
||||
const canvas = await chunkedReplies(p, [
|
||||
[drawReply("d1", cell("a", "Alpha", 40))],
|
||||
cutReply,
|
||||
[
|
||||
sse([{ type: "start" }]),
|
||||
...KEEP_OPEN.slice(0, 10),
|
||||
`${sse([{ 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")
|
||||
// The cut off call has its result; the continuation is running
|
||||
await expect(
|
||||
p.locator(
|
||||
'[data-tool-name="display_diagram"][data-tool-state="output-error"]',
|
||||
),
|
||||
).toBeVisible({ timeout: 15000 })
|
||||
await p.waitForTimeout(800)
|
||||
await expect(canvas.getByText("Beta", { exact: true })).toBeVisible()
|
||||
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("a finished continuation is one change, undone back to the diagram before", async ({
|
||||
page: p,
|
||||
}) => {
|
||||
const rest = toolCallEvents("a1", "append_diagram", {
|
||||
xml: `ma" style="rounded=1;" vertex="1" parent="1"><mxGeometry x="220" y="40" width="120" height="60" as="geometry"/></mxCell>`,
|
||||
})
|
||||
const canvas = await chunkedReplies(p, [
|
||||
[drawReply("d1", cell("a", "Alpha", 40))],
|
||||
cutReply,
|
||||
[
|
||||
`${sse([{ type: "start" }, rest.start, ...rest.deltas, rest.done, { type: "finish" }])}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("Gamma", { exact: true })).toBeVisible({
|
||||
timeout: 15000,
|
||||
})
|
||||
await expect(canvas.getByText("Beta", { exact: true })).toBeVisible()
|
||||
await expect(p.locator('[data-testid="new-chat-button"]')).toBeEnabled({
|
||||
timeout: 15000,
|
||||
})
|
||||
await p.waitForTimeout(1500)
|
||||
await p.locator('[data-testid="version-undo"]').click()
|
||||
await expect(canvas.getByText("Alpha", { exact: true })).toBeVisible()
|
||||
await expect(canvas.getByText("Beta", { exact: true })).toHaveCount(0)
|
||||
})
|
||||
|
||||
test("a cut off drawing goes when the model stops without continuing", async ({
|
||||
page: p,
|
||||
}) => {
|
||||
const canvas = await chunkedReplies(p, [
|
||||
[drawReply("d1", cell("a", "Alpha", 40))],
|
||||
cutReply,
|
||||
[textReply("I could not finish it.")],
|
||||
])
|
||||
await sendMessage(p, "Draw a box")
|
||||
await waitForCompleteCount(p, 1)
|
||||
await sendMessage(p, "Draw a bigger one")
|
||||
await expect(p.getByText("I could not finish it.")).toBeVisible({
|
||||
timeout: 15000,
|
||||
})
|
||||
await expect(canvas.getByText("Beta", { exact: true })).toHaveCount(0)
|
||||
await expect(canvas.getByText("Alpha", { exact: true })).toBeVisible()
|
||||
// The first drawing is on the canvas again: its card can still undo it
|
||||
await p.waitForTimeout(1500)
|
||||
await expect(p.locator('[data-testid="version-undo"]')).toBeVisible()
|
||||
})
|
||||
|
||||
test("stopping a continuation leaves the diagram from before the cut off one", async ({
|
||||
page: p,
|
||||
}) => {
|
||||
// The model draws anew instead of continuing, and the user stops it
|
||||
const redraw = toolCallEvents("d3", "display_diagram", {
|
||||
xml: cell("g", "Gamma", 400),
|
||||
})
|
||||
const canvas = await chunkedReplies(p, [
|
||||
[drawReply("d1", cell("a", "Alpha", 40))],
|
||||
cutReply,
|
||||
[
|
||||
sse([{ type: "start" }, redraw.start, ...redraw.deltas]),
|
||||
...KEEP_OPEN,
|
||||
],
|
||||
])
|
||||
await sendMessage(p, "Draw a box")
|
||||
await waitForCompleteCount(p, 1)
|
||||
await sendMessage(p, "Draw a bigger one")
|
||||
await expect(canvas.getByText("Gamma", { exact: true })).toBeVisible({
|
||||
timeout: 15000,
|
||||
})
|
||||
await p.getByRole("button", { name: "Stop generation" }).click()
|
||||
await p.waitForTimeout(1000)
|
||||
await expect(canvas.getByText("Gamma", { exact: true })).toHaveCount(0)
|
||||
await expect(canvas.getByText("Beta", { exact: true })).toHaveCount(0)
|
||||
await expect(canvas.getByText("Alpha", { exact: true })).toBeVisible()
|
||||
})
|
||||
|
||||
test("a drawing that failed its screenshot check can still be restored", async ({
|
||||
page: p,
|
||||
}) => {
|
||||
await p.addInitScript(() => {
|
||||
localStorage.setItem("next-ai-draw-io-vlm-validation-enabled", "true")
|
||||
})
|
||||
let chatRequests = 0
|
||||
await p.route("**/api/chat", async (route) => {
|
||||
chatRequests++
|
||||
if (chatRequests === 1) {
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: "text/event-stream",
|
||||
body: drawReply("d1", cell("a", "Alpha", 40)),
|
||||
})
|
||||
return
|
||||
}
|
||||
await route.fulfill({
|
||||
status: 500,
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify({ error: "Model is overloaded" }),
|
||||
})
|
||||
})
|
||||
await p.route("**/api/validate-diagram", (route) =>
|
||||
route.fulfill({
|
||||
status: 200,
|
||||
contentType: "text/plain",
|
||||
body: JSON.stringify(FAILED_CHECK),
|
||||
}),
|
||||
)
|
||||
await p.goto("/", { waitUntil: "networkidle" })
|
||||
await getIframe(p).waitFor({ state: "visible", timeout: 30000 })
|
||||
await sendMessage(p, "Draw a box")
|
||||
await expect(p.getByText("Model is overloaded").first()).toBeVisible({
|
||||
timeout: 20000,
|
||||
})
|
||||
await expect(p.locator('[data-testid="version-thumb"]')).toHaveCount(1)
|
||||
})
|
||||
|
||||
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 openDrawioFile(
|
||||
p,
|
||||
"packed.drawio",
|
||||
`<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++
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: "text/event-stream",
|
||||
body: drawReply(
|
||||
`r${calls}`,
|
||||
`<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 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()
|
||||
})
|
||||
|
||||
test("the version strip stays away while the first drawing is checked", async ({
|
||||
page: p,
|
||||
}) => {
|
||||
await p.addInitScript(() => {
|
||||
localStorage.setItem("next-ai-draw-io-vlm-validation-enabled", "true")
|
||||
})
|
||||
await p.route("**/api/chat", (route) =>
|
||||
route.fulfill({
|
||||
status: 200,
|
||||
contentType: "text/event-stream",
|
||||
body: drawReply("d1", cell("a", "Alpha", 40)),
|
||||
}),
|
||||
)
|
||||
let checking = false
|
||||
await p.route("**/api/validate-diagram", async (route) => {
|
||||
checking = true
|
||||
await new Promise((r) => setTimeout(r, 3000))
|
||||
await route
|
||||
.fulfill({
|
||||
status: 200,
|
||||
contentType: "text/plain",
|
||||
body: JSON.stringify({
|
||||
valid: true,
|
||||
issues: [],
|
||||
suggestions: [],
|
||||
}),
|
||||
})
|
||||
.catch(() => {})
|
||||
})
|
||||
await p.goto("/", { waitUntil: "networkidle" })
|
||||
await getIframe(p).waitFor({ state: "visible", timeout: 30000 })
|
||||
await sendMessage(p, "Draw a box")
|
||||
await expect.poll(() => checking, { timeout: 15000 }).toBe(true)
|
||||
// The version exists already; its card comes when the check is done
|
||||
await p.waitForTimeout(1000)
|
||||
await expect(p.locator('[data-testid="version-thumb"]')).toHaveCount(0)
|
||||
await expect(p.locator('[data-testid="version-card"]')).toHaveCount(1, {
|
||||
timeout: 10000,
|
||||
})
|
||||
await expect(p.locator('[data-testid="version-thumb"]')).toHaveCount(0)
|
||||
})
|
||||
|
||||
@@ -35,10 +35,8 @@ 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 }) => {
|
||||
|
||||
@@ -169,9 +169,14 @@ 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({ timeout: 15000 })
|
||||
).toBeVisible()
|
||||
await expect(page.locator('text="Truncated"')).toHaveCount(0)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { SINGLE_BOX_XML } from "./fixtures/diagrams"
|
||||
import {
|
||||
expect,
|
||||
getAttachmentInput,
|
||||
getChatInput,
|
||||
getIframe,
|
||||
sendMessage,
|
||||
@@ -13,19 +14,22 @@ test.describe("File Upload", () => {
|
||||
await page.goto("/", { waitUntil: "networkidle" })
|
||||
await getIframe(page).waitFor({ state: "visible", timeout: 30000 })
|
||||
|
||||
const uploadButton = page.locator(
|
||||
'button[aria-label="Upload file"], button:has(svg.lucide-image)',
|
||||
)
|
||||
await expect(uploadButton.first()).toBeVisible({ timeout: 10000 })
|
||||
await expect(uploadButton.first()).toBeEnabled()
|
||||
// The "Add" menu holds the upload entry
|
||||
const addButton = page.locator('[data-testid="composer-add"]')
|
||||
await expect(addButton).toBeVisible({ timeout: 10000 })
|
||||
await addButton.click()
|
||||
const [chooser] = await Promise.all([
|
||||
page.waitForEvent("filechooser"),
|
||||
page.getByRole("button", { name: "Upload file" }).click(),
|
||||
])
|
||||
expect(chooser.isMultiple()).toBe(true)
|
||||
})
|
||||
|
||||
test("shows file preview after selecting image", async ({ page }) => {
|
||||
await page.goto("/", { waitUntil: "networkidle" })
|
||||
await getIframe(page).waitFor({ state: "visible", timeout: 30000 })
|
||||
|
||||
// The chat attachment input; the template panel has its own file input
|
||||
const fileInput = page.locator('input[type="file"][multiple]')
|
||||
const fileInput = getAttachmentInput(page)
|
||||
|
||||
await fileInput.setInputFiles({
|
||||
name: "test-image.png",
|
||||
@@ -45,8 +49,7 @@ test.describe("File Upload", () => {
|
||||
await page.goto("/", { waitUntil: "networkidle" })
|
||||
await getIframe(page).waitFor({ state: "visible", timeout: 30000 })
|
||||
|
||||
// The chat attachment input; the template panel has its own file input
|
||||
const fileInput = page.locator('input[type="file"][multiple]')
|
||||
const fileInput = getAttachmentInput(page)
|
||||
|
||||
await fileInput.setInputFiles({
|
||||
name: "test-image.png",
|
||||
@@ -93,8 +96,7 @@ test.describe("File Upload", () => {
|
||||
await page.goto("/", { waitUntil: "networkidle" })
|
||||
await getIframe(page).waitFor({ state: "visible", timeout: 30000 })
|
||||
|
||||
// The chat attachment input; the template panel has its own file input
|
||||
const fileInput = page.locator('input[type="file"][multiple]')
|
||||
const fileInput = getAttachmentInput(page)
|
||||
|
||||
await fileInput.setInputFiles({
|
||||
name: "architecture.png",
|
||||
@@ -118,8 +120,7 @@ test.describe("File Upload", () => {
|
||||
await page.goto("/", { waitUntil: "networkidle" })
|
||||
await getIframe(page).waitFor({ state: "visible", timeout: 30000 })
|
||||
|
||||
// The chat attachment input; the template panel has its own file input
|
||||
const fileInput = page.locator('input[type="file"][multiple]')
|
||||
const fileInput = getAttachmentInput(page)
|
||||
const largeBuffer = Buffer.alloc(3 * 1024 * 1024, "x")
|
||||
|
||||
await fileInput.setInputFiles({
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user