mirror of
https://github.com/DayuanJiang/next-ai-draw-io.git
synced 2026-10-12 04:29:51 +08:00
* refactor(canvas): decouple the canvas from the chat engine
The canvas components will be reused by the MCP server's browser shell,
which has no chat and no Next.js. They now read everything they need
from small contexts and the canvas store instead of the chat engine:
- components/canvas/versions-context.tsx: VersionsProvider and
useVersionsContext give the version cards, the strip and the compare
dialog a VersionsSource (versions, onCanvasId, undoneId, isBusy,
canUndo, canRedo, restore, undo, redo). The chat fills it from the
versions store and the engine in components/chat/chat-versions.tsx.
- stores/canvas-store.ts: isBusy and busyReason; the chat engine sets
them while a turn runs, SelectionAsk and the version UI read them.
- components/canvas/version-card.tsx: the visual version card and the
thumbnail, out of tool-activity.tsx; the chat's card composes it and
adds its "Show XML" link and code panel.
- compare-dialog.tsx and version-strip.tsx move to components/canvas;
the strip takes the minimum number of versions to show as a prop, the
chat panel computes it from the cards it has.
- components/canvas/locale-context.tsx: LocaleProvider and useLocale,
fed by the [lang] layout; CanvasStage no longer uses next/navigation.
The overlay (SelectionAsk) is a slot and the wait for the saved
language is a prop, so the shell can leave both out.
- lib/version-text.ts: describeChanges is now describeChangeSummary, so
it can be imported next to the MCP core's describeChanges.
* test(unit): canvas import boundary and the shared version card
The boundary test scans the canvas modules' imports and bundles the canvas components with esbuild; both fail on anything from components/chat, the tool handlers, next/navigation, next/font, next/script, next/headers or a server-only module. The card test renders VersionCard and VersionStrip on a fake VersionsSource.
* refactor(scripts): pin the draw.io release in one json file and share the zip reader
* feat(mcp-server): bundle a trimmed draw.io into dist/drawio at build time
scripts/fetch-drawio.mjs downloads the pinned draw.war into a cache
(DRAWIO_WAR_CACHE, or DRAWIO_WAR for a local file), checks its SHA-256
and extracts the files named in drawio-files.txt plus a LICENSE with the
Apache-2.0 text. scripts/check-drawio-files.mjs drives the embedded
editor in headless Chromium from a full copy, records every requested
file and checks or rewrites (--update) the list.
* feat(mcp-server): serve the bundled draw.io same-origin behind an api token
GET /drawio/<path> serves dist/drawio with a MIME table, a day of
caching and nosniff; paths are normalized and never reach WEB-INF or
META-INF. The preview embeds /drawio/index.html when the copy exists and
DRAWIO_BASE_URL is unset, else the external draw.io as before (and
start_session says so). Every /api request must carry the per-process
X-Drawio-Token the page gets in its HTML; pages send
frame-ancestors 'self' and nosniff.
* build(mcp-server): require the bundled draw.io in the package and cap the tarball at 40 MB
* fix(mcp-server): review fixes for the preview token and the draw.io static files
- The preview page fetches a fresh copy of itself and retries once when an
API request is refused with 403: another MCP process, with its own token,
now answers on this port, and the recovery logic (recoverState) needs its
polls to go through. The page is sent with Cache-Control: no-store.
- draw.io files are served with an ETag and Cache-Control: no-cache instead
of a 24 hour max-age: their names do not change between versions, so a
package upgrade must reach the browser on the next preview. HEAD and
If-None-Match (304) are answered.
- The file read stream goes through stream.pipeline, so a read error no
longer ends the MCP process and a client that leaves mid-download no
longer leaks the file handle.
* fix(mcp-server): review fixes for the draw.io file list and its guard
- The list now ships the templates of Insert > Template, the PlantUML
parser of Insert > Advanced and the template dialog's icon: the menu
items were shown but failed with 404s. 15.1 MB packed; the tarball cap
goes from 40 MB to 20 MB, where it still catches a list that grew by a
whole js/ directory.
- The guard drives both features, fails when the copy it runs against
lacks files the editor asked for or when an export does not answer,
checks that the copy is the pinned draw.io version, and runs in CI
(npm run check-drawio, one E2E shard) so a list regression cannot reach
a release.
* docs: review fixes for the draw.io version pointer and the preview's embedding
- The offline deployment guides point at packages/mcp-server/src/
drawio-version.json for the draw.war version; scripts/fetch-drawio.mjs
no longer names it.
- The MCP README says what the bundled draw.io copy includes, and that the
preview page cannot be shown inside an editor's built-in browser since it
is served with frame-ancestors 'self'.
* refactor(canvas): review fixes for the busy flag, the comparison note and the bundle size check
- busyReason had no reader: the chat engine sets isBusy through the store's
generic set, like every other flag.
- The isSameDocument comment says the two "same document" rules disagree
in both directions, so neither is a subset of the other.
- The import boundary test bundles minified with one pako (the MCP core
resolves its own copy) and caps the canvas core at 160 KB (134 KB now).
* fix(mcp-server): name exports at random and stamp the draw.io version into ETags
- Export requests carried a per-process counter. The preview page retries a
result refused with 403 against the process that took over the port, so a
late result of the old process's export could be taken for the new
process's export with the same number. The id is a random UUID now.
- The ETag of a bundled draw.io file now starts with the version the fetch
script stamps into dist/drawio/.version. An install that keeps the
archive's dates (npm does not) would otherwise answer 304 for a changed
file of the same size after an upgrade.
* docs(mcp-server): mermaid is in the bundled draw.io, which has no image proxy
- Mermaid's converter is in js/extensions.min.js, which the editor loads at
startup, so the README no longer lists it as left out; the org chart
layout (js/orgchart.min.js) is the example of a feature that is.
- Note that the bundled copy, like the web app's, has no /drawio/proxy:
images from other websites, including those in some templates, are left
out of exports and thumbnails. The guard's comment names this as a known
limitation it does not cover.
* refactor(canvas): getDrawioSrc takes the editor's source, and CanvasStage can be given one
* feat(mcp-server): canvas shell bundled from the web app's canvas with esbuild
The shell (packages/mcp-server/shell) renders DrawioFrame inside the
shared providers without the chat: its config comes from
window.__MCP_CONFIG__, the four dictionaries ship in the bundle, the
theme is kept under an mcp: localStorage key, and system fonts stand in
for the web fonts. scripts/build-shell.mjs bundles it into dist/shell
with the root's esbuild and Tailwind; check-package caps shell.js at
1.5 MB and shell.css at 300 KB.
* feat(mcp-server): serve the canvas shell at /shell/ behind DRAWIO_PREVIEW_UI
GET /shell/ fills the page template's {{CONFIG_JSON}} with the session,
the API token, where draw.io comes from and the host's editor settings,
with the classic page's security headers; /shell/<file> serves the built
files like the draw.io copy. start_session opens the shell when
DRAWIO_PREVIEW_UI=shell; the classic page stays the default.
* test(mcp-server): open the built shell with playwright
npm run test:e2e starts dist/index.js over the MCP SDK with
DRAWIO_PREVIEW_UI=shell, opens the URL start_session returns in headless
Chromium, and checks that the bundled draw.io loads without console
errors and that the theme toggle switches draw.io in place.
* ci: install the root dependencies before building the mcp package
The shell build needs the root's esbuild, Tailwind and React.
* feat(canvas): DiagramProvider exports with options and shows a transient document
requestExport(request, timeoutMs) runs one draw.io export with the
request's own parameters (a PNG's pageId and width) and resolves with its
answer (data, or xml for format "xml"); null when draw.io does not answer.
showTransient(xml) puts a document on the canvas for an export only: it is
not recorded, its autosaves are ignored, and the next loadDiagram brings
the user back to the page they were on. The MCP's canvas shell answers the
server's export requests with both.
* feat(mcp-server): the shell syncs with the server like the classic page
shell/mcp-sync-core.ts ports preview.js's protocol without the page or
React: polling GET /api/state every 2 s with the state id and version,
pushes of the user's edits with their base version and state, 409 and 413
handling, recovery of a recreated session (the tab's copy goes to History),
the token refresh after a 403, sync and export requests (PNG by page id,
SVG of another page through a transient one-page projection with autosave
ignored, the 600 ms wait for icons), thumbnails for History, and History
reads and restores that name the state the list belongs to.
A server write goes on the canvas as one undo step (loadDiagram commit,
with the change marked) when decideLoad finds it changes only the page on
screen, keeps the file variables and needs no load-only setting; the other
pages are compared with the MCP core's contentFingerprint. draw.io's own
re-serialized copy of such a write is recognized with isSameDocument and
not pushed back as an edit.
shell/use-mcp-sync.ts mounts the sync once inside DiagramProvider, reads
draw.io's autosave and load messages, and reports the connection state,
which the shell's status bar shows; notices come as toasts from the
dictionaries.
* test(mcp-server): port the preview protocol tests to the sync core
The recovery, thumbnail, size limit and History cases of
tests/unit/mcp-preview-recovery.test.ts, driven against mcp-sync-core.ts
with a stubbed server and canvas, plus the stale 409 recovery, an export
request answered once, a projection that ignores edits and restores the
document, the thumbnail of a write loaded before draw.io was ready, writes
taken in place as commits (draw.io's own copy is not pushed), and the
commit-or-load decision table of decideLoad.
* test(mcp-server): e2e of the shell's sync with the server
The shell connects, shows what create_new_diagram draws, marks an
edit_diagram change and takes it back with one Ctrl+Z (which get_diagram
then reflects), pushes a shape inserted in the editor, serves
screenshot_diagram, and shows another page only for its SVG export before
the user's page comes back.
* fix(mcp-server): review fixes for the shell's sync core
A recovery loads the server's diagram in full and waits for draw.io's
load report, as the classic page does: whether the canvas takes a write
in place is decided inside DiagramProvider, and an autosave of the
canvas being replaced went to the server as an edit when the decision
was wrong. A paper size change alone is pushed again: autosaves are
compared as documents only for draw.io's own copy of a committed write.
A projection stays on screen until draw.io reports the restore load
(5 s at most), the whole export ends in 15 s even when its result POST
hangs, and an export in flight is dropped when draw.io starts over. A
refused poll shows the tab offline. With several pages and the page on
screen unknown, a write loads in full. hasLoadOnlySettings and the
highlight of an AI change are shared with the editor bridge.
* fix(mcp-server): review fixes for the preview URL and the theme menu
The stalled-tab note names the page start_session opened (the shell
with DRAWIO_PREVIEW_UI=shell) instead of the classic page. With the
shell not built, start_session opens the classic page and the server
says so at startup, as it does for a missing dist/drawio. BROWSER=none
skips the system browser, as Vite and CRA dev servers do: the e2e tests
set it. Without a fixed DRAWIO_UI, the shell's draw.io URL carries
themes=1 so Extras > Theme is offered, as on the classic page.
* fix(mcp-server): review fixes for the shell's build
tsc -p shell checked nothing: the inherited exclude dropped every shell
file; the shell's tsconfig now excludes node_modules only. Tailwind scans
app-toaster.tsx too, so the sync notices look like the web app's toasts.
The bundle keeps its dependencies' license comments (esbuild appends them
at the end). postcss, which build-shell.mjs loads from the root, is a
declared devDependency.
* ci: run the shell's browser tests on the packed tarball, publish on shared code changes
The e2e job's first shard builds the MCP package, packs it, installs the
tarball in an empty directory and runs packages/mcp-server/tests/e2e
against the installed dist/index.js (MCP_SERVER_ENTRY), so a shell that
builds but fails in the browser, or a tarball missing a runtime
dependency, fails the PR. Its traces go with the Playwright report. The
publish workflow also triggers on the web app code the shell is bundled
from (components, contexts, hooks, lib, stores, globals.css) and the
root lockfile.
* fix(canvas): ignore autosaves of the document a pending full load replaces
An autosave that arrives after loadDiagram sent a full load, and before draw.io reports it, is of the canvas being replaced (draw.io reports each load, in order). Until now it overwrote chartXMLRef: in the MCP shell, a late autosave of a one-page projection replaced the cached document, and a remount of an external draw.io then put the projection on the canvas as the whole document.
* fix(mcp-server): review fixes for the shell's commits and export timeout
A write taken in place that leaves the canvas as it is gets no autosave from draw.io, so the marker for its copy stayed armed and ate the user's next paper-size edit: arm it only when the write changed the document. The 15 s export timeout now retires the export's sequence number, so a result POST that answers late no longer restores the document a second time.
* ci: create the tarball directory before npm pack
npm pack does not create its --pack-destination; on a fresh runner the step failed with ENOENT before the packed shell tests ran.
* feat(mcp-server): history entries name their maker, and /api/history the entry on the canvas
Each History entry records who made it (a user edit, a recovering tab's
copy, a restored copy; none for the AI's writes), when, and its page
count. GET /api/history now also returns each entry's XML, the first entry
with the same content (a restore adds a copy of an older one), the state
the ids belong to, and the entry whose content the canvas shows, by the
rule restore_version uses. The shell's version cards read these; the
classic page keeps using index, id and svg.
* feat(mcp-server): version cards in the shell from the server's History
The shell's VersionsSource (shell/node-versions-source.ts) reads GET
/api/history through the sync and shows one version per distinct content
(a restore's copy folds into the entry it copies; the blank page is none),
numbered as they first appeared, with what changed since the one before.
The entry the server says is on the canvas marks the version; undo and
redo of the newest version restore the one before it, or it again, through
POST /api/restore, so hand edits stay as versions of their own. The cards
sit in a panel beside the canvas (toggle in the header); the strip and
Compare come from the shared canvas components. The sync tells listeners
once per server version, and when History changed without one, so the
list is read again only then.
* test(mcp-server): e2e of the shell's version cards
In a session of its own: three writes give three cards; undoing the newest
restores the one before on the server too, redo brings it back, a hand edit
turns the undo into a restore, and restoring the first version keeps that
edit as a version of its own, which restore_version also finds.
* feat(mcp-server): get_selection reads the cells the user selected in the shell
The server asks the preview tab for the selection the way it asks for an export (a random request id in GET /api/state, the answer in a POST with that id, 10 s to answer). The shell answers through the editor bridge with each cell's id, label, an edge's ends, a shape's geometry and the page on screen; without a same-origin editor it says so, and the tool names the external draw.io. The classic page cannot answer, so the tool says that at once. screenshot_diagram's description now says a page selector renders that page without changing the page on screen (PNG exports by pageId already did).
* test(mcp-server): selection requests, get_selection texts, and page screenshots that leave the view alone
Unit tests for the request plumbing (random id, one answer, timeout), the tool's texts (cells, nothing selected, external draw.io) and the shell's side (one answer per request, after draw.io is up, never during a projection); the wiring test starts a session with BROWSER=none to see the no-tab message. Shell e2e: cells selected in the editor reach get_selection; a PNG of another page differs from the page on screen, which stays.
* feat(mcp-server): the canvas shell is the default preview, the classic page behind DRAWIO_PREVIEW_UI=classic
* docs: the MCP preview is the web app's canvas; get_selection, DRAWIO_BASE_URL and the classic page
* chore(mcp-server): version 0.4.0; check-package requires the draw.io version stamp
* ci: an mcp-shell job builds the package, runs its tests, the draw.io file guard and the shell e2e
* fix(mcp-server): review fixes for the version cards and History
The shell's version cards (shell/node-versions-source.ts):
- a version's change and undo target are the state it replaced, the
History entry right before its first copy, not the card before it: after
a restore those differ, and undo went to the wrong version (and not
where restore_version steps_back=1 goes)
- a card restores the newest copy of its content, as restore_version
does, so page settings the user changed (a "user" copy) are kept
- a blank page after a drawing is a clear of the canvas, a version of its
own; only the blank page before any drawing is hidden
- numbers and changes are keyed by content, not by the first copy's id,
so a version keeps them when its first copy drops out of the server's
20-entry buffer; the caches start over for another server state (the
process restarted: entry ids name other content)
The server's History (src/history.ts):
- firstCopyIds compares each entry with the first of every group only: a
bare model matches any page name, so "same content" is not transitive,
and a card could show one document and restore another
- the time and pages fields had no reader; pages parsed every XML once
more on every write
Reading History (src/http-server.ts, shell/mcp-sync-core.ts):
- GET /api/state and a push's answer carry a History key (entry count,
newest id, the entry on the canvas); the shell reads History again only
when it changes, so a hand edit no longer downloads every entry's XML and
thumbnail
- a failed History read is told again at the next poll
- a History list from a state the poll has not seen yet is dropped
* fix(mcp-server): review fixes for get_selection
Server (src/http-server.ts, src/index.ts, src/selection.ts,
src/new-diagram.ts):
- overlapping get_selection calls take turns (readSelection, one slot per
session as the export slot) instead of replacing each other's request,
which left one of them with a false "tab not in front" timeout
- an answer is taken only while a request is pending (both ids undefined
compared equal)
- the tool sees the page start_session actually opens: with dist/shell
missing the classic page is in use, which never answers, so the tool
says so instead of timing out
- the result lists at most 100 cells and counts the rest: a whole large
diagram selected would fill the model's context
- every <diagram> the model sends without an id gets one, so the page id
the shell reports exists in the server's document
Shell (shell/mcp-sync-core.ts, shell/use-mcp-sync.ts,
contexts/diagram-context.tsx, lib/drawio/editor-bridge.ts):
- an answer whose POST failed is sent again at the next poll
- no answer while a full load has yet to reach the editor: it still shows
the previous document, whose cells and pages the answer would name
- a hidden tab (the same session open twice) answers a poll later, so the
tab in front answers first; alone, it still answers within the timeout
- a cell's container is reported by the model's isLayer, not by comparing
with the default parent, which is the group the user entered
* fix(mcp-server): review fixes for the shell page
- a download button in the header opens the web app's export dialog
(.drawio, .png, .svg, .drawio.svg), which the classic page had and the
shell lacked when it became the default
- the shell asks draw.io for the custom library menu (libraries=1), as
the classic page did; the web app keeps libraries=0
- the newest card no longer shows "Rendering preview" for good: the sync
takes the thumbnail of a diagram the server recovered from its file
(saved without pictures) while the canvas kept it, and of a write whose
picture was skipped because an edit came first, once the canvas shows
the write again
- e2e: the get_selection test covers a shape in a container the user
entered; a download test saves a .drawio file
* ci: the version bump reminder watches every path publish-mcp.yml does, and compares the version field
The reminder step left out the root lockfile and scripts/, which change
what is bundled into the shell and trigger the publish workflow; and it
took any change of packages/mcp-server/package.json (a dependency bump)
for a version bump. It now compares the version field between the two
commits.
* fix(mcp-server): get_selection lists every selected id, with detail for the first 100
* fix(mcp-server): a hidden tab's delayed selection answer checks the canvas again first
* fix(mcp-server): the version cards keep only the current History in memory, picture the newest copy, and keep a clear the buffer scrolled to
* fix(mcp-server): save who made each History entry with it, so the cards look the same after a restart
* fix(mcp-server): the download waits for a page export's projection to end, and the dialog's styles are bundled
* fix(mcp-server): bundle open.html and js/open.js, the picker of Open Library from > Browser
* fix(mcp-server): the shell shows the versions once, in the side panel
* test(mcp-server): get_selection wiring test expects the classic page's answer when the shell is not built
1984 lines
77 KiB
TypeScript
1984 lines
77 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 { pageModelXml, placeOnPage } from "@/lib/diagram-pages"
|
|
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 sees and
|
|
* edits one page, the one the user is viewing (the first when unknown), so
|
|
* give it that page's mxGraphModel. Older snapshots already hold a single
|
|
* mxGraphModel and are returned as is.
|
|
*/
|
|
function getPageXml(xml: string, pageId: string | null): string {
|
|
if (!xml.includes("<mxfile")) return xml
|
|
const model = pageModelXml(xml, pageId)
|
|
return model ? formatXML(model) : xml
|
|
}
|
|
|
|
/** The page the user is viewing; null with an external draw.io */
|
|
function viewedPageId(): string | null {
|
|
return useCanvasStore.getState().currentPageId
|
|
}
|
|
|
|
// 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())
|
|
// The page the running turn's model reads and writes: the one the user
|
|
// viewed when the message was sent (null: the first page)
|
|
const turnPageIdRef = useRef<string | null>(null)
|
|
// 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,
|
|
turnPageIdRef,
|
|
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
|
|
// Canvas components (version cards, the compare dialog, "ask AI") read
|
|
// the busy flag from the canvas store
|
|
useEffect(() => {
|
|
useCanvasStore.getState().set({ isBusy })
|
|
}, [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,
|
|
turnPageIdRef,
|
|
})
|
|
|
|
const { restoreVersion, undoVersion } = useVersions({
|
|
currentTurnRef,
|
|
isBusy,
|
|
isTurnActive: () => !canStartTurn(),
|
|
getChatGeneration: sessionManager.getChatGeneration,
|
|
})
|
|
|
|
// ---------------------------------------------------------------------
|
|
// Selection sent with the next message
|
|
// ---------------------------------------------------------------------
|
|
|
|
const selection = useCanvasStore((s) => s.selection)
|
|
const [dismissedSelectionKey, setDismissedSelectionKey] = useState("")
|
|
const selectionKey = selection.map((c) => c.id).join(",")
|
|
const chatSelection =
|
|
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
|
|
? getPageXml(
|
|
xmlSnapshotsRef.current.get(snapshotKeys[0]) || "",
|
|
viewedPageId(),
|
|
)
|
|
: ""
|
|
}
|
|
|
|
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
|
|
turnPageIdRef.current = viewedPageId()
|
|
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 the page
|
|
// the user is viewing
|
|
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) {
|
|
const pageId = viewedPageId()
|
|
const canvasXml = chartXMLRef.current || ""
|
|
onDisplayChart(
|
|
keepFileVars(
|
|
placeOnPage(prepared.xml, canvasXml, pageId),
|
|
canvasXml,
|
|
),
|
|
true,
|
|
"commit",
|
|
{ toolCallId, pageId },
|
|
)
|
|
}
|
|
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,
|
|
getPageXml(savedXml, viewedPageId()),
|
|
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>
|
|
)
|
|
}
|