mirror of
https://github.com/DayuanJiang/next-ai-draw-io.git
synced 2026-10-09 19:19:50 +08:00
feat(ui): canvas-first redesign with a self-hosted draw.io (#957)
The canvas now fills the window and the chat floats on its right. draw.io is served from our own origin (public/drawio, downloaded at build time and checked against the release's SHA-256), so the app can drive the editor directly: - every AI change becomes a version card with a thumbnail; compare, restore or undo it, and Ctrl+Z on the canvas takes an AI change back in one step - shapes the AI just changed are highlighted on the canvas - selected shapes appear as a chip and are sent to the model - draw.io's own simple UI: its toolbar, menus (auto layout, Mermaid, CSV import) and page tabs; the shape library starts closed - new start screen, session rename and switcher, open .drawio files - settings split into Models & keys, Appearance, Advanced and About - Bedrock in the model settings also takes an API key instead of access keys - phones switch between canvas and chat - dark mode uses draw.io's own dark mode, with neutral greys around it With an external draw.io (NEXT_PUBLIC_DRAWIO_BASE_URL) the features above that need same origin are off. The chat logic moved from the 1500-line chat panel into a chat engine plus small UI components; zustand holds canvas, versions, settings and UI state.
This commit is contained in:
@@ -0,0 +1,176 @@
|
||||
import { useParams } from "next/navigation"
|
||||
import { useEffect, useRef, useState } from "react"
|
||||
import { DrawioFrame } from "@/components/canvas/drawio-frame"
|
||||
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 { DRAWIO_CSS, 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 draw.io's own toolbar, menus and page tabs, and
|
||||
* the "ask AI" button laid over it (only when the page can drive draw.io
|
||||
* directly, not with an external cross-origin draw.io).
|
||||
*/
|
||||
export function CanvasStage({
|
||||
className,
|
||||
style,
|
||||
hideOverlays = false,
|
||||
}: {
|
||||
className?: string
|
||||
style?: React.CSSProperties
|
||||
/** Hide the "ask AI" button (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)
|
||||
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
|
||||
// The theme class is set before the page renders; the settings store
|
||||
// may not have read it yet
|
||||
const dark = document.documentElement.classList.contains("dark")
|
||||
setSrc(getDrawioSrc({ lang, isElectron, dark }))
|
||||
}, [lang])
|
||||
|
||||
// A new iframe starts over: draw.io reports load and access again
|
||||
useEffect(() => {
|
||||
resetDrawioReady()
|
||||
setEditorAccess(null)
|
||||
}, [src])
|
||||
|
||||
// Cross-origin draw.io only takes its dark mode when it starts: reload
|
||||
// it when that is not the theme now (also one switched while it
|
||||
// loaded). Same-origin switches it in place.
|
||||
useEffect(() => {
|
||||
if (editorAccess !== false || !src) return
|
||||
const startedDark = new URL(src).searchParams.get("dark") === "1"
|
||||
if (startedDark === isDark) return
|
||||
const isElectron = !!(window as { electronAPI?: unknown }).electronAPI
|
||||
setSrc(getDrawioSrc({ lang, isElectron, dark: isDark }))
|
||||
}, [isDark, editorAccess, lang, src])
|
||||
|
||||
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
|
||||
|
||||
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}
|
||||
ref={drawioRef}
|
||||
src={src}
|
||||
configCss={DRAWIO_CSS}
|
||||
editorCss={DRAWIO_CSS}
|
||||
dark={isDark}
|
||||
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 && (
|
||||
<SelectionAsk
|
||||
width={size.width}
|
||||
height={size.height}
|
||||
hidden={engine.isBusy || !onFirstPage}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,265 @@
|
||||
"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
|
||||
/** draw.io's dark mode, switched in place when the editor is reachable */
|
||||
dark: boolean
|
||||
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,
|
||||
dark,
|
||||
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
|
||||
const darkRef = useRef(dark)
|
||||
darkRef.current = dark
|
||||
// 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])
|
||||
|
||||
// Same-origin only: draw.io's own dark mode, without a reload
|
||||
const applyDarkMode = () => {
|
||||
if (!attachedRef.current) return
|
||||
try {
|
||||
// Runs on every load: switch only when it differs
|
||||
const frameWindow = iframeRef.current?.contentWindow as any
|
||||
if (frameWindow?.Editor?.isDarkMode?.() === darkRef.current) {
|
||||
return
|
||||
}
|
||||
editorUiRef.current?.setDarkMode?.(darkRef.current)
|
||||
} catch (error) {
|
||||
console.warn("Failed to set draw.io's dark mode:", error)
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
applyDarkMode()
|
||||
}, [dark])
|
||||
|
||||
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)
|
||||
// Called on every action state update: once per editor
|
||||
const ok = watchForEditorUi(frameWindow, (ui) => {
|
||||
if (editorUiRef.current === ui) return
|
||||
editorUiRef.current = ui
|
||||
// draw.io follows the system's theme on its own:
|
||||
// keep the app's
|
||||
ui.addListener?.("darkModeChanged", () =>
|
||||
applyDarkMode(),
|
||||
)
|
||||
})
|
||||
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)
|
||||
attachedRef.current = true
|
||||
applyThemeCss()
|
||||
applyDarkMode()
|
||||
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,71 @@
|
||||
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 popupOpen = useCanvasStore((s) => s.isDrawioPopupOpen)
|
||||
const focusComposer = useUiStore((s) => s.focusComposer)
|
||||
|
||||
if (hidden || popupOpen || !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>
|
||||
)
|
||||
}
|
||||
@@ -41,7 +41,8 @@ import { TemplateEditDialog } from "./TemplateEditDialog"
|
||||
|
||||
interface TemplatePanelProps {
|
||||
setInput: (input: string) => void
|
||||
onSendTemplate?: (template: Template) => void
|
||||
/** Whether it was sent */
|
||||
onSendTemplate?: (template: Template) => Promise<boolean>
|
||||
currentInput?: string
|
||||
}
|
||||
|
||||
@@ -178,16 +179,21 @@ export function TemplatePanel({
|
||||
|
||||
// Actually send the template
|
||||
const sendTemplate = async (template: Template) => {
|
||||
// Increment click count only when actually sending
|
||||
await incrementClickCount(template.id)
|
||||
if (onSendTemplate) {
|
||||
// Increment run count and update lastUsedAt
|
||||
await incrementRunCount(template.id)
|
||||
// Reload to show updated stats
|
||||
loadTemplates()
|
||||
// Call the send callback
|
||||
onSendTemplate(template)
|
||||
// Sent first, while the chat is the one it was clicked in (the
|
||||
// counts wait for storage). A run only when it was sent (not
|
||||
// while a message is still being sent, or attachments read).
|
||||
const sending = onSendTemplate(template)
|
||||
// Increment click count only when actually sending
|
||||
await incrementClickCount(template.id)
|
||||
if (await sending) {
|
||||
// Increment run count and update lastUsedAt
|
||||
await incrementRunCount(template.id)
|
||||
// Reload to show updated stats
|
||||
loadTemplates()
|
||||
}
|
||||
} else {
|
||||
await incrementClickCount(template.id)
|
||||
// Fallback: just fill the input if no send callback provided
|
||||
setInput(template.prompt)
|
||||
}
|
||||
@@ -333,14 +339,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,190 @@
|
||||
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 {
|
||||
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()
|
||||
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">
|
||||
{isDrawioReady && (
|
||||
<IconButton
|
||||
label={dict.workspace.export}
|
||||
onClick={() => setSaveDialogOpen(true)}
|
||||
data-testid="export-button"
|
||||
>
|
||||
<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,160 @@
|
||||
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])
|
||||
|
||||
// The version went away (dropped as the oldest, another chat): the
|
||||
// dialog closes, and nothing waits for its Escape
|
||||
useEffect(() => {
|
||||
if (versionId && !version) closeCompare()
|
||||
}, [versionId, version, closeCompare])
|
||||
|
||||
// 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: version.number })} ${describeChanges(changes, t)}`
|
||||
: formatMessage(t.differsElsewhere, {
|
||||
n: version.number,
|
||||
})}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
{version && (
|
||||
<div className="flex flex-col gap-4 sm:flex-row">
|
||||
<Sheet
|
||||
label={formatMessage(t.versionLabel, {
|
||||
n: version.number,
|
||||
})}
|
||||
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: version?.number ?? 0,
|
||||
})}
|
||||
</button>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,552 @@
|
||||
import {
|
||||
ArrowUp,
|
||||
BookMarked,
|
||||
BookmarkPlus,
|
||||
Globe,
|
||||
Paperclip,
|
||||
Plus,
|
||||
Square,
|
||||
SquareDashedMousePointer,
|
||||
X,
|
||||
} from "lucide-react"
|
||||
import type React from "react"
|
||||
import {
|
||||
useCallback,
|
||||
useEffect,
|
||||
useLayoutEffect,
|
||||
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])
|
||||
|
||||
// The chat is left while the user types (browser back): the browser
|
||||
// takes the focus from the input it disables, after this runs. It gets
|
||||
// the focus back once enabled, unless something else took it meanwhile.
|
||||
const refocusRef = useRef(false)
|
||||
const isLeaving = engine.isLeaving
|
||||
useLayoutEffect(() => {
|
||||
if (isDisabled) {
|
||||
if (isLeaving && document.activeElement === textareaRef.current) {
|
||||
refocusRef.current = true
|
||||
}
|
||||
return
|
||||
}
|
||||
if (!refocusRef.current) return
|
||||
refocusRef.current = false
|
||||
if (document.activeElement === document.body) {
|
||||
textareaRef.current?.focus()
|
||||
}
|
||||
}, [isDisabled, isLeaving])
|
||||
|
||||
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) && (
|
||||
// A short window keeps room for the buttons below
|
||||
<div className="max-h-[min(176px,22vh)] w-full overflow-y-auto scrollbar-thin">
|
||||
<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(
|
||||
// A short window keeps room for the buttons below
|
||||
"block max-h-[min(220px,30vh)] 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)
|
||||
return 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,640 @@
|
||||
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 = async () => {
|
||||
if (!editText.trim()) return
|
||||
// Kept open, with its text, when it could not go now
|
||||
if (await 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={(e) => {
|
||||
// Not for a link or an attachment's toggle inside, nor
|
||||
// when text was selected to copy it
|
||||
const target = e.target as Element
|
||||
if (target.closest("a, button")) return
|
||||
if (window.getSelection()?.toString()) return
|
||||
startEdit()
|
||||
}}
|
||||
onKeyDown={(e) => {
|
||||
if (e.target !== e.currentTarget) return
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
// Text parts with nothing shown between them render together (a step
|
||||
// boundary of an automatic retry shows nothing); tools render in place
|
||||
const blocks: {
|
||||
kind: "text" | "tool" | "reasoning"
|
||||
index: number
|
||||
texts: string[]
|
||||
}[] = []
|
||||
parts.forEach((part, partIndex) => {
|
||||
if (part.type === "reasoning")
|
||||
blocks.push({ kind: "reasoning", index: partIndex, texts: [] })
|
||||
else if (part.type?.startsWith("tool-"))
|
||||
blocks.push({ kind: "tool", index: partIndex, texts: [] })
|
||||
else if (part.type === "text") {
|
||||
const text = (part as { text: string }).text
|
||||
const last = blocks[blocks.length - 1]
|
||||
if (last?.kind === "text") last.texts.push(text)
|
||||
else blocks.push({ kind: "text", index: partIndex, texts: [text] })
|
||||
}
|
||||
})
|
||||
|
||||
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 = block.texts.join("\n")
|
||||
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,295 @@
|
||||
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])
|
||||
|
||||
// The title as the list shows it
|
||||
const shownTitle = (title: string) =>
|
||||
title === "New Chat" ? dict.workspace.untitled : title
|
||||
const sessions = engine.sessions.filter((s) =>
|
||||
shownTitle(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">
|
||||
{shownTitle(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,531 @@
|
||||
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, describeTotals } 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,
|
||||
isLatest,
|
||||
}: {
|
||||
part: ToolPartLike
|
||||
version: DiagramVersion
|
||||
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
|
||||
? describeTotals(version.summary, t)
|
||||
: 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{version.number}
|
||||
</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: version.number })}
|
||||
</button>
|
||||
)
|
||||
|
||||
// What the AI wrote, on every card (main showed it on every tool call)
|
||||
const codeButton = (
|
||||
<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>
|
||||
)
|
||||
const codePanel = 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>
|
||||
)
|
||||
|
||||
if (!isLatest) {
|
||||
return (
|
||||
<div
|
||||
className="overflow-hidden rounded-xl border border-border bg-card"
|
||||
data-testid="version-card"
|
||||
data-tool-state={part.state}
|
||||
>
|
||||
<div className="flex items-center gap-2.5 p-1.5 pr-2">
|
||||
<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>
|
||||
{codeButton}
|
||||
{restoreButton}
|
||||
</div>
|
||||
{codePanel}
|
||||
</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>
|
||||
{codeButton}
|
||||
{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>
|
||||
{codePanel}
|
||||
</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 dict = useDictionary()
|
||||
const [open, setOpen] = useState(false)
|
||||
const { copied, copy } = useCopy()
|
||||
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={cn(
|
||||
"relative space-y-2 border-t border-border bg-surface-1 px-3 py-2.5",
|
||||
hasInput && "pr-11",
|
||||
)}
|
||||
>
|
||||
{hasInput && (
|
||||
<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>
|
||||
)}
|
||||
{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]}
|
||||
isLatest={versionIndex === versions.length - 1}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
// The answer ended before the call did (stopped, network error)
|
||||
const running = isLastMessage && engine.isBusy
|
||||
|
||||
if (toolName === "get_shape_library" && state !== "output-error") {
|
||||
const isDone = state === "output-available"
|
||||
return (
|
||||
<ToolRow
|
||||
part={part}
|
||||
icon={<Library />}
|
||||
label={
|
||||
isDone ? (
|
||||
t.libraryLoaded
|
||||
) : running ? (
|
||||
<Shimmer as="span">{t.loadingLibrary}</Shimmer>
|
||||
) : (
|
||||
t.stopped
|
||||
)
|
||||
}
|
||||
detail={
|
||||
isDone && typeof output === "string"
|
||||
? `${output.slice(0, 600)}${output.length > 600 ? "…" : ""}`
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
// Stop turns a call still streaming into "Stopped by user" (right
|
||||
// away, or when the answer has ended for one not on screen yet): one
|
||||
// left like this was cut off (a closed connection, an error, a closed
|
||||
// tab)
|
||||
if (state === "input-streaming" || state === "input-available") {
|
||||
const failed = !running && !(isLastMessage && engine.stoppedRef.current)
|
||||
return (
|
||||
<ToolRow
|
||||
part={part}
|
||||
icon={running ? <PenTool /> : <AlertCircle />}
|
||||
tone={failed ? "error" : "default"}
|
||||
label={
|
||||
running ? (
|
||||
<Shimmer as="span">
|
||||
{toolName === "edit_diagram"
|
||||
? t.editing
|
||||
: t.drawing}
|
||||
</Shimmer>
|
||||
) : failed ? (
|
||||
t.failed
|
||||
) : (
|
||||
t.stopped
|
||||
)
|
||||
}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
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,231 @@
|
||||
"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}-result`)
|
||||
lastProcessedXmlRef.current.delete(`${toolCallId}-ops`)
|
||||
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
|
||||
|
||||
// The last operation's XML may still be streaming: it applies
|
||||
// once it parses, so compare results, not the number of
|
||||
// operations. Skip if nothing changed since the last preview.
|
||||
const resultKey = `${toolCallId}-result`
|
||||
const opsKey = `${toolCallId}-ops`
|
||||
const ops = JSON.stringify(completeOps)
|
||||
if (lastProcessedXmlRef.current.get(opsKey) === ops) continue
|
||||
lastProcessedXmlRef.current.set(opsKey, ops)
|
||||
try {
|
||||
const { result } = applyDiagramOperations(
|
||||
originalXml,
|
||||
completeOps,
|
||||
)
|
||||
if (lastProcessedXmlRef.current.get(resultKey) === result) {
|
||||
continue
|
||||
}
|
||||
// Load the full document so other pages stay intact
|
||||
loadDiagram(result, true, "preview")
|
||||
lastProcessedXmlRef.current.set(resultKey, result)
|
||||
} 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,183 @@
|
||||
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; Ctrl+Z or redo can also bring the latest version
|
||||
// (or the diagram before it) back. 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) {
|
||||
const shown =
|
||||
id === store.onCanvasVersionId
|
||||
? version.xml
|
||||
: version.beforeXml || BLANK_MXFILE
|
||||
if (isSameDocument(shown, chartXML)) return
|
||||
// Its Undo would also take the changes made since away
|
||||
toast.dismiss("version-restored")
|
||||
}
|
||||
const latest = store.versions.at(-1)
|
||||
if (latest && isSameDocument(latest.xml, chartXML)) {
|
||||
store.setOnCanvas(latest.id)
|
||||
} else if (
|
||||
latest?.beforeXml !== undefined &&
|
||||
isSameDocument(latest.beforeXml || BLANK_MXFILE, chartXML)
|
||||
) {
|
||||
store.setUndone(latest.id)
|
||||
} else {
|
||||
store.clearCanvasFlags()
|
||||
}
|
||||
}, [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: store.versions[index].number,
|
||||
}),
|
||||
{
|
||||
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) => {
|
||||
const isCurrent = version.id === onCanvasVersionId
|
||||
return (
|
||||
<button
|
||||
key={version.id}
|
||||
type="button"
|
||||
onClick={() => openCompare(version.id)}
|
||||
title={`v${version.number}${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{version.number}
|
||||
</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,28 @@ function ConfigCard({ children }: { children: React.ReactNode }) {
|
||||
)
|
||||
}
|
||||
|
||||
/** A Bedrock API key or an access key pair, and a region */
|
||||
function hasBedrockCredentials(p: ProviderConfig): boolean {
|
||||
return (
|
||||
(!!p.apiKey || (!!p.awsAccessKeyId && !!p.awsSecretAccessKey)) &&
|
||||
!!p.awsRegion
|
||||
)
|
||||
}
|
||||
|
||||
/** 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 +203,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
|
||||
}
|
||||
}, [])
|
||||
// The providers as they are now: once this view is gone, as saved
|
||||
const providersNow = () =>
|
||||
closedRef.current ? savedProviders() : configRef.current.providers
|
||||
// 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 p = providersNow().find((x) => x.id === providerId)
|
||||
return JSON.stringify([
|
||||
p?.provider,
|
||||
p?.apiKey,
|
||||
@@ -266,7 +289,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 {
|
||||
@@ -355,7 +380,7 @@ export function ModelConfigDialog({
|
||||
if (!selectedProviderId || !selectedProvider) return false
|
||||
// Prevent duplicate model IDs
|
||||
if (existingModelIds.includes(modelId)) {
|
||||
setDuplicateError(`Model "${modelId}" already exists`)
|
||||
setDuplicateError(dict.modelConfig.modelIdExists)
|
||||
return false
|
||||
}
|
||||
setDuplicateError("")
|
||||
@@ -388,13 +413,7 @@ export function ModelConfigDialog({
|
||||
const isOllama = selectedProvider.provider === "ollama"
|
||||
const isVertexAI = selectedProvider.provider === "vertexai"
|
||||
if (isBedrock) {
|
||||
if (
|
||||
!selectedProvider.awsAccessKeyId ||
|
||||
!selectedProvider.awsSecretAccessKey ||
|
||||
!selectedProvider.awsRegion
|
||||
) {
|
||||
return
|
||||
}
|
||||
if (!hasBedrockCredentials(selectedProvider)) return
|
||||
} else if (isVertexAI) {
|
||||
// Vertex AI requires vertexApiKey for Express Mode
|
||||
if (!selectedProvider.vertexApiKey) {
|
||||
@@ -406,7 +425,7 @@ export function ModelConfigDialog({
|
||||
|
||||
// Need at least one model to validate
|
||||
if (selectedProvider.models.length === 0) {
|
||||
setValidationError("Add at least one model to validate")
|
||||
setValidationError(dict.modelConfig.addModelFirst)
|
||||
setValidationStatus("error")
|
||||
return
|
||||
}
|
||||
@@ -485,14 +504,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,
|
||||
}
|
||||
}
|
||||
@@ -511,7 +533,7 @@ export function ModelConfigDialog({
|
||||
return
|
||||
}
|
||||
// So did this model's id: the result is for the old one
|
||||
const current = configRef.current.providers
|
||||
const current = providersNow()
|
||||
.find((p) => p.id === selectedProviderId)
|
||||
?.models.find((m) => m.id === model.id)
|
||||
if (current?.modelId !== model.modelId) {
|
||||
@@ -565,7 +587,11 @@ export function ModelConfigDialog({
|
||||
}, 1500)
|
||||
} else {
|
||||
setValidationStatus("error")
|
||||
setValidationError(`${errorCount} model(s) failed validation`)
|
||||
setValidationError(
|
||||
formatMessage(dict.modelConfig.validationFailedCount, {
|
||||
count: errorCount,
|
||||
}),
|
||||
)
|
||||
}
|
||||
}, [
|
||||
selectedProvider,
|
||||
@@ -682,27 +708,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>
|
||||
@@ -898,6 +910,7 @@ export function ModelConfigDialog({
|
||||
awsRegion={
|
||||
selectedProvider.awsRegion
|
||||
}
|
||||
bedrockApiKey
|
||||
onChange={(field, value) =>
|
||||
handleProviderUpdate(
|
||||
field,
|
||||
@@ -914,9 +927,9 @@ export function ModelConfigDialog({
|
||||
selectedProvider.provider ===
|
||||
"bedrock"
|
||||
? renderTestButton(
|
||||
!!selectedProvider.awsAccessKeyId &&
|
||||
!!selectedProvider.awsSecretAccessKey &&
|
||||
!!selectedProvider.awsRegion,
|
||||
hasBedrockCredentials(
|
||||
selectedProvider,
|
||||
),
|
||||
)
|
||||
: selectedProvider.provider ===
|
||||
"edgeone"
|
||||
@@ -1413,7 +1426,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 +1487,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 +1525,7 @@ export function ModelConfigDialog({
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</div>
|
||||
|
||||
{/* Delete Confirmation Dialog */}
|
||||
<AlertDialog
|
||||
@@ -1588,6 +1605,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>
|
||||
) : (
|
||||
|
||||
@@ -53,6 +53,9 @@ interface ProviderCredentialsFieldsProps {
|
||||
name?: string
|
||||
baseUrl?: string
|
||||
awsRegion?: string
|
||||
// Bedrock: also offer an API key (the user dialog; the admin panel
|
||||
// stores access keys only)
|
||||
bedrockApiKey?: boolean
|
||||
disabled?: boolean
|
||||
// Update a plain text field
|
||||
onChange: (field: "name" | "baseUrl" | "awsRegion", value: string) => void
|
||||
@@ -72,6 +75,7 @@ export function ProviderCredentialsFields({
|
||||
name,
|
||||
baseUrl,
|
||||
awsRegion,
|
||||
bedrockApiKey,
|
||||
disabled,
|
||||
onChange,
|
||||
renderSecret,
|
||||
@@ -112,6 +116,26 @@ export function ProviderCredentialsFields({
|
||||
|
||||
{provider === "bedrock" ? (
|
||||
<>
|
||||
{/* Bedrock API key: used instead of the access keys */}
|
||||
{bedrockApiKey && (
|
||||
<div className="space-y-2">
|
||||
<Label
|
||||
htmlFor="bedrock-api-key"
|
||||
className="text-xs font-medium flex items-center gap-1.5"
|
||||
>
|
||||
<Key className="h-3.5 w-3.5 text-muted-foreground" />
|
||||
{dict.modelConfig.bedrockApiKey}
|
||||
</Label>
|
||||
{renderSecret({
|
||||
field: "apiKey",
|
||||
id: "bedrock-api-key",
|
||||
})}
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{dict.modelConfig.bedrockApiKeyHint}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* AWS Access Key ID */}
|
||||
<div className="space-y-2">
|
||||
<Label
|
||||
|
||||
@@ -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,403 @@
|
||||
"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 } 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. A new chat brings
|
||||
// it back, an opened file or saved chat puts it away (the chat engine).
|
||||
const showHero =
|
||||
engine.isRestored &&
|
||||
engine.messages.length === 0 &&
|
||||
!engine.isResending &&
|
||||
!isRealDiagram(chartXML) &&
|
||||
!heroDismissed
|
||||
|
||||
// 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
|
||||
useEffect(() => {
|
||||
const handle = (event: KeyboardEvent) => {
|
||||
if (!(event.metaKey || event.ctrlKey)) return false
|
||||
// ⌘⇧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 }}
|
||||
hideOverlays={!isMobile && showHero}
|
||||
/>
|
||||
{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} />
|
||||
)}
|
||||
|
||||
{/* Below draw.io's toolbar (50 px), whose
|
||||
right-hand buttons it would cover */}
|
||||
{!showHero && !panelOpen && (
|
||||
<div className="absolute top-[60px] 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>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user