"use client" import { useChat } from "@ai-sdk/react" import { type ChatStatus, DefaultChatTransport, isToolUIPart, type UIMessage, } from "ai" import { usePathname, useRouter, useSearchParams } from "next/navigation" import type React from "react" import { createContext, useCallback, useContext, useEffect, useLayoutEffect, useMemo, useRef, useState, } from "react" import { flushSync } from "react-dom" import { toast } from "sonner" import type { ValidationState } from "@/components/chat/ValidationCard" import { useDiagram } from "@/contexts/diagram-context" import { keepFileVars, useDiagramToolHandlers, } from "@/hooks/use-diagram-tool-handlers" import { useDictionary } from "@/hooks/use-dictionary" import { getSelectedAIConfig, type UseModelConfigReturn, useModelConfig, } from "@/hooks/use-model-config" import { getSessionVersions, type SessionData, useSessionManager, } from "@/hooks/use-session-manager" import { useValidateDiagram } from "@/hooks/use-validate-diagram" import { getApiEndpoint } from "@/lib/base-path" import { findCachedResponse } from "@/lib/cached-responses" import { buildChatHeaders } from "@/lib/chat-request" import { EMPTY_DRAWIO_DOCUMENT } from "@/lib/drawio/drawio-config" import { formatMessage } from "@/lib/i18n/utils" import { isPdfFile, isTextFile } from "@/lib/pdf-utils" import type { SessionMetadata } from "@/lib/session-storage" import { sanitizeMessages } from "@/lib/session-storage" import { STORAGE_KEYS } from "@/lib/storage" import type { UrlData } from "@/lib/url-utils" import { type FileData, useFileProcessor } from "@/lib/use-file-processor" import { useQuotaManager } from "@/lib/use-quota-manager" import { formatXML, isRealDiagram } from "@/lib/utils" import { parseDrawioFileContent } from "@/packages/mcp-server/src/load-diagram.ts" import { prepareNewDiagram } from "@/packages/mcp-server/src/new-diagram.ts" import { BLANK_MXFILE, hasCells } from "@/packages/mcp-server/src/pages.ts" import { type SelectedCell, useCanvasStore } from "@/stores/canvas-store" import { useSettingsStore } from "@/stores/settings-store" import { useUiStore } from "@/stores/ui-store" import { useVersionsStore } from "@/stores/versions-store" import { APPENDED_FILE_SECTIONS_PATTERN } from "./message-text" import { useDiagramStreaming } from "./use-diagram-streaming" import { useVersions } from "./use-versions" // sessionStorage key for the unsent input (survives remounts and reloads) const SESSION_STORAGE_INPUT_KEY = "next-ai-draw-io-input" const TOOL_ERROR_STATE = "output-error" as const // 3 to support VLM validation retries (matches MAX_VALIDATION_RETRIES) const MAX_AUTO_RETRY_COUNT = 3 // Limit for truncation continuation retries const MAX_CONTINUATION_RETRY_COUNT = 2 // Save the session at most once per second const SAVE_DEBOUNCE_MS = 1000 /** * Check if auto-resubmit should happen based on tool errors. * Only checks the LAST tool part (most recent tool call), not all tool parts. * A call the user stopped is not one to retry. */ function hasToolErrors(messages: UIMessage[]): boolean { const lastMessage = messages[messages.length - 1] if (lastMessage?.role !== "assistant") return false const lastToolPart = lastMessage.parts.filter(isToolUIPart).at(-1) return ( lastToolPart?.state === TOOL_ERROR_STATE && lastToolPart.errorText !== "Stopped by user" ) } /** * Snapshots keep the full multi-page document, but the model only sees and * edits the first page, so give it the first page's mxGraphModel. * Older snapshots already hold a single mxGraphModel and are returned as is. */ function getFirstPageXml(xml: string): string { if (!xml.includes("[] function selectionOf(message: UIMessage): MessageSelection { const meta = message.metadata as | { selectedCells?: MessageSelection } | undefined return Array.isArray(meta?.selectedCells) ? meta.selectedCells : [] } function newLangfuseSessionId() { return `session-${Date.now()}-${Math.random().toString(36).slice(2, 9)}` } const XML_PROLOG = /^(?:\s|<\?[\s\S]*?\?>||[]*(?:\[[\s\S]*?\])?\s*>)*/i /** * Read the diagram document out of a .drawio, .xml or .drawio.svg file, with * its pages inflated: previews and the model read it before draw.io saves * it again. A broken or cut off file gives null. */ async function readDiagramFile(file: File): Promise { const raw = await file.text() // What XML allows before the root element: the declaration, comments, // a DOCTYPE (the parser below expects the root first) let text = raw.replace(XML_PROLOG, "") // Editable SVG: the diagram is stored in the root's `content` attribute. // Parsed whole: its DOCTYPE may define entities the content uses. if (/^<(?:[\w.-]+:)?svg[\s>]/i.test(text)) { const doc = new DOMParser().parseFromString(raw, "image/svg+xml") text = (doc.documentElement?.getAttribute("content") ?? "").replace( XML_PROLOG, "", ) } const loaded = parseDrawioFileContent(text) if (!loaded.ok) return null return withUniquePageIds(loaded.xml) } /** * draw.io refuses a file whose pages share an id (EditorUi.setFileData: a * page without one gets its index, and ids are looked up in a plain object, * so "constructor" counts as taken too). Ids the file writes keep their * pages, for links to them; the others get a new one. */ function withUniquePageIds(xml: string): string { const doc = new DOMParser().parseFromString(xml, "text/xml") const pages = Array.from(doc.getElementsByTagName("diagram")) const usable = (id: string) => !(id in {}) const written = pages.map((page) => page.getAttribute("id")) const taken = new Set() const ids: (string | null)[] = written.map((id) => { if (id === null || taken.has(id) || !usable(id)) return null taken.add(id) return id }) let changed = false pages.forEach((page, i) => { if (ids[i] !== null) return // As draw.io would number it, or else a new id let id = written[i] ?? String(i) if (taken.has(id) || !usable(id)) { const base = id && usable(id) ? id : "page" let n = 1 while (taken.has(`${base}-${n}`)) n++ id = `${base}-${n}` } taken.add(id) // (draw.io numbers a page without an id the same way itself) if (id !== (written[i] ?? String(i))) { page.setAttribute("id", id) changed = true } }) return changed ? new XMLSerializer().serializeToString(doc) : xml } // An attachment that could not be read (its file went away meanwhile) class FileReadError extends Error {} export interface ChatEngine { messages: UIMessage[] status: ChatStatus error: Error | undefined isBusy: boolean /** The chat on screen is being left (saved, then another one shown) */ isLeaving: boolean /** A message is being sent again; the chat is not empty meanwhile */ isResending: boolean setMessages: (messages: UIMessage[]) => void stop: () => void input: string setInput: (value: string) => void files: File[] pdfData: Map setFiles: (files: File[]) => void urlData: Map setUrlData: React.Dispatch>> isExtractingAttachments: boolean /** Whether the message was sent */ submit: (overrideText?: string) => Promise /** Whether it was sent */ sendTemplate: (prompt: string) => Promise improveWithSuggestions: (feedback: string) => Promise regenerate: (messageIndex: number) => Promise retryLastMessage: () => Promise /** False when nothing was sent (another answer is being prepared) */ editMessage: (messageIndex: number, newText: string) => Promise /** Selected shapes that will be sent with the next message */ chatSelection: SelectedCell[] dismissSelection: () => void sessions: SessionMetadata[] currentSessionId: string | null currentTitle: string | null isRestored: boolean newChat: () => Promise selectSession: (id: string) => Promise deleteSession: (id: string) => Promise renameSession: (title: string) => Promise openDiagramFile: (file: File) => Promise /** * Save the chat on screen, then leave the page (another language) with * the chat's session id; nothing happens while an answer runs */ leavePage: (go: (sessionId: string | null) => void) => Promise restoreVersion: (versionId: string) => void undoVersion: (versionId: string) => void langfuseSessionId: string loadedMessageIdsRef: React.RefObject> /** Stop was pressed in the turn on screen (until the next message) */ stoppedRef: React.RefObject validationStates: Record modelConfig: UseModelConfigReturn } const ChatEngineContext = createContext(null) export function useChatEngine(): ChatEngine { const engine = useContext(ChatEngineContext) if (!engine) { throw new Error("useChatEngine must be used inside ChatEngineProvider") } return engine } export function ChatEngineProvider({ children, }: { children: React.ReactNode }) { const { loadDiagram: onDisplayChart, handleExport: onExport, exportResolversRef, chartXML, chartXMLRef: liveChartXMLRef, latestSvg, clearDiagram, getThumbnailSvg, captureValidationPng, startTurn, } = useDiagram() const dict = useDictionary() const router = useRouter() const pathname = usePathname() const searchParams = useSearchParams() const urlSessionId = searchParams.get("session") const settings = useSettingsStore() const openSettings = useUiStore((s) => s.openSettings) const focusComposer = useUiStore((s) => s.focusComposer) const onFetchChart = () => { // Waits for the reply to its own export, by its tag const tag = onExport() return Promise.race([ new Promise((resolve) => { if (tag) exportResolversRef.current[tag] = resolve }), new Promise((_, reject) => { setTimeout(() => { delete exportResolversRef.current[tag] reject(new Error("Chart export timed out after 10 seconds")) }, 10000) }), ]) } const { files, pdfData, handleFileChange, setFiles } = useFileProcessor({ tooLong: dict.errors.fileTooLong, readFailed: dict.errors.fileReadFailed, }) const [urlData, setUrlData] = useState>(new Map()) const filesRef = useRef(files) filesRef.current = files const urlDataRef = useRef(urlData) urlDataRef.current = urlData const modelConfig = useModelConfig() const sessionManager = useSessionManager({ initialSessionId: urlSessionId }) const [input, setInputState] = useState("") const [dailyRequestLimit, setDailyRequestLimit] = useState(0) const [dailyTokenLimit, setDailyTokenLimit] = useState(0) const [tpmLimit, setTpmLimit] = useState(0) // Restore the unsent input from sessionStorage useEffect(() => { const savedInput = sessionStorage.getItem(SESSION_STORAGE_INPUT_KEY) if (savedInput) { inputRef.current = savedInput setInputState(savedInput) } }, []) // Latest composer contents, for code that runs after an await const inputRef = useRef("") const setInput = useCallback((value: string) => { inputRef.current = value setInputState(value) if (value) sessionStorage.setItem(SESSION_STORAGE_INPUT_KEY, value) else sessionStorage.removeItem(SESSION_STORAGE_INPUT_KEY) }, []) useEffect(() => { fetch(getApiEndpoint("/api/config")) .then((res) => res.json()) .then((data) => { setDailyRequestLimit(data.dailyRequestLimit || 0) setDailyTokenLimit(data.dailyTokenLimit || 0) setTpmLimit(data.tpmLimit || 0) }) .catch(() => {}) }, []) const quotaManager = useQuotaManager({ dailyRequestLimit, dailyTokenLimit, tpmLimit, onConfigModel: () => openSettings("models"), }) // Session ID for Langfuse tracing (restored from localStorage if available) const [langfuseSessionId, setLangfuseSessionId] = useState(() => { if (typeof window !== "undefined") { const saved = localStorage.getItem(STORAGE_KEYS.sessionId) if (saved) return saved } return newLangfuseSessionId() }) useEffect(() => { localStorage.setItem(STORAGE_KEYS.sessionId, langfuseSessionId) }, [langfuseSessionId]) // Title for a session that is not saved yet (opened file, early rename) const [pendingTitle, setPendingTitleState] = useState(null) const pendingTitleRef = useRef(null) const setPendingTitle = (title: string | null) => { pendingTitleRef.current = title setPendingTitleState(title) } // XML snapshot taken before each user message (keyed by message index) const xmlSnapshotsRef = useRef>(new Map()) // Index of the user message whose turn is running (versions belong to it) const currentTurnRef = useRef(0) // The chat (its generation) the running turn was sent in: a reply that // ends after another chat came on screen has nothing to say there const turnChatRef = useRef(0) const hasRestoredRef = useRef(false) const [isRestored, setIsRestored] = useState(false) // Latest chartXML for callbacks (avoids stale closures) const chartXMLRef = useRef(chartXML) // Session loaded without a diagram (prevents thumbnail contamination) const justLoadedSessionIdRef = useRef(null) useEffect(() => { chartXMLRef.current = chartXML if (chartXML) justLoadedSessionIdRef.current = null }, [chartXML]) const latestSvgRef = useRef(latestSvg) useEffect(() => { latestSvgRef.current = latestSvg }, [latestSvg]) const autoRetryCountRef = useRef(0) const continuationRetryCountRef = useRef(0) // Partial XML when output was truncated; non-empty means continuation mode const partialXmlRef = useRef("") // Diagram before a cut off drawing, whose half drawn preview stays on // the canvas while append_diagram finishes it const continuationOriginalRef = useRef(null) // Tool calls already applied, so remounts never replay old outputs const processedToolCallsRef = useRef>(new Set()) // Set by Stop until the user sends the next message const stoppedRef = useRef(false) const preparingSendRef = useRef(false) // Set while the chat on screen is left (saved, then another one put on // screen): an answer started meanwhile would land in the next chat const leavingRef = useRef(false) const [isLeaving, setIsLeaving] = useState(false) // The user's provider the current turn was sent to, for its errors const requestProviderIdRef = useRef(undefined) // A message is being sent again (retry, regenerate, edit), until the // SDK has put it back const [isResending, setIsResending] = useState(false) // Presses of Stop: a check that began before one still knows of it after // the next message clears stoppedRef const stopCountRef = useRef(0) // Diagram before each streamed display_diagram and edit_diagram preview // (key: toolCallId), shared between the streaming preview and the tool // handler const editDiagramOriginalXmlRef = useRef>(new Map()) const saveDebounceRef = useRef | null>(null) const [validationStates, setValidationStates] = useState< Record >({}) const handleValidationStateChange = useCallback( (toolCallId: string, state: ValidationState) => { setValidationStates((prev) => ({ ...prev, [toolCallId]: state })) }, [], ) // Failed VLM validations in the current user turn (reset on user action) const validationRetryCountRef = useRef(0) const { validateWithFallback, cancel: cancelValidation } = useValidateDiagram() const { handleToolCall } = useDiagramToolHandlers({ partialXmlRef, continuationOriginalRef, editDiagramOriginalXmlRef, processedToolCallsRef, validationRetryCountRef, // A preview undone just before the tool call is in this one already chartXMLRef: liveChartXMLRef, onDisplayChart, onFetchChart, captureValidationPng, validateDiagram: validateWithFallback, enableVlmValidation: settings.vlmValidationEnabled, sessionId: langfuseSessionId, watchStop: () => { const stopsBefore = stopCountRef.current // Also when the page is gone return () => stoppedRef.current || stopCountRef.current !== stopsBefore || !mountedRef.current }, onValidationStateChange: handleValidationStateChange, }) // The turn ends without finishing a cut off drawing: back to the // diagram before it const abandonContinuation = () => { const original = continuationOriginalRef.current if (original === null) return continuationOriginalRef.current = null // Previews drawn since are on top of the cut off one: none of them // may be undone to it later for (const id of editDiagramOriginalXmlRef.current.keys()) { processedToolCallsRef.current.add(id) } editDiagramOriginalXmlRef.current.clear() onDisplayChart(original, true, "revert") } const transport = useMemo( () => new DefaultChatTransport({ api: getApiEndpoint("/api/chat") }), [], ) const { messages, sendMessage, addToolOutput, status, error, setMessages, stop, } = useChat({ transport, onToolCall: async ({ toolCall }) => { await handleToolCall({ toolCall }, addToolOutputSafely) }, onFinish: ({ message, isAbort, isError }) => { // Stopped, failed, or the reply just ended (a closed connection): // tool calls still streaming never reach the tool handler. Mark // them handled so a later render of the stream does not draw // their preview again. const cut = (message.parts as any[]).filter( (part) => part.state === "input-streaming" && part.toolCallId, ) for (const part of cut) { processedToolCallsRef.current.add(part.toolCallId) } // A call that started just before Stop was not on screen yet, so // Stop did not mark it: it was stopped too, not cut off (Stop, // or the page going, aborts a reply). Not once another chat is // on screen: the call is not among its messages. if (isAbort && getChatGeneration() === turnChatRef.current) { for (const part of cut) { addToolOutputSafely({ tool: part.type.replace("tool-", ""), toolCallId: part.toolCallId, state: "output-error", errorText: "Stopped by user", }) } } if (isAbort || isError || cut.length === 0) return // Ended with no error: undo the preview, and say so, as an // error does (with Retry) const [originalXml] = editDiagramOriginalXmlRef.current.values() if (originalXml) onDisplayChart(originalXml, true, "revert") editDiagramOriginalXmlRef.current.clear() abandonContinuation() // Not in another chat that came on screen meanwhile if (getChatGeneration() !== turnChatRef.current) return setMessages((currentMessages) => [ ...currentMessages, { id: `error-${Date.now()}`, role: "system" as const, parts: [ { type: "text" as const, text: dict.errors.answerEndedEarly, }, ], }, ]) }, onError: (error) => { // A diagram still streaming when the request failed never // reaches the tool handler: undo its preview. Only previews not // handled yet are stored, and the first one holds the diagram // before any of them. const [originalXml] = editDiagramOriginalXmlRef.current.values() if (originalXml) onDisplayChart(originalXml, true, "revert") editDiagramOriginalXmlRef.current.clear() abandonContinuation() // Server errors are JSON: a quota limit ({type: request, token or // tpm}), a provider error ({type: "provider", code, message}) or // {error}. The SDK puts the response body in error.message. let data: any = null try { data = JSON.parse(error.message) } catch { // Plain text, e.g. a network failure in the browser } if (data?.type === "request") { quotaManager.showQuotaLimitToast(data.used, data.limit) return } if (data?.type === "token") { quotaManager.showTokenLimitToast(data.used, data.limit) return } if (data?.type === "tpm") { quotaManager.showTPMLimitToast(data.limit) return } const isAccessCodeError = String( data?.error ?? error.message, ).includes("Invalid or missing access code") if (!isAccessCodeError) console.error("Chat error:", error) // A hint the user can act on, then the provider's own words let text: string = error.message let openModelConfig = false if (data?.type === "provider") { const hints = dict.errors.llm as Record const hint = hints[data.code] text = hint && data.message ? `${hint}\n\n${data.message}` : hint || data.message openModelConfig = [ "invalid_api_key", "forbidden", "model_not_found", "server_key_forbidden", ].includes(data.code) } else if (typeof data?.error === "string") { text = data.error } else if (error.message === "Failed to fetch") { text = dict.errors.networkError } // Not in another chat that came on screen meanwhile if (getChatGeneration() !== turnChatRef.current) return // The provider the turn was sent to (none for a server model); // the model selected may have changed since, e.g. in another tab const providerId = requestProviderIdRef.current // A system message, so it can be cleared with the conversation setMessages((currentMessages) => [ ...currentMessages, { id: `error-${Date.now()}`, role: "system" as const, parts: [{ type: "text" as const, text }], // The message shows a button that opens model settings, // on the page of the provider that refused ...(openModelConfig && { metadata: { openModelConfig: true, providerId }, }), }, ]) if (isAccessCodeError) openSettings("general") }, // Re-render streamed messages at most every 150 ms. The streaming // diagram preview draws on each update, so this also limits redraws experimental_throttle: 150, sendAutomaticallyWhen: ({ messages }) => { const send = shouldSendAgain(messages) // The turn ends here if (!send) abandonContinuation() return send }, }) // The SDK applies a tool output to the last message of the chat on // screen when its turn comes (after a running tool call). One of a chat // that was left belongs to none of these messages; and a chat emptied // meanwhile (New Chat right after Stop) has none to apply it to. const addToolOutputSafely = ( params: Parameters[0], ) => { if (getChatGeneration() !== turnChatRef.current) return Promise.resolve() return Promise.resolve(addToolOutput(params)).catch((error: unknown) => console.warn("[chat] Tool output not applied:", error), ) } // After each request: send the next one (retry, continue a cut off // drawing) or end the turn const shouldSendAgain = (messages: UIMessage[]) => { // The user stopped: a tool result that arrives later (a VLM // check still running) must not start a new request if (stoppedRef.current) return false // Another chat came on screen: these are its messages, and the // request would carry the left chat's diagram. Nor from a page that // is gone (its aborted turn's tool outputs still arrive). if (getChatGeneration() !== turnChatRef.current) return false if (!mountedRef.current) return false const isInContinuationMode = partialXmlRef.current.length > 0 const shouldRetry = hasToolErrors(messages) if (!shouldRetry) { autoRetryCountRef.current = 0 continuationRetryCountRef.current = 0 partialXmlRef.current = "" return false } if (isInContinuationMode) { if ( continuationRetryCountRef.current >= MAX_CONTINUATION_RETRY_COUNT ) { toast.error( formatMessage(dict.errors.continuationRetryLimit, { max: MAX_CONTINUATION_RETRY_COUNT, }), ) continuationRetryCountRef.current = 0 partialXmlRef.current = "" return false } continuationRetryCountRef.current++ } else { if (autoRetryCountRef.current >= MAX_AUTO_RETRY_COUNT) { toast.error( formatMessage(dict.errors.retryLimit, { max: MAX_AUTO_RETRY_COUNT, }), ) autoRetryCountRef.current = 0 partialXmlRef.current = "" return false } autoRetryCountRef.current++ } return true } const isBusy = status === "streaming" || status === "submitted" useEffect(() => { if (messages.length > 0) setIsResending(false) }, [messages.length]) // isBusy of the latest render, for code that runs after an await const busyRef = useRef(isBusy) busyRef.current = isBusy // The page goes (another language mounts a new one): the answer stops, // so it cannot reach the next page's canvas const stopRef = useRef(stop) stopRef.current = stop // (Development runs this twice, with a cleanup in between) const mountedRef = useRef(false) useEffect(() => { mountedRef.current = true return () => { mountedRef.current = false stopRef.current() } }, []) // While an answer is sent or streams in, the chat must stay on screen: // the rest of the answer and its diagram would land in another chat // (Between the requests of one turn the SDK is idle for a moment; a cut // off drawing still waiting for its continuation keeps the turn going) const isTurnActive = () => busyRef.current || preparingSendRef.current || continuationOriginalRef.current !== null // Sending, version actions: not during an answer, nor while leaving const canStartTurn = () => !isTurnActive() && !leavingRef.current // Message IDs loaded from a session (skip animations and replays) const loadedMessageIdsRef = useRef>(new Set()) useDiagramStreaming({ messages, processedToolCallsRef, editDiagramOriginalXmlRef, loadedMessageIdsRef, }) const { restoreVersion, undoVersion } = useVersions({ currentTurnRef, isBusy, isTurnActive: () => !canStartTurn(), getChatGeneration: sessionManager.getChatGeneration, }) // --------------------------------------------------------------------- // Selection sent with the next message // --------------------------------------------------------------------- const selection = useCanvasStore((s) => s.selection) // The model sees and edits the first page only const onFirstPage = useCanvasStore( (s) => s.pages.length === 0 || s.pages[0]?.id === s.currentPageId, ) const [dismissedSelectionKey, setDismissedSelectionKey] = useState("") const selectionKey = selection.map((c) => c.id).join(",") const chatSelection = !onFirstPage || selectionKey === dismissedSelectionKey ? [] : selection // Once nothing is selected, picking the same shapes again attaches them useEffect(() => { if (selection.length === 0) setDismissedSelectionKey("") }, [selection.length]) const dismissSelection = useCallback( () => setDismissedSelectionKey(selectionKey), [selectionKey], ) // --------------------------------------------------------------------- // Sessions // --------------------------------------------------------------------- const messagesRef = useRef(messages) useEffect(() => { messagesRef.current = messages }, [messages]) // Last synced session ID, to detect external changes (URL back/forward) const lastSyncedSessionIdRef = useRef(null) // Message arrays of our own saves. A session holding one of them was // created by our own save, so it must not be treated as an external // switch (with two saves of a new chat at once, the first creates it). const savedMessagesRef = useRef(new WeakSet()) // Session was just loaded (skip the auto-save that would reorder it) const justLoadedSessionRef = useRef(false) const abandonContinuationRef = useRef(abandonContinuation) abandonContinuationRef.current = abandonContinuation // The chat on screen is left while its answer comes in: the answer stops // (also a screenshot check), and its calls are done with, so a render // that still shows its messages does not keep the next chat's diagram // as the one from before their previews const stopAnswerOfChatLeft = useCallback(() => { stopCountRef.current++ cancelValidation() stopRef.current() // As Stop does: a cut off drawing waits for no continuation abandonContinuationRef.current() const last = messagesRef.current[messagesRef.current.length - 1] for (const part of (last?.parts ?? []) as any[]) { if (part.toolCallId) processedToolCallsRef.current.add(part.toolCallId) } }, [cancelValidation]) // Counts the chats put on screen; the state changes in the render that // shows the chat's messages, which can come a render after its session const shownChatsRef = useRef(0) const [shownChats, setShownChats] = useState(0) // The messages shown when another chat was put on screen: useChat can // show that chat's own up to 150 ms later than the rest of its state const messagesBeforeShownRef = useRef(null) const syncUIWithSession = useCallback( ( data: | (SessionData & { diagramHistory?: { svg: string; xml: string }[] }) | null, ) => { // An answer still coming in belongs to the chat that was on // screen (browser back or forward): it stops, before this chat's // messages are put in its place, so none of it lands here if (busyRef.current) stopAnswerOfChatLeft() // They act on the chat that was on screen: its canvas (Undo of // a restore) or leaving it unsaved, and so does its title // waiting for a session toast.dismiss("version-restored") toast.dismiss("session-save-leave") setPendingTitle(null) // A saved chat shows its canvas (it can hold just a blank // file); one with nothing in it, or none, shows the start screen useUiStore .getState() .setHeroDismissed( !!data && (data.messages.length > 0 || (!!data.diagramXml && data.diagramXml !== EMPTY_DRAWIO_DOCUMENT)), ) // Its versions are not this chat's (old ones share their ids) useUiStore.getState().closeCompare() // Comes with this chat's state, in the same render shownChatsRef.current++ setShownChats(shownChatsRef.current) messagesBeforeShownRef.current = messagesRef.current // Diagrams from before a preview of the chat left: saves and an // undone preview of this chat must not bring them here. A Stop // there is not one here either. editDiagramOriginalXmlRef.current.clear() continuationOriginalRef.current = null stoppedRef.current = false if (data) { loadedMessageIdsRef.current = new Set( (data.messages as { id: string }[]).map((m) => m.id), ) setMessages(data.messages as unknown as UIMessage[]) xmlSnapshotsRef.current = new Map(data.xmlSnapshots) if (data.diagramXml) { onDisplayChart(data.diagramXml, true) chartXMLRef.current = data.diagramXml } else { clearDiagram() chartXMLRef.current = "" } if (!isRealDiagram(data.diagramXml)) latestSvgRef.current = "" useVersionsStore .getState() .setVersions(getSessionVersions(data)) } else { loadedMessageIdsRef.current = new Set() setMessages([]) xmlSnapshotsRef.current.clear() clearDiagram() chartXMLRef.current = "" latestSvgRef.current = "" useVersionsStore.getState().clear() } }, [setMessages, onDisplayChart, clearDiagram, stopAnswerOfChatLeft], ) const buildSessionData = useCallback( async ( options: { withThumbnail?: boolean } = {}, ): Promise => { // Read together, before the wait for the thumbnail: what changes // meanwhile goes into the next save, not half into this one. // A drawing still streaming (the tab is hidden mid-answer) is // not saved: the diagram before its preview is. const [previewOriginal] = editDiagramOriginalXmlRef.current.values() const currentDiagramXml = continuationOriginalRef.current ?? previewOriginal ?? (chartXMLRef.current || "") const messages = sanitizeMessages(messagesRef.current) savedMessagesRef.current.add(messages) const data = { messages, xmlSnapshots: Array.from(xmlSnapshotsRef.current.entries()), diagramXml: currentDiagramXml, versions: useVersionsStore.getState().versions, title: pendingTitleRef.current ?? undefined, } let thumbnailDataUrl: string | undefined if (isRealDiagram(currentDiagramXml) && options.withThumbnail) { const freshThumb = await getThumbnailSvg() if (freshThumb) { latestSvgRef.current = freshThumb thumbnailDataUrl = freshThumb } else if (latestSvgRef.current) { thumbnailDataUrl = latestSvgRef.current } } return { ...data, thumbnailDataUrl } }, [getThumbnailSvg], ) // Restore from the session manager once it has loaded useLayoutEffect(() => { if (hasRestoredRef.current) return if (sessionManager.isLoading) return hasRestoredRef.current = true try { const currentSession = sessionManager.currentSession if (currentSession) { justLoadedSessionRef.current = true syncUIWithSession(currentSession) } else { // A page of another language comes up empty: the versions // of the chat left unsaved are not this one's useVersionsStore.getState().clear() } lastSyncedSessionIdRef.current = sessionManager.currentSessionId } catch (error) { console.error("Failed to restore session:", error) toast.error(dict.errors.sessionCorrupted) } finally { setIsRestored(true) } }, [ sessionManager.isLoading, sessionManager.currentSession, syncUIWithSession, dict.errors.sessionCorrupted, ]) // Sync UI when the session changes externally (URL back/forward) useEffect(() => { if (!isRestored) return if (!sessionManager.isAvailable) return const newSessionId = sessionManager.currentSessionId const newSession = sessionManager.currentSession if (newSessionId === lastSyncedSessionIdRef.current) return const isOwnNewSession = !!newSession && savedMessagesRef.current.has(newSession.messages) lastSyncedSessionIdRef.current = newSessionId if (isOwnNewSession) { // Renamed while that first save ran: it has the old title const title = pendingTitleRef.current setPendingTitle(null) if (title && title !== newSession.title) { sessionManager.renameSession(newSession.id, title) } return } justLoadedSessionRef.current = true syncUIWithSession(newSession) }, [ isRestored, sessionManager.isAvailable, sessionManager.currentSessionId, sessionManager.currentSession, syncUIWithSession, ]) const { isAvailable: sessionIsAvailable, currentSessionId, saveCurrentSession, getChatGeneration, getSaveTicket, } = sessionManager // Ref avoids re-running the save effect after every save const saveCurrentSessionRef = useRef(saveCurrentSession) saveCurrentSessionRef.current = saveCurrentSession const versions = useVersionsStore((s) => s.versions) // Debounced auto-save, only when not streaming useEffect(() => { if (!hasRestoredRef.current) return if (!sessionIsAvailable) return if (status === "streaming" || status === "submitted") return if (justLoadedSessionRef.current) { // Not before the render with the loaded chat's own state, its // messages included if (shownChats !== shownChatsRef.current) return if (messages === messagesBeforeShownRef.current) return justLoadedSessionRef.current = false // Shown as loaded: nothing to save. A message sent before they // were shown is a change of its own. const loaded = loadedMessageIdsRef.current if ( messages.length === loaded.size && messages.every((m) => loaded.has(m.id)) ) { return } } if (saveDebounceRef.current) clearTimeout(saveDebounceRef.current) // Capture the chat on screen at schedule time; the save is dropped // if another chat is on screen by the time it runs const scheduledForChat = getChatGeneration() const hasDiagramNow = isRealDiagram(chartXMLRef.current) const isNoDiagramSession = justLoadedSessionIdRef.current === currentSessionId saveDebounceRef.current = setTimeout(async () => { try { // Also a chat with just a title (an opened blank file) if ( messagesRef.current.length > 0 || hasDiagramNow || pendingTitleRef.current !== null ) { // Taken before the data is read, for the chat it was // scheduled for const ticket = { ...getSaveTicket(), generation: scheduledForChat, } const sessionData = await buildSessionData({ withThumbnail: hasDiagramNow && !isNoDiagramSession, }) await saveCurrentSessionRef.current(sessionData, ticket) } } catch (error) { console.error("Failed to save session:", error) } }, SAVE_DEBOUNCE_MS) return () => { if (saveDebounceRef.current) clearTimeout(saveDebounceRef.current) } }, [ chartXML, messages, versions, status, sessionIsAvailable, currentSessionId, getChatGeneration, getSaveTicket, buildSessionData, // A title typed for an empty chat saves it too pendingTitle, shownChats, ]) // Put the session in the URL once it exists. Not while the chat is left: // the save just gave it a session, and this URL would win over the one // of what comes next (another chat, a new one, another language). useEffect(() => { if (leavingRef.current) return if (sessionManager.currentSessionId && !urlSessionId) { router.replace(`?session=${sessionManager.currentSessionId}`, { scroll: false, }) } }, [sessionManager.currentSessionId, urlSessionId, router]) // Save when the page is hidden (more reliable than beforeunload for IndexedDB) useEffect(() => { if (!sessionManager.isAvailable) return const handleVisibilityChange = async () => { if ( document.visibilityState === "hidden" && (messagesRef.current.length > 0 || isRealDiagram(chartXMLRef.current) || pendingTitleRef.current !== null) ) { try { const ticket = sessionManager.getSaveTicket() const sessionData = await buildSessionData({ withThumbnail: false, }) await sessionManager.saveCurrentSession(sessionData, ticket) } catch (error) { console.error( "Failed to save session on visibility change:", error, ) } } } document.addEventListener("visibilitychange", handleVisibilityChange) return () => document.removeEventListener( "visibilitychange", handleVisibilityChange, ) }, [sessionManager, buildSessionData]) // --------------------------------------------------------------------- // Sending // --------------------------------------------------------------------- const isExtractingAttachments = files.some((f) => pdfData.get(f)?.isExtracting) || Array.from(urlData.values()).some((d) => d.isExtracting) // Append PDF, text file and URL content to the user's text; images // become file parts when imageParts is given const processFilesAndAppendContent = async ( baseText: string, imageParts?: any[], ): Promise => { let userText = baseText for (const file of files) { if (isPdfFile(file)) { const extracted = pdfData.get(file) if (extracted?.text) { userText += `\n\n[PDF: ${file.name}]\n${extracted.text}` } } else if (isTextFile(file)) { const extracted = pdfData.get(file) if (extracted?.text) { userText += `\n\n[File: ${file.name}]\n${extracted.text}` } } else if (imageParts) { const reader = new FileReader() const dataUrl = await new Promise((resolve, reject) => { reader.onload = () => resolve(reader.result as string) reader.onerror = () => reject( new FileReadError( formatMessage(dict.errors.fileReadFailed, { name: file.name, }), ), ) reader.readAsDataURL(file) }) imageParts.push({ type: "file", url: dataUrl, mediaType: file.type, }) } } for (const [url, data] of urlData) { if (data.content) { userText += `\n\n[URL: ${url}]\nTitle: ${data.title}\n\n${data.content}` } } return userText } const getPreviousXml = (beforeIndex: number): string => { const snapshotKeys = Array.from(xmlSnapshotsRef.current.keys()) .filter((k) => k < beforeIndex) .sort((a, b) => b - a) return snapshotKeys.length > 0 ? getFirstPageXml( xmlSnapshotsRef.current.get(snapshotKeys[0]) || "", ) : "" } const sendChatMessage = ( parts: any, xml: string, previousXml: string, turnIndex: number, selectedCells: MessageSelection, ) => { autoRetryCountRef.current = 0 continuationRetryCountRef.current = 0 validationRetryCountRef.current = 0 partialXmlRef.current = "" continuationOriginalRef.current = null stoppedRef.current = false currentTurnRef.current = turnIndex turnChatRef.current = getChatGeneration() // Busy from now on, before the next render says so busyRef.current = true // Its Undo would take this answer's diagram away toast.dismiss("version-restored") startTurn() const config = getSelectedAIConfig() const { customSystemMessage, minimalStyle, maxOutputTokens } = useSettingsStore.getState() // For this turn's errors: the provider these headers name (read with // them, from storage: another tab may have changed it meanwhile) requestProviderIdRef.current = config.providerId || undefined const selected = selectedCells.map(({ id, label }) => ({ id, label })) const options = { body: { xml, previousXml, sessionId: langfuseSessionId, customSystemMessage, ...(selected.length > 0 && { selectedCells: selected }), }, headers: buildChatHeaders(config, { minimalStyle, maxOutputTokens, }), } // The selection stays with the message, for regenerate and retry sendMessage( { parts, ...(selected.length > 0 && { metadata: { selectedCells: selected }, }), }, options, ) } // Export the current diagram, snapshot it for this message, and send. // False when another chat came on screen meanwhile (browser back or // forward), also since `generation` (when the send began): nothing is // sent. const sendWithCurrentDiagram = async ( parts: any[], selectedCells: MessageSelection = [], generation = getChatGeneration(), ): Promise => { const chartXml = formatXML(await onFetchChart()) if (getChatGeneration() !== generation || leavingRef.current) { return false } const turnIndex = messagesRef.current.length const previousXml = getPreviousXml(turnIndex) // Snapshot the full multi-page document (kept fresh by autosave) so // regenerate/edit can restore every page; the model gets page 1 only xmlSnapshotsRef.current.set(turnIndex, chartXMLRef.current || chartXml) sendChatMessage(parts, chartXml, previousXml, turnIndex, selectedCells) return true } const clearComposer = () => { setInput("") setFiles([]) setUrlData(new Map()) } // Whether the message was sent const submitInput = async (overrideText?: string): Promise => { const text = overrideText ?? input // busyRef: also when called after a wait (a template click) if (!text.trim() || busyRef.current || isExtractingAttachments) { return false } // The first message is typed on the start screen: its answer shows // in the panel, also when the panel was closed before if (messagesRef.current.length === 0) { useUiStore.getState().setPanelOpen(true) } // Cached example: only with no messages and an empty canvas (same // rule as the server) if ( messagesRef.current.length === 0 && !hasCells(chartXMLRef.current || "") ) { // The file name keeps a user's own file from matching an example const cached = findCachedResponse( text.trim(), files.length > 0, files.length === 1 ? files[0].name : undefined, ) if (cached) { const toolCallId = `cached-${Date.now()}` const userText = await processFilesAndAppendContent(text) currentTurnRef.current = 0 startTurn() setMessages([ { id: `user-${Date.now()}`, role: "user" as const, parts: [{ type: "text" as const, text: userText }], }, { id: `assistant-${Date.now()}`, role: "assistant" as const, parts: [ { type: "tool-display_diagram" as const, toolCallId, state: "output-available" as const, input: { xml: cached.xml }, output: "Successfully displayed the diagram.", }, ], }, ] as any) // Snapshot the canvas before the example so editing this message works xmlSnapshotsRef.current.set( 0, chartXMLRef.current || BLANK_MXFILE, ) // Load its diagram here: these messages never reach the // tool handler const prepared = prepareNewDiagram(cached.xml, { pageId: "page-1", pageName: "Page-1", }) if (prepared.ok) { onDisplayChart( keepFileVars(prepared.xml, chartXMLRef.current || ""), true, "commit", { toolCallId }, ) } clearComposer() return true } } // Another chat can come on screen while the attachments are read or // the diagram exports (browser back or forward): then nothing is sent const generation = getChatGeneration() const sentFiles = files const sentUrls = urlData const sentSelectionKey = selectionKey let sent = false try { const parts: any[] = [] // The backend only reads the first text part, so combine them const userText = await processFilesAndAppendContent(text, parts) parts.unshift({ type: "text", text: userText }) if (getChatGeneration() === generation) { // Clear right away, so text typed while the diagram exports // is kept clearComposer() sent = await sendWithCurrentDiagram( parts, chatSelection, generation, ) } } catch (error) { console.warn("Error preparing the message:", error) toast.error( error instanceof FileReadError ? error.message : dict.errors.failedToExport, ) } if (sent) { // The selected shapes went with it, if they are still the ones // selected. Not before: a message that is not sent keeps them for // the next try, and a selection taken off or changed meanwhile // stays as it is. const nowSelected = useCanvasStore .getState() .selection.map((c) => c.id) .join(",") if (nowSelected === sentSelectionKey) { setDismissedSelectionKey(sentSelectionKey) } return true } // Nothing was sent: give the message back, unless something new // was typed or attached meanwhile if ( !inputRef.current && filesRef.current.length === 0 && urlDataRef.current.size === 0 ) { setInput(text) setFiles(sentFiles) // Also not over links attached in the same moment setUrlData((current) => (current.size > 0 ? current : sentUrls)) } return false } const submit = async (overrideText?: string): Promise => { // While a send is prepared (attachments read, diagram exported) the // status is still "ready": a second Enter or click would send the // message again if (leavingRef.current) return false if (preparingSendRef.current) { toast.error(dict.errors.sendInProgress, { id: "send-in-progress" }) return false } preparingSendRef.current = true try { return await submitInput(overrideText) } finally { preparingSendRef.current = false } } // Templates fill the input and send it right away (attachments included) // Whether it was sent (it can run after a wait: the template panel // stores its click count first, so busy is read from the ref) const sendTemplate = async (prompt: string): Promise => { // Like the send button, it waits for an answer or a message being // prepared (that message may still come back to the input), and for // attachments still being read; the input keeps what it has if (leavingRef.current) return false if (busyRef.current) { toast.error(dict.errors.answerInProgress, { id: "template-not-sent", }) return false } if (preparingSendRef.current) { toast.error(dict.errors.sendInProgress, { id: "send-in-progress" }) return false } if (isExtractingAttachments) { toast.error(dict.errors.attachmentsStillReading, { id: "template-not-sent", }) return false } setInput(prompt) return submit(prompt) } const improveWithSuggestions = async (feedback: string) => { if (!canStartTurn()) return // Like submit: the diagram export comes first preparingSendRef.current = true try { await sendWithCurrentDiagram([{ type: "text", text: feedback }]) } catch (error) { console.error("Error fetching chart data:", error) toast.error(dict.errors.failedToExport) } finally { preparingSendRef.current = false } } const restoreDiagramFromSnapshot = (savedXml: string) => { onDisplayChart(savedXml, true) chartXMLRef.current = savedXml } // Snapshots of the messages after this one go with them const dropLaterSnapshots = (messageIndex: number) => { for (const key of xmlSnapshotsRef.current.keys()) { if (key > messageIndex) xmlSnapshotsRef.current.delete(key) } } // Send the user message at index again with these parts, from the // diagram it was sent with const rerunFrom = (index: number, parts: any[]): boolean => { const savedXml = xmlSnapshotsRef.current.get(index) if (!savedXml) { console.error("No saved XML snapshot for message index:", index) toast.error(dict.errors.cannotResend) return false } const previousXml = getPreviousXml(index) restoreDiagramFromSnapshot(savedXml) dropLaterSnapshots(index) useVersionsStore.getState().removeFromTurn(index) // Remove the user message onwards (sendMessage re-adds it). For a // moment there may be no message: that is no empty chat. flushSync(() => { setIsResending(true) setMessages(messages.slice(0, index)) }) sendChatMessage( parts, getFirstPageXml(savedXml), previousXml, index, selectionOf(messages[index]), ) return true } const regenerate = async (messageIndex: number) => { if (!canStartTurn()) return let userMessageIndex = messageIndex - 1 while ( userMessageIndex >= 0 && messages[userMessageIndex].role !== "user" ) { userMessageIndex-- } if (userMessageIndex < 0) return const userParts = messages[userMessageIndex].parts if (!userParts?.some((p: any) => p.type === "text")) return rerunFrom(userMessageIndex, userParts) } const editMessage = async (messageIndex: number, newText: string) => { if (!canStartTurn()) return false const message = messages[messageIndex] if (message?.role !== "user") return false // The edit box only shows the typed text; keep appended file content const newParts = message.parts?.map((part: any) => { if (part.type === "text") { const appended = part.text.match(APPENDED_FILE_SECTIONS_PATTERN)?.[0] ?? "" return { ...part, text: newText + appended } } return part }) || [{ type: "text", text: newText }] return rerunFrom(messageIndex, newParts) } /** * Send the last user message again (after an error) with the canvas as * it is now: the failed answer's preview was undone, so what it shows * are the user's edits since and the versions that answer drew (they * stay in the strip). The message keeps the snapshot it was first sent * with, for a later regenerate. */ const retryLastMessage = async () => { if (!canStartTurn()) return const index = messagesRef.current.map((m) => m.role).lastIndexOf("user") if (index < 0) return const message = messagesRef.current[index] const generation = getChatGeneration() // Like submit: the diagram export comes first preparingSendRef.current = true try { const chartXml = formatXML(await onFetchChart()) // Another chat came on screen meanwhile (browser back/forward), // or this one is being left if (getChatGeneration() !== generation || leavingRef.current) { return } dropLaterSnapshots(index) if (!xmlSnapshotsRef.current.has(index)) { xmlSnapshotsRef.current.set( index, chartXMLRef.current || chartXml, ) } flushSync(() => { setIsResending(true) setMessages(messagesRef.current.slice(0, index)) }) sendChatMessage( message.parts, chartXml, getPreviousXml(index), index, selectionOf(message), ) } catch (error) { console.error("Error fetching chart data:", error) toast.error(dict.errors.failedToExport) } finally { preparingSendRef.current = false } } const handleStop = useCallback(() => { stoppedRef.current = true stopCountRef.current++ // A running screenshot check holds up the chat (the SDK waits for // the tool handler): end it, so the call gets its result now cancelValidation() const lastMessage = messages[messages.length - 1] // Calls the tool handler already took can still show as streaming: // the messages update at most every 150 ms (useChat throttle) const toolParts = lastMessage?.parts?.filter( (part: any) => part.type?.startsWith("tool-") && part.state === "input-streaming" && !processedToolCallsRef.current.has(part.toolCallId), ) for (const part of (toolParts ?? []) as any[]) { if (part.toolCallId) { addToolOutputSafely({ tool: part.type.replace("tool-", ""), toolCallId: part.toolCallId, state: "output-error", errorText: "Stopped by user", }) } } stop() abandonContinuation() }, [messages, addToolOutputSafely, stop, cancelValidation]) // --------------------------------------------------------------------- // Session actions // --------------------------------------------------------------------- // The current chat could not be saved (storage full). The list where // old chats can be deleted shows only in an empty chat, so let the user // go on without saving. It replaces the plain message, and has its own // id so a later failed auto-save does not take its button away. const offerToContinueUnsaved = useCallback( (proceed: () => void) => { toast.dismiss("session-save-failed") toast.error(dict.errors.sessionSaveFailedLeave, { id: "session-save-leave", duration: 15000, action: { label: dict.errors.continueWithoutSaving, onClick: proceed, }, }) }, [dict], ) // A new turn makes the offer stale: going on would clear the chat while // the answer streams in useEffect(() => { if (isBusy) toast.dismiss("session-save-leave") }, [isBusy]) // Save the chat on screen before leaving it (also a diagram drawn // without messages, or a chat with just a title, like an opened blank // file). If that failed (storage full), stay on it unless the user goes // on without saving: then proceed runs later. Resolves to whether the // caller can leave now. const saveBeforeLeaving = async (proceed: () => void) => { if (!sessionManager.isAvailable) return true if ( messagesRef.current.length === 0 && !isRealDiagram(chartXMLRef.current) && pendingTitleRef.current === null ) { return true } // Of the chat on screen now, also if another one comes on screen // while the thumbnail is taken const ticket = sessionManager.getSaveTicket() const sessionData = await buildSessionData({ withThumbnail: true }) if (!(await sessionManager.saveCurrentSession(sessionData, ticket))) { offerToContinueUnsaved(proceed) return false } return true } // Browser back or forward puts another chat on screen without the app's // own leaving: this one is saved first, with what was sent and answered // so far, its answer stopped. A call left unfinished is saved as // stopped. No thumbnail: taking one would hold the switch for seconds // (the old one stays). // Meanwhile it is being left: nothing can be sent (a message being // prepared is given back to the input instead) const saveBeforeUrlSwitch = () => whileLeaving(async () => { const wasAnswering = busyRef.current const save = sessionManager.isAvailable && (messagesRef.current.length > 0 || isRealDiagram(chartXMLRef.current) || pendingTitleRef.current !== null) // Read before the stop: it puts the diagram from before a cut // off drawing back, on the canvas only a moment later const ticket = save ? sessionManager.getSaveTicket() : null const reading = save ? buildSessionData() : null if (wasAnswering) stopAnswerOfChatLeft() if (!ticket || !reading) return const data = await reading const last = data.messages[data.messages.length - 1] if (wasAnswering && last?.role === "assistant") { // A call with a version drew its diagram (the messages // shown can be a moment behind); the others were stopped const drawn = new Set( useVersionsStore .getState() .versions.map((v) => v.toolCallId), ) const ended = (part: any) => { if ( !part.toolCallId || (part.state !== "input-streaming" && part.state !== "input-available") ) { return part } if (drawn.has(part.toolCallId)) { return { ...part, state: "output-available", output: part.type === "tool-edit_diagram" ? "Successfully applied the operations to the diagram." : "Successfully displayed the diagram.", } } return { ...part, state: "output-error", errorText: "Stopped by user", } } const messages = [ ...data.messages.slice(0, -1), { ...last, parts: last.parts.map(ended) }, ] // Saved by this chat itself (the effect that follows // session changes must not put it on screen again) savedMessagesRef.current.add(messages) data.messages = messages } await sessionManager.saveCurrentSession(data, ticket) }) const { setBeforeUrlSwitch } = sessionManager useEffect(() => { setBeforeUrlSwitch(saveBeforeUrlSwitch) return () => setBeforeUrlSwitch(null) }) // Leaving the chat on screen: no answer can start until it is done // (Can run inside another: browser back while a file is being opened) const leavingCountRef = useRef(0) const whileLeaving = async (fn: () => unknown) => { leavingCountRef.current++ leavingRef.current = true // The composer shows it: what is typed now could not be sent setIsLeaving(true) try { await fn() } finally { leavingCountRef.current-- if (leavingCountRef.current === 0) { leavingRef.current = false setIsLeaving(false) } } } const selectSession = async (id: string) => { if (!sessionManager.isAvailable || !canStartTurn()) return const open = async () => { if (isTurnActive()) return const sessionData = await sessionManager.switchSession(id) if (!sessionData) { // That chat is gone (deleted in another tab): this one // stays, and the save before may have just given it a // session, which the URL skipped while leaving const current = sessionManager.getCurrentSessionId() if (current) { router.replace(`?session=${current}`, { scroll: false }) } await sessionManager.refreshSessions() return } const hasRealDiagram = isRealDiagram(sessionData.diagramXml) justLoadedSessionRef.current = true // Use the new session's thumbnail, never the previous one's latestSvgRef.current = sessionData.thumbnailDataUrl || "" justLoadedSessionIdRef.current = hasRealDiagram ? null : id // Shown here: the effect that follows session changes must not // show it again (that would save it, first in the list) lastSyncedSessionIdRef.current = id setValidationStates({}) syncUIWithSession(sessionData) router.replace(`?session=${id}`, { scroll: false }) } // Going on without saving (storage full) is a leave of its own const proceed = () => whileLeaving(open) // A save meanwhile over the chat limit keeps the chat opened (also // an auto-save waiting for its thumbnail) await whileLeaving(() => sessionManager.whileOpening(id, async () => { if (await saveBeforeLeaving(proceed)) await open() }), ) } const deleteSession = useCallback( async (id: string) => { if (!sessionManager.isAvailable) return const isCurrent = id === sessionManager.currentSessionId if (isCurrent && !canStartTurn()) return const remove = async () => { const result = await sessionManager.deleteSession(id) if (result.wasCurrentSession) { syncUIWithSession(null) router.replace(pathname, { scroll: false }) } } if (isCurrent) await whileLeaving(remove) else await remove() }, [sessionManager, syncUIWithSession, router, pathname, isBusy], ) // Clear everything for a new conversation (the old one is saved) const startNewChat = () => { // Clear session state BEFORE the URL, so the URL effect does not // bring the old session back sessionManager.clearCurrentSession() toast.dismiss("version-restored") toast.dismiss("session-save-leave") // A new conversation brings the start screen back useUiStore.getState().setHeroDismissed(false) setMessages([]) setInput("") clearDiagram() useVersionsStore.getState().clear() setValidationStates({}) handleFileChange([]) setUrlData(new Map()) setLangfuseSessionId(newLangfuseSessionId()) xmlSnapshotsRef.current.clear() setPendingTitle(null) router.replace(pathname, { scroll: false }) } const newChat = async () => { if (!canStartTurn()) return const start = () => { if (isTurnActive()) return startNewChat() toast.success(dict.dialogs.clearSuccess) focusComposer() } await whileLeaving(async () => { if (!(await saveBeforeLeaving(start))) return // The list shows the chat just saved if (sessionManager.isAvailable) { await sessionManager.refreshSessions() } start() }) } const renameSession = useCallback( async (title: string) => { const id = sessionManager.currentSessionId if (!id) { // Not saved yet: use the title when the session is created setPendingTitle(title.trim().slice(0, 100) || null) return } await sessionManager.renameSession(id, title) }, [sessionManager], ) const leavePage = async (go: (sessionId: string | null) => void) => { if (!canStartTurn()) return // Also run later, from the "continue without saving" offer: not // while an answer started meanwhile (or is being prepared) const proceed = () => { if (isTurnActive()) return go(sessionManager.getCurrentSessionId()) } await whileLeaving(async () => { if (await saveBeforeLeaving(proceed)) proceed() }) } const openDiagramFile = async (file: File) => { if (!canStartTurn()) return await whileLeaving(() => openFile(file)) } const openFile = async (file: File) => { const xml = await readDiagramFile(file).catch(() => null) if (!xml) { toast.error( formatMessage(dict.errors.notADiagramFile, { name: file.name, }), ) return } // A loaded file starts a fresh conversation named after it const open = () => { if (isTurnActive()) return startNewChat() // This empty chat is ours: the effect that follows session // changes must not clear the file it is about to show lastSyncedSessionIdRef.current = null setPendingTitle( file.name.replace(/\.(drawio\.svg|drawio|xml|svg)$/i, ""), ) onDisplayChart(xml, true) // The file is on screen, also one without shapes yet useUiStore.getState().setHeroDismissed(true) } // Going on without saving (storage full) is a leave of its own const proceed = () => whileLeaving(open) if (await saveBeforeLeaving(proceed)) open() } const engine: ChatEngine = { messages, status, error, isBusy, isLeaving, isResending, setMessages: setMessages as (messages: UIMessage[]) => void, stop: handleStop, input, setInput, files, pdfData, setFiles: handleFileChange, urlData, setUrlData, isExtractingAttachments, submit, sendTemplate, improveWithSuggestions, regenerate, retryLastMessage, editMessage, chatSelection, dismissSelection, sessions: sessionManager.sessions, currentSessionId: sessionManager.currentSessionId, currentTitle: sessionManager.currentSession?.title ?? pendingTitle, isRestored, newChat, selectSession, deleteSession, renameSession, openDiagramFile, leavePage, restoreVersion, undoVersion, langfuseSessionId, loadedMessageIdsRef, stoppedRef, validationStates, modelConfig, } return ( {children} ) }