Files
next-ai-draw-io/components/chat/chat-engine.tsx
T
dayuan.jiang 69fb66804d feat(settings): reorganize the settings dialog and the model settings
Tabs Models, General, Drawing and About, each with a fixed title row.

The models tab becomes three pages: the user's providers with one status
each and the server's models; a searchable picker of all 24 providers in
four groups; and a provider page in three steps (connection, models,
test). A new provider gets its first suggested model, and a passing test
offers to use the model in the chat. Bedrock asks for one way of signing
in, with an optional session token for temporary access keys.

The composer's model picker lists every model, marking untested and
failed ones, and can open the provider picker. A chat error that offers
model settings opens the provider the request went to.
2026-10-08 17:44:08 +09:00

1964 lines
76 KiB
TypeScript

"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("<mxfile")) return xml
const doc = new DOMParser().parseFromString(xml, "text/xml")
const model = doc.querySelector("diagram")?.querySelector("mxGraphModel")
return model ? formatXML(new XMLSerializer().serializeToString(model)) : xml
}
// Shapes sent with a user message (also kept in its metadata)
type MessageSelection = Pick<SelectedCell, "id" | "label">[]
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]*?-->|<!DOCTYPE[^>[]*(?:\[[\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<string | null> {
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<string>()
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<File, FileData>
setFiles: (files: File[]) => void
urlData: Map<string, UrlData>
setUrlData: React.Dispatch<React.SetStateAction<Map<string, UrlData>>>
isExtractingAttachments: boolean
/** Whether the message was sent */
submit: (overrideText?: string) => Promise<boolean>
/** Whether it was sent */
sendTemplate: (prompt: string) => Promise<boolean>
improveWithSuggestions: (feedback: string) => Promise<void>
regenerate: (messageIndex: number) => Promise<void>
retryLastMessage: () => Promise<void>
/** False when nothing was sent (another answer is being prepared) */
editMessage: (messageIndex: number, newText: string) => Promise<boolean>
/** 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<void>
selectSession: (id: string) => Promise<void>
deleteSession: (id: string) => Promise<void>
renameSession: (title: string) => Promise<void>
openDiagramFile: (file: File) => Promise<void>
/**
* 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<void>
restoreVersion: (versionId: string) => void
undoVersion: (versionId: string) => void
langfuseSessionId: string
loadedMessageIdsRef: React.RefObject<Set<string>>
/** Stop was pressed in the turn on screen (until the next message) */
stoppedRef: React.RefObject<boolean>
validationStates: Record<string, ValidationState>
modelConfig: UseModelConfigReturn
}
const ChatEngineContext = createContext<ChatEngine | null>(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<string>((resolve) => {
if (tag) exportResolversRef.current[tag] = resolve
}),
new Promise<string>((_, 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<Map<string, UrlData>>(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<string | null>(null)
const pendingTitleRef = useRef<string | null>(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<Map<number, string>>(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<string | null>(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<string>("")
// Diagram before a cut off drawing, whose half drawn preview stays on
// the canvas while append_diagram finishes it
const continuationOriginalRef = useRef<string | null>(null)
// Tool calls already applied, so remounts never replay old outputs
const processedToolCallsRef = useRef<Set<string>>(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<string | undefined>(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<Map<string, string>>(new Map())
const saveDebounceRef = useRef<ReturnType<typeof setTimeout> | null>(null)
const [validationStates, setValidationStates] = useState<
Record<string, ValidationState>
>({})
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<string, string>
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<typeof addToolOutput>[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<Set<string>>(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<string | null>(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<object>())
// 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<UIMessage[] | null>(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<SessionData> => {
// 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<string> => {
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<string>((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<boolean> => {
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<boolean> => {
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<boolean> => {
// 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<boolean> => {
// 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 (
<ChatEngineContext.Provider value={engine}>
{children}
</ChatEngineContext.Provider>
)
}