mirror of
https://github.com/DayuanJiang/next-ai-draw-io.git
synced 2026-10-10 19:49:52 +08:00
feat(ui): canvas-first redesign with a self-hosted draw.io (#957)
The canvas now fills the window and the chat floats on its right. draw.io is served from our own origin (public/drawio, downloaded at build time and checked against the release's SHA-256), so the app can drive the editor directly: - every AI change becomes a version card with a thumbnail; compare, restore or undo it, and Ctrl+Z on the canvas takes an AI change back in one step - shapes the AI just changed are highlighted on the canvas - selected shapes appear as a chip and are sent to the model - draw.io's own simple UI: its toolbar, menus (auto layout, Mermaid, CSV import) and page tabs; the shape library starts closed - new start screen, session rename and switcher, open .drawio files - settings split into Models & keys, Appearance, Advanced and About - Bedrock in the model settings also takes an API key instead of access keys - phones switch between canvas and chat - dark mode uses draw.io's own dark mode, with neutral greys around it With an external draw.io (NEXT_PUBLIC_DRAWIO_BASE_URL) the features above that need same origin are off. The chat logic moved from the 1500-line chat panel into a chat engine plus small UI components; zustand holds canvas, versions, settings and UI state.
This commit is contained in:
@@ -1,9 +1,10 @@
|
||||
import type { MutableRefObject } from "react"
|
||||
import type { RefObject } from "react"
|
||||
import type { DiagramOperation } from "@/components/chat/types"
|
||||
import type {
|
||||
ValidationState,
|
||||
ValidationStatus,
|
||||
} from "@/components/chat/ValidationCard"
|
||||
import type { LoadMode } from "@/contexts/diagram-context"
|
||||
import type { ValidationResult } from "@/lib/diagram-validator"
|
||||
import { formatValidationFeedback } from "@/lib/diagram-validator"
|
||||
import { isMxCellXmlComplete } from "@/lib/utils"
|
||||
@@ -15,6 +16,26 @@ const DEBUG = process.env.NODE_ENV === "development"
|
||||
// display_diagram replaces the document with this one page
|
||||
const NEW_PAGE = { pageId: "page-1", pageName: "Page-1" }
|
||||
|
||||
/**
|
||||
* A new diagram written without file variables (%name% placeholders) keeps
|
||||
* the canvas file's, as replacing the page in draw.io does
|
||||
*/
|
||||
export function keepFileVars(xml: string, canvasXml: string): string {
|
||||
const varsOf = (doc: Document) =>
|
||||
doc.documentElement?.nodeName === "mxfile"
|
||||
? doc.documentElement.getAttribute("vars")
|
||||
: null
|
||||
const parser = new DOMParser()
|
||||
const canvasVars = varsOf(parser.parseFromString(canvasXml, "text/xml"))
|
||||
if (!canvasVars) return xml
|
||||
const doc = parser.parseFromString(xml, "text/xml")
|
||||
if (doc.documentElement?.nodeName !== "mxfile" || varsOf(doc) !== null) {
|
||||
return xml
|
||||
}
|
||||
doc.documentElement.setAttribute("vars", canvasVars)
|
||||
return new XMLSerializer().serializeToString(doc)
|
||||
}
|
||||
|
||||
interface ToolCall {
|
||||
toolCallId: string
|
||||
toolName: string
|
||||
@@ -50,16 +71,23 @@ type ValidateDiagramFn = (
|
||||
) => Promise<ValidationResult>
|
||||
|
||||
interface UseDiagramToolHandlersParams {
|
||||
partialXmlRef: MutableRefObject<string>
|
||||
editDiagramOriginalXmlRef: MutableRefObject<Map<string, string>>
|
||||
partialXmlRef: RefObject<string>
|
||||
// Diagram before a cut off display_diagram whose half drawn preview
|
||||
// stays while append_diagram finishes it; null when none is pending
|
||||
continuationOriginalRef: RefObject<string | null>
|
||||
editDiagramOriginalXmlRef: RefObject<Map<string, string>>
|
||||
// Tool calls the streaming preview must leave alone (shared with it)
|
||||
processedToolCallsRef: MutableRefObject<Set<string>>
|
||||
processedToolCallsRef: RefObject<Set<string>>
|
||||
// Failed VLM validations in the current user turn (reset on each user message)
|
||||
validationRetryCountRef: MutableRefObject<number>
|
||||
chartXMLRef: MutableRefObject<string>
|
||||
onDisplayChart: (xml: string, skipValidation?: boolean) => string | null
|
||||
onFetchChart: (saveToHistory?: boolean) => Promise<string>
|
||||
onExport: () => void
|
||||
validationRetryCountRef: RefObject<number>
|
||||
chartXMLRef: RefObject<string>
|
||||
onDisplayChart: (
|
||||
xml: string,
|
||||
skipValidation?: boolean,
|
||||
mode?: LoadMode,
|
||||
meta?: { toolCallId?: string },
|
||||
) => string | null
|
||||
onFetchChart: () => Promise<string>
|
||||
captureValidationPng?: () => Promise<string | null>
|
||||
validateDiagram?: ValidateDiagramFn
|
||||
enableVlmValidation?: boolean
|
||||
@@ -84,13 +112,13 @@ interface UseDiagramToolHandlersParams {
|
||||
*/
|
||||
export function useDiagramToolHandlers({
|
||||
partialXmlRef,
|
||||
continuationOriginalRef,
|
||||
editDiagramOriginalXmlRef,
|
||||
processedToolCallsRef,
|
||||
validationRetryCountRef,
|
||||
chartXMLRef,
|
||||
onDisplayChart,
|
||||
onFetchChart,
|
||||
onExport,
|
||||
captureValidationPng,
|
||||
validateDiagram,
|
||||
enableVlmValidation = true,
|
||||
@@ -146,6 +174,17 @@ export function useDiagramToolHandlers({
|
||||
// handler. The first is the diagram before all of them. A call that
|
||||
// draws its result replaces those previews, so the preview code must
|
||||
// neither draw them again nor undo them later. Returns that first one.
|
||||
// The final result as one undo step. A cut off drawing it replaces goes
|
||||
// first, so the step (and the version) starts from the diagram before
|
||||
// it, also when a failed call in between was undone to the cut off one.
|
||||
const commit = (xml: string, toolCallId: string) => {
|
||||
const original = continuationOriginalRef.current
|
||||
if (original !== null) onDisplayChart(original, true, "revert")
|
||||
const error = onDisplayChart(xml, true, "commit", { toolCallId })
|
||||
if (!error) continuationOriginalRef.current = null
|
||||
return error
|
||||
}
|
||||
|
||||
const takeOriginals = (): string | undefined => {
|
||||
const [originalXml] = editDiagramOriginalXmlRef.current.values()
|
||||
for (const id of editDiagramOriginalXmlRef.current.keys()) {
|
||||
@@ -181,6 +220,15 @@ export function useDiagramToolHandlers({
|
||||
if (isTruncated) {
|
||||
// Store the partial XML for continuation via append_diagram
|
||||
partialXmlRef.current = xml
|
||||
// The half drawn preview stays while append_diagram finishes
|
||||
// it; the chat engine brings this diagram back if that never
|
||||
// happens
|
||||
if (
|
||||
originalXml !== undefined &&
|
||||
continuationOriginalRef.current === null
|
||||
) {
|
||||
continuationOriginalRef.current = originalXml
|
||||
}
|
||||
|
||||
// Tell LLM to use append_diagram to continue
|
||||
const partialEnding = partialXmlRef.current.slice(-500)
|
||||
@@ -210,16 +258,22 @@ NEXT STEP: Call append_diagram with the continuation XML.
|
||||
|
||||
// Wrap, validate and auto-fix the model's XML like the MCP server's
|
||||
// create_new_diagram, then load it
|
||||
// One undo step (and a version) right away, before the screenshot
|
||||
// check: a stop, another chat or a hand edit during the check then
|
||||
// finds the diagram already in place
|
||||
const prepared = prepareNewDiagram(finalXml, NEW_PAGE)
|
||||
const validationError = prepared.ok
|
||||
? onDisplayChart(prepared.xml, true)
|
||||
? commit(
|
||||
keepFileVars(prepared.xml, chartXMLRef.current),
|
||||
toolCall.toolCallId,
|
||||
)
|
||||
: prepared.error
|
||||
|
||||
if (validationError) {
|
||||
console.warn("[display_diagram] Validation error:", validationError)
|
||||
// Undo the streamed preview, as a failed edit does: the canvas
|
||||
// keeps the diagram from before this failed call
|
||||
if (originalXml) onDisplayChart(originalXml, true)
|
||||
if (originalXml) onDisplayChart(originalXml, true, "revert")
|
||||
// Return error to model - sendAutomaticallyWhen will trigger retry
|
||||
if (DEBUG) {
|
||||
console.log(
|
||||
@@ -430,7 +484,7 @@ ${finalXml}
|
||||
// On failure, undo the streaming preview so the canvas matches the XML
|
||||
// reported back to the model
|
||||
const restoreOriginal = () => {
|
||||
if (originalXml) onDisplayChart(originalXml, true)
|
||||
if (originalXml) onDisplayChart(originalXml, true, "revert")
|
||||
}
|
||||
try {
|
||||
if (originalXml) {
|
||||
@@ -442,7 +496,7 @@ ${finalXml}
|
||||
currentXml = cachedXML
|
||||
} else {
|
||||
// Last resort: export from iframe
|
||||
currentXml = await onFetchChart(false)
|
||||
currentXml = await onFetchChart()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -470,8 +524,7 @@ Please check the cell IDs and retry.`,
|
||||
return
|
||||
}
|
||||
|
||||
onDisplayChart(outcome.xml, true)
|
||||
onExport()
|
||||
commit(outcome.xml, toolCall.toolCallId)
|
||||
addToolOutput({
|
||||
tool: "edit_diagram",
|
||||
toolCallId: toolCall.toolCallId,
|
||||
@@ -558,17 +611,22 @@ Start your continuation with the NEXT character after where it stopped.`,
|
||||
partialXmlRef.current = "" // Reset
|
||||
|
||||
const prepared = prepareNewDiagram(finalXml, NEW_PAGE)
|
||||
// It draws now: it takes the stored originals, as display_diagram
|
||||
const originalXml = prepared.ok ? takeOriginals() : undefined
|
||||
// The continuation ends here, drawn or not: it takes the stored
|
||||
// originals, so the preview code undoes none of them later
|
||||
const originalXml = takeOriginals()
|
||||
const validationError = prepared.ok
|
||||
? onDisplayChart(prepared.xml, true)
|
||||
? commit(
|
||||
keepFileVars(prepared.xml, chartXMLRef.current),
|
||||
toolCall.toolCallId,
|
||||
)
|
||||
: prepared.error
|
||||
|
||||
if (validationError) {
|
||||
// Loading failed: back to the diagram before the previews
|
||||
if (prepared.ok && originalXml) {
|
||||
onDisplayChart(originalXml, true)
|
||||
}
|
||||
// Back to the diagram before the cut off drawing, or else
|
||||
// before the previews
|
||||
const before = continuationOriginalRef.current ?? originalXml
|
||||
continuationOriginalRef.current = null
|
||||
if (before) onDisplayChart(before, true, "revert")
|
||||
addToolOutput({
|
||||
tool: "append_diagram",
|
||||
toolCallId: toolCall.toolCallId,
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
import { useEffect, useState } from "react"
|
||||
import { PANEL_MIN_WIDTH, useSettingsStore } from "@/stores/settings-store"
|
||||
|
||||
// Room the canvas keeps next to the chat panel in a small window
|
||||
const MIN_CANVAS_WIDTH = 400
|
||||
|
||||
/** The chat panel's width, narrower when the window has no room for it */
|
||||
export function usePanelWidth(): number {
|
||||
const width = useSettingsStore((s) => s.panelWidth)
|
||||
// Set after mounting, so the server render and the first one agree
|
||||
const [windowWidth, setWindowWidth] = useState(Number.POSITIVE_INFINITY)
|
||||
useEffect(() => {
|
||||
const update = () => setWindowWidth(window.innerWidth)
|
||||
update()
|
||||
window.addEventListener("resize", update)
|
||||
return () => window.removeEventListener("resize", update)
|
||||
}, [])
|
||||
return Math.max(
|
||||
PANEL_MIN_WIDTH,
|
||||
Math.min(width, windowWidth - MIN_CANVAS_WIDTH),
|
||||
)
|
||||
}
|
||||
@@ -14,18 +14,32 @@ import {
|
||||
isIndexedDBAvailable,
|
||||
migrateFromLocalStorage,
|
||||
readSessionCount,
|
||||
renameSession as renameSessionInDB,
|
||||
type SessionMetadata,
|
||||
type StoredMessage,
|
||||
saveSession,
|
||||
} from "@/lib/session-storage"
|
||||
import { STORAGE_KEYS } from "@/lib/storage"
|
||||
import {
|
||||
type DiagramVersion,
|
||||
versionsFromLegacyHistory,
|
||||
} from "@/stores/versions-store"
|
||||
|
||||
export interface SessionData {
|
||||
messages: StoredMessage[]
|
||||
xmlSnapshots: [number, string][]
|
||||
diagramXml: string
|
||||
thumbnailDataUrl?: string
|
||||
diagramHistory?: { svg: string; xml: string }[]
|
||||
versions?: DiagramVersion[]
|
||||
/** Title for a session created by this save (default: first message) */
|
||||
title?: string
|
||||
}
|
||||
|
||||
/** Versions of a stored session, converting the old history format */
|
||||
export function getSessionVersions(
|
||||
session: Pick<ChatSession, "versions" | "diagramHistory">,
|
||||
): DiagramVersion[] {
|
||||
return session.versions ?? versionsFromLegacyHistory(session.diagramHistory)
|
||||
}
|
||||
|
||||
// Taken right before a save's data is read: the chat on screen then, and
|
||||
@@ -57,7 +71,22 @@ export interface UseSessionManagerReturn {
|
||||
refreshSessions: () => Promise<void>
|
||||
clearCurrentSession: () => void
|
||||
getChatGeneration: () => number
|
||||
/** The chat on screen now, also right after a save created it */
|
||||
getCurrentSessionId: () => string | null
|
||||
getSaveTicket: () => SaveTicket
|
||||
renameSession: (id: string, title: string) => Promise<void>
|
||||
/**
|
||||
* Run before the URL (browser back or forward) puts another chat on
|
||||
* screen: the chat on screen is saved first, which the app's own ways
|
||||
* of leaving it do themselves
|
||||
*/
|
||||
setBeforeUrlSwitch: (handler: (() => Promise<void>) | null) => void
|
||||
/**
|
||||
* Runs fn while the chat with this id is about to be put on screen: a
|
||||
* save meanwhile that creates a session (over the chat limit) does not
|
||||
* delete it as the oldest one
|
||||
*/
|
||||
whileOpening: <T>(id: string, fn: () => Promise<T>) => Promise<T>
|
||||
}
|
||||
|
||||
// Reading the session list loads every stored session in full, and window
|
||||
@@ -103,6 +132,31 @@ export function useSessionManager(
|
||||
const saveSeqRef = useRef(0)
|
||||
const savedSeqRef = useRef(0)
|
||||
|
||||
const beforeUrlSwitchRef = useRef<(() => Promise<void>) | null>(null)
|
||||
// The chat about to be put on screen, while the one on screen is saved
|
||||
const openingSessionRef = useRef<{ id: string } | null>(null)
|
||||
const whileOpening = useCallback(
|
||||
async <T>(id: string, fn: () => Promise<T>): Promise<T> => {
|
||||
// Its own: another one opened meanwhile sets another
|
||||
const opening = { id }
|
||||
openingSessionRef.current = opening
|
||||
try {
|
||||
return await fn()
|
||||
} finally {
|
||||
if (openingSessionRef.current === opening) {
|
||||
openingSessionRef.current = null
|
||||
}
|
||||
}
|
||||
},
|
||||
[],
|
||||
)
|
||||
const setBeforeUrlSwitch = useCallback(
|
||||
(handler: (() => Promise<void>) | null) => {
|
||||
beforeUrlSwitchRef.current = handler
|
||||
},
|
||||
[],
|
||||
)
|
||||
|
||||
const changeChat = useCallback((session: ChatSession | null) => {
|
||||
chatGenerationRef.current++
|
||||
currentSessionRef.current = session
|
||||
@@ -215,6 +269,19 @@ export function useSessionManager(
|
||||
|
||||
// Only update if the session is different from current
|
||||
if (session && currentSessionRef.current?.id !== session.id) {
|
||||
// The chat on screen is saved first. Meanwhile the URL
|
||||
// may change again, or another chat come on screen.
|
||||
await whileOpening(session.id, async () => {
|
||||
try {
|
||||
await beforeUrlSwitchRef.current?.()
|
||||
} catch (error) {
|
||||
console.warn("Failed to save the chat left:", error)
|
||||
}
|
||||
})
|
||||
if (currentSequence !== urlChangeSequenceRef.current) {
|
||||
return
|
||||
}
|
||||
if (generation !== chatGenerationRef.current) return
|
||||
changeChat(session)
|
||||
}
|
||||
}
|
||||
@@ -267,7 +334,7 @@ export function useSessionManager(
|
||||
xmlSnapshots: session.xmlSnapshots,
|
||||
diagramXml: session.diagramXml,
|
||||
thumbnailDataUrl: session.thumbnailDataUrl,
|
||||
diagramHistory: session.diagramHistory,
|
||||
versions: getSessionVersions(session),
|
||||
}
|
||||
},
|
||||
[currentSessionId, changeChat],
|
||||
@@ -319,8 +386,8 @@ export function useSessionManager(
|
||||
xmlSnapshots: data.xmlSnapshots,
|
||||
diagramXml: data.diagramXml,
|
||||
thumbnailDataUrl: data.thumbnailDataUrl,
|
||||
diagramHistory: data.diagramHistory,
|
||||
title: extractTitle(data.messages),
|
||||
versions: data.versions,
|
||||
title: data.title || extractTitle(data.messages),
|
||||
}
|
||||
// Without a stored session, keep no session id (it would end
|
||||
// up in the URL and point to nothing after a reload)
|
||||
@@ -329,7 +396,9 @@ export function useSessionManager(
|
||||
return false
|
||||
}
|
||||
savedSeqRef.current = seq
|
||||
await enforceSessionLimit()
|
||||
await enforceSessionLimit(
|
||||
() => openingSessionRef.current?.id,
|
||||
)
|
||||
if (stillOnScreen()) {
|
||||
currentSessionRef.current = newSession
|
||||
setCurrentSession(newSession)
|
||||
@@ -348,8 +417,9 @@ export function useSessionManager(
|
||||
thumbnailDataUrl:
|
||||
data.thumbnailDataUrl ??
|
||||
currentSession.thumbnailDataUrl,
|
||||
diagramHistory:
|
||||
data.diagramHistory ?? currentSession.diagramHistory,
|
||||
versions:
|
||||
data.versions ?? getSessionVersions(currentSession),
|
||||
diagramHistory: undefined,
|
||||
updatedAt: Date.now(),
|
||||
// Update title if it's still default and we have messages
|
||||
title:
|
||||
@@ -403,6 +473,10 @@ export function useSessionManager(
|
||||
}, [changeChat])
|
||||
|
||||
const getChatGeneration = useCallback(() => chatGenerationRef.current, [])
|
||||
const getCurrentSessionId = useCallback(
|
||||
() => currentSessionRef.current?.id ?? null,
|
||||
[],
|
||||
)
|
||||
|
||||
const getSaveTicket = useCallback(
|
||||
(): SaveTicket => ({
|
||||
@@ -412,6 +486,38 @@ export function useSessionManager(
|
||||
[],
|
||||
)
|
||||
|
||||
const renameSession = useCallback(
|
||||
(id: string, title: string): Promise<void> => {
|
||||
const trimmed = title.trim().slice(0, 100)
|
||||
if (!trimmed) return Promise.resolve()
|
||||
// In the save queue, so a save running now cannot put the old
|
||||
// title back
|
||||
const run = async () => {
|
||||
const updated = await renameSessionInDB(id, trimmed)
|
||||
// Also when it could not be stored (another tab deleted the
|
||||
// chat): the next save of the chat on screen writes it
|
||||
const current = currentSessionRef.current
|
||||
if (current?.id === id) {
|
||||
currentSessionRef.current = { ...current, title: trimmed }
|
||||
setCurrentSession(currentSessionRef.current)
|
||||
}
|
||||
if (!updated) {
|
||||
notifySaveFailed(dict.errors.sessionSaveFailed)
|
||||
return
|
||||
}
|
||||
setSessions((prev) =>
|
||||
prev.map((s) =>
|
||||
s.id === id ? { ...s, title: trimmed } : s,
|
||||
),
|
||||
)
|
||||
}
|
||||
const result = saveQueueRef.current.then(run)
|
||||
saveQueueRef.current = result.catch(() => {})
|
||||
return result
|
||||
},
|
||||
[dict],
|
||||
)
|
||||
|
||||
return {
|
||||
sessions,
|
||||
currentSessionId,
|
||||
@@ -424,6 +530,10 @@ export function useSessionManager(
|
||||
refreshSessions,
|
||||
clearCurrentSession,
|
||||
getChatGeneration,
|
||||
getCurrentSessionId,
|
||||
getSaveTicket,
|
||||
renameSession,
|
||||
setBeforeUrlSwitch,
|
||||
whileOpening,
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user