mirror of
https://github.com/DayuanJiang/next-ai-draw-io.git
synced 2026-10-11 03:59:58 +08:00
Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1067cba4d9 | ||
|
|
2757509d4e |
@@ -46,11 +46,6 @@ export function CanvasStage({
|
||||
const engine = useChatEngine()
|
||||
const isDark = useSettingsStore((s) => s.isDark)
|
||||
const hasEditor = useCanvasStore((s) => s.hasEditor)
|
||||
// The model sees the first page only, so asking about shapes elsewhere
|
||||
// would change the wrong page
|
||||
const onFirstPage = useCanvasStore(
|
||||
(s) => s.pages.length === 0 || s.pages[0]?.id === s.currentPageId,
|
||||
)
|
||||
// Canvas width when the chat panel starts sliding in or out
|
||||
const slideStartWidthRef = useRef(0)
|
||||
|
||||
@@ -168,7 +163,7 @@ export function CanvasStage({
|
||||
<SelectionAsk
|
||||
width={size.width}
|
||||
height={size.height}
|
||||
hidden={engine.isBusy || !onFirstPage}
|
||||
hidden={engine.isBusy}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -42,6 +42,7 @@ 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"
|
||||
@@ -90,15 +91,20 @@ function hasToolErrors(messages: UIMessage[]): boolean {
|
||||
}
|
||||
|
||||
/**
|
||||
* Snapshots keep the full multi-page document, but the model only sees and
|
||||
* edits the first page, so give it the first page's mxGraphModel.
|
||||
* Older snapshots already hold a single mxGraphModel and are returned as is.
|
||||
* 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 getFirstPageXml(xml: string): string {
|
||||
function getPageXml(xml: string, pageId: string | null): string {
|
||||
if (!xml.includes("<mxfile")) return xml
|
||||
const doc = new DOMParser().parseFromString(xml, "text/xml")
|
||||
const model = doc.querySelector("diagram")?.querySelector("mxGraphModel")
|
||||
return model ? formatXML(new XMLSerializer().serializeToString(model)) : xml
|
||||
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)
|
||||
@@ -374,6 +380,9 @@ export function ChatEngineProvider({
|
||||
|
||||
// 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
|
||||
@@ -450,6 +459,7 @@ export function ChatEngineProvider({
|
||||
validationRetryCountRef,
|
||||
// A preview undone just before the tool call is in this one already
|
||||
chartXMLRef: liveChartXMLRef,
|
||||
turnPageIdRef,
|
||||
onDisplayChart,
|
||||
onFetchChart,
|
||||
captureValidationPng,
|
||||
@@ -748,6 +758,7 @@ export function ChatEngineProvider({
|
||||
processedToolCallsRef,
|
||||
editDiagramOriginalXmlRef,
|
||||
loadedMessageIdsRef,
|
||||
turnPageIdRef,
|
||||
})
|
||||
|
||||
const { restoreVersion, undoVersion } = useVersions({
|
||||
@@ -762,14 +773,10 @@ export function ChatEngineProvider({
|
||||
// ---------------------------------------------------------------------
|
||||
|
||||
const selection = useCanvasStore((s) => s.selection)
|
||||
// The model sees and edits the first page only
|
||||
const onFirstPage = useCanvasStore(
|
||||
(s) => s.pages.length === 0 || s.pages[0]?.id === s.currentPageId,
|
||||
)
|
||||
const [dismissedSelectionKey, setDismissedSelectionKey] = useState("")
|
||||
const selectionKey = selection.map((c) => c.id).join(",")
|
||||
const chatSelection =
|
||||
!onFirstPage || selectionKey === dismissedSelectionKey ? [] : selection
|
||||
selectionKey === dismissedSelectionKey ? [] : selection
|
||||
// Once nothing is selected, picking the same shapes again attaches them
|
||||
useEffect(() => {
|
||||
if (selection.length === 0) setDismissedSelectionKey("")
|
||||
@@ -1178,8 +1185,9 @@ export function ChatEngineProvider({
|
||||
.filter((k) => k < beforeIndex)
|
||||
.sort((a, b) => b - a)
|
||||
return snapshotKeys.length > 0
|
||||
? getFirstPageXml(
|
||||
? getPageXml(
|
||||
xmlSnapshotsRef.current.get(snapshotKeys[0]) || "",
|
||||
viewedPageId(),
|
||||
)
|
||||
: ""
|
||||
}
|
||||
@@ -1198,6 +1206,7 @@ export function ChatEngineProvider({
|
||||
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
|
||||
@@ -1254,7 +1263,8 @@ export function ChatEngineProvider({
|
||||
const turnIndex = messagesRef.current.length
|
||||
const previousXml = getPreviousXml(turnIndex)
|
||||
// Snapshot the full multi-page document (kept fresh by autosave) so
|
||||
// regenerate/edit can restore every page; the model gets page 1 only
|
||||
// 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
|
||||
@@ -1328,11 +1338,16 @@ export function ChatEngineProvider({
|
||||
pageName: "Page-1",
|
||||
})
|
||||
if (prepared.ok) {
|
||||
const pageId = viewedPageId()
|
||||
const canvasXml = chartXMLRef.current || ""
|
||||
onDisplayChart(
|
||||
keepFileVars(prepared.xml, chartXMLRef.current || ""),
|
||||
keepFileVars(
|
||||
placeOnPage(prepared.xml, canvasXml, pageId),
|
||||
canvasXml,
|
||||
),
|
||||
true,
|
||||
"commit",
|
||||
{ toolCallId },
|
||||
{ toolCallId, pageId },
|
||||
)
|
||||
}
|
||||
clearComposer()
|
||||
@@ -1492,7 +1507,7 @@ export function ChatEngineProvider({
|
||||
})
|
||||
sendChatMessage(
|
||||
parts,
|
||||
getFirstPageXml(savedXml),
|
||||
getPageXml(savedXml, viewedPageId()),
|
||||
previousXml,
|
||||
index,
|
||||
selectionOf(messages[index]),
|
||||
|
||||
@@ -56,11 +56,11 @@ export function CompareDialog() {
|
||||
() => !version || isSameDocument(version.xml, chartXML),
|
||||
[version, chartXML],
|
||||
)
|
||||
// What the canvas has changed since this version on the first page
|
||||
// (null: nothing there)
|
||||
// What the canvas has changed since this version on the page it was
|
||||
// made on (null: nothing there)
|
||||
const changes = useMemo(() => {
|
||||
if (!version || same) return null
|
||||
const { summary } = diffDiagrams(version.xml, chartXML)
|
||||
const { summary } = diffDiagrams(version.xml, chartXML, version.pageId)
|
||||
return Object.values(summary).some((n) => n > 0) ? summary : null
|
||||
}, [version, chartXML, same])
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@ import type { RefObject } from "react"
|
||||
import { useCallback, useEffect, useRef } from "react"
|
||||
import type { DiagramOperation, ToolPartLike } from "@/components/chat/types"
|
||||
import { useDiagram } from "@/contexts/diagram-context"
|
||||
import { pageSelectorFor } from "@/lib/diagram-pages"
|
||||
import {
|
||||
convertToLegalXml,
|
||||
extractCompleteMxCells,
|
||||
@@ -15,6 +16,7 @@ import {
|
||||
expandCompactCells,
|
||||
} from "@/packages/mcp-server/src/compact-cells.ts"
|
||||
import { applyDiagramOperations } from "@/packages/mcp-server/src/diagram-operations.ts"
|
||||
import { targetPageXml } from "@/packages/mcp-server/src/edit-diagram.ts"
|
||||
import { BLANK_MXFILE } from "@/packages/mcp-server/src/pages.ts"
|
||||
import {
|
||||
addDefaultStyles,
|
||||
@@ -44,6 +46,8 @@ interface UseDiagramStreamingParams {
|
||||
processedToolCallsRef: RefObject<Set<string>>
|
||||
editDiagramOriginalXmlRef: RefObject<Map<string, string>>
|
||||
loadedMessageIdsRef: RefObject<Set<string>>
|
||||
// The page the model draws on in this turn; null: the first page
|
||||
turnPageIdRef?: RefObject<string | null>
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -60,6 +64,7 @@ export function useDiagramStreaming({
|
||||
processedToolCallsRef,
|
||||
editDiagramOriginalXmlRef,
|
||||
loadedMessageIdsRef,
|
||||
turnPageIdRef,
|
||||
}: UseDiagramStreamingParams) {
|
||||
const { chartXML, chartXMLRef, loadDiagram } = useDiagram()
|
||||
const previousXML = useRef<string>("")
|
||||
@@ -102,10 +107,15 @@ export function useDiagramStreaming({
|
||||
if (testDoc.querySelector("parsererror")) return
|
||||
|
||||
try {
|
||||
// Replace the first page's cells so other pages stay intact.
|
||||
// An empty canvas gets a default mxfile to put the cells in.
|
||||
// Replace the model's page's cells so other pages stay
|
||||
// intact. An empty canvas gets a default mxfile to put the
|
||||
// cells in.
|
||||
const baseXML = chartXML || BLANK_MXFILE
|
||||
const replacedXML = replaceNodes(baseXML, convertedXml)
|
||||
const replacedXML = replaceNodes(
|
||||
baseXML,
|
||||
convertedXml,
|
||||
turnPageIdRef?.current,
|
||||
)
|
||||
previousXML.current = convertedXml
|
||||
loadDiagram(replacedXML, true, "preview")
|
||||
} catch (error) {
|
||||
@@ -220,8 +230,15 @@ export function useDiagramStreaming({
|
||||
if (lastProcessedXmlRef.current.get(opsKey) === ops) continue
|
||||
lastProcessedXmlRef.current.set(opsKey, ops)
|
||||
try {
|
||||
const edges = edgeIdsOf(originalXml)
|
||||
const layer = defaultLayerOf(originalXml)
|
||||
// The model's page: the edges on it and its first layer,
|
||||
// as editDiagram reads them
|
||||
const selector = pageSelectorFor(
|
||||
originalXml,
|
||||
turnPageIdRef?.current,
|
||||
)
|
||||
const page = targetPageXml(originalXml, selector)
|
||||
const edges = edgeIdsOf(page)
|
||||
const layer = defaultLayerOf(page)
|
||||
const { result } = applyDiagramOperations(
|
||||
originalXml,
|
||||
completeOps.map((op) =>
|
||||
@@ -235,6 +252,7 @@ export function useDiagramStreaming({
|
||||
}
|
||||
: op,
|
||||
),
|
||||
selector,
|
||||
)
|
||||
if (lastProcessedXmlRef.current.get(resultKey) === result) {
|
||||
continue
|
||||
|
||||
@@ -46,15 +46,17 @@ export function useVersions({
|
||||
// (effect cleanups run before the streaming hook commits). The handler
|
||||
// only reads refs and stable functions.
|
||||
useEffect(() => {
|
||||
setCommitHandler(({ beforeXml, afterXml, toolCallId }) => {
|
||||
setCommitHandler(({ beforeXml, afterXml, toolCallId, pageId }) => {
|
||||
if (restoringRef.current) return
|
||||
const { summary, touchedIds, fromScratch } = diffDiagrams(
|
||||
beforeXml,
|
||||
afterXml,
|
||||
pageId,
|
||||
)
|
||||
const id = useVersionsStore.getState().addVersion({
|
||||
xml: afterXml,
|
||||
beforeXml,
|
||||
...(pageId && { pageId }),
|
||||
turnIndex: currentTurnRef.current,
|
||||
toolCallId,
|
||||
summary,
|
||||
|
||||
@@ -23,12 +23,14 @@ import {
|
||||
previewDiagram,
|
||||
resetPreview,
|
||||
revertPreview,
|
||||
selectPage,
|
||||
} from "@/lib/drawio/editor-bridge"
|
||||
import {
|
||||
BLANK_MXFILE,
|
||||
normalizeToMxfile,
|
||||
} from "@/packages/mcp-server/src/pages.ts"
|
||||
import { validateAndFixXml } from "@/packages/mcp-server/src/xml-validation.ts"
|
||||
import { useCanvasStore } from "@/stores/canvas-store"
|
||||
import { extractDiagramXML, isRealDiagram } from "../lib/utils"
|
||||
|
||||
/**
|
||||
@@ -54,6 +56,8 @@ export interface DiagramCommit {
|
||||
beforeXml: string
|
||||
afterXml: string
|
||||
toolCallId?: string
|
||||
/** The page the change was made on; null or absent: the first page */
|
||||
pageId?: string | null
|
||||
}
|
||||
|
||||
interface DiagramContextType {
|
||||
@@ -65,12 +69,12 @@ interface DiagramContextType {
|
||||
chart: string,
|
||||
skipValidation?: boolean,
|
||||
mode?: LoadMode,
|
||||
meta?: { toolCallId?: string },
|
||||
meta?: { toolCallId?: string; pageId?: string | null },
|
||||
) => string | null
|
||||
// Returns the export's tag (empty when draw.io is not there yet)
|
||||
handleExport: () => string
|
||||
// Pending exports by tag; a plain export's resolver gets the first
|
||||
// page's XML
|
||||
// Pending exports by tag; a plain export's resolver gets the XML of the
|
||||
// page on screen
|
||||
exportResolversRef: React.MutableRefObject<
|
||||
Record<string, (data: string, xml?: string) => void>
|
||||
>
|
||||
@@ -134,17 +138,28 @@ export function DiagramProvider({ children }: { children: React.ReactNode }) {
|
||||
null,
|
||||
)
|
||||
|
||||
// The page to show again once a full load is done: draw.io shows the
|
||||
// first page of a loaded document
|
||||
const pageAfterLoadRef = useRef<string | null>(null)
|
||||
|
||||
// Sends a full load. draw.io runs it when its message arrives, and then
|
||||
// reports "load": until then the editor shows the diagram from before,
|
||||
// so later changes go the same way and keep their order
|
||||
const fullLoad = (xml: string) => {
|
||||
if (!drawioRef.current) return
|
||||
const pageId = useCanvasStore.getState().currentPageId
|
||||
pageAfterLoadRef.current =
|
||||
pageId && xml.includes(`id="${pageId}"`) ? pageId : null
|
||||
pendingLoadsRef.current++
|
||||
drawioRef.current.load({ xml })
|
||||
}
|
||||
|
||||
const onDrawioLoad = () => {
|
||||
pendingLoadsRef.current = Math.max(0, pendingLoadsRef.current - 1)
|
||||
// Back to the page the user was on
|
||||
const pageId = pageAfterLoadRef.current
|
||||
pageAfterLoadRef.current = null
|
||||
if (pageId) selectPage(pageId)
|
||||
// Only set ready state once to prevent infinite loops
|
||||
if (hasCalledOnLoadRef.current) return
|
||||
hasCalledOnLoadRef.current = true
|
||||
@@ -241,7 +256,7 @@ export function DiagramProvider({ children }: { children: React.ReactNode }) {
|
||||
chart: string,
|
||||
skipValidation?: boolean,
|
||||
mode: LoadMode = "load",
|
||||
meta?: { toolCallId?: string },
|
||||
meta?: { toolCallId?: string; pageId?: string | null },
|
||||
): string | null => {
|
||||
// The editor bridge is shared: a page that is gone (another language
|
||||
// mounted a new one) must not change the new page's canvas
|
||||
@@ -308,6 +323,7 @@ export function DiagramProvider({ children }: { children: React.ReactNode }) {
|
||||
beforeXml,
|
||||
afterXml: xmlToLoad,
|
||||
toolCallId: meta?.toolCallId,
|
||||
pageId: meta?.pageId,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -332,7 +348,12 @@ export function DiagramProvider({ children }: { children: React.ReactNode }) {
|
||||
// data.xml from xmlsvg exports has compressed <diagram> payloads that
|
||||
// would break edit_diagram/display_diagram. Autosave keeps chartXML
|
||||
// up to date with the full uncompressed multi-page document (#879).
|
||||
const extractedXML = extractDiagramXML(data.data)
|
||||
// The chat gets the page the user is viewing (the first one with
|
||||
// an external draw.io, which cannot tell us)
|
||||
const extractedXML = extractDiagramXML(
|
||||
data.data,
|
||||
useCanvasStore.getState().currentPageId,
|
||||
)
|
||||
setLatestSvg(data.data)
|
||||
|
||||
// The chat's own export (onFetchChart), not another one in flight
|
||||
|
||||
@@ -5,6 +5,7 @@ import type {
|
||||
ValidationStatus,
|
||||
} from "@/components/chat/ValidationCard"
|
||||
import type { LoadMode } from "@/contexts/diagram-context"
|
||||
import { pageModelXml, pageSelectorFor, placeOnPage } from "@/lib/diagram-pages"
|
||||
import type { ValidationResult } from "@/lib/diagram-validator"
|
||||
import { formatValidationFeedback } from "@/lib/diagram-validator"
|
||||
import { isMxCellXmlComplete } from "@/lib/utils"
|
||||
@@ -15,7 +16,8 @@ import { hasCells } from "@/packages/mcp-server/src/pages.ts"
|
||||
|
||||
const DEBUG = process.env.NODE_ENV === "development"
|
||||
|
||||
// display_diagram replaces the document with this one page
|
||||
// The page a drawing is wrapped in; on the canvas it replaces the page the
|
||||
// model works on, so the canvas page keeps its own id and name
|
||||
const NEW_PAGE = { pageId: "page-1", pageName: "Page-1" }
|
||||
|
||||
/**
|
||||
@@ -83,11 +85,14 @@ interface UseDiagramToolHandlersParams {
|
||||
// Failed VLM validations in the current user turn (reset on each user message)
|
||||
validationRetryCountRef: RefObject<number>
|
||||
chartXMLRef: RefObject<string>
|
||||
// The page the model reads and writes in this turn (the one the user
|
||||
// viewed when it began); null, or absent, means the first page
|
||||
turnPageIdRef?: RefObject<string | null>
|
||||
onDisplayChart: (
|
||||
xml: string,
|
||||
skipValidation?: boolean,
|
||||
mode?: LoadMode,
|
||||
meta?: { toolCallId?: string },
|
||||
meta?: { toolCallId?: string; pageId?: string | null },
|
||||
) => string | null
|
||||
onFetchChart: () => Promise<string>
|
||||
captureValidationPng?: () => Promise<string | null>
|
||||
@@ -119,6 +124,7 @@ export function useDiagramToolHandlers({
|
||||
processedToolCallsRef,
|
||||
validationRetryCountRef,
|
||||
chartXMLRef,
|
||||
turnPageIdRef,
|
||||
onDisplayChart,
|
||||
onFetchChart,
|
||||
captureValidationPng,
|
||||
@@ -182,11 +188,29 @@ export function useDiagramToolHandlers({
|
||||
const commit = (xml: string, toolCallId: string) => {
|
||||
const original = continuationOriginalRef.current
|
||||
if (original !== null) onDisplayChart(original, true, "revert")
|
||||
const error = onDisplayChart(xml, true, "commit", { toolCallId })
|
||||
const error = onDisplayChart(xml, true, "commit", {
|
||||
toolCallId,
|
||||
pageId: aiPageId(),
|
||||
})
|
||||
if (!error) continuationOriginalRef.current = null
|
||||
return error
|
||||
}
|
||||
|
||||
const aiPageId = () => turnPageIdRef?.current ?? null
|
||||
|
||||
// The diagram as the model sees it in the chat request: its page only
|
||||
const shownToModel = (xml: string) =>
|
||||
foldCells(pageModelXml(xml, aiPageId()) ?? xml)
|
||||
|
||||
// A drawn page goes onto the model's page of the canvas file, which
|
||||
// keeps its other pages; a drawing that brings several pages replaces
|
||||
// the file, and gets its variables
|
||||
const onAiPage = (drawnXml: string) =>
|
||||
keepFileVars(
|
||||
placeOnPage(drawnXml, chartXMLRef.current, aiPageId()),
|
||||
chartXMLRef.current,
|
||||
)
|
||||
|
||||
const takeOriginals = (): string | undefined => {
|
||||
const [originalXml] = editDiagramOriginalXmlRef.current.values()
|
||||
for (const id of editDiagramOriginalXmlRef.current.keys()) {
|
||||
@@ -265,10 +289,7 @@ NEXT STEP: Call append_diagram with the continuation XML.
|
||||
// finds the diagram already in place
|
||||
const prepared = prepareNewDiagram(finalXml, NEW_PAGE)
|
||||
const validationError = prepared.ok
|
||||
? commit(
|
||||
keepFileVars(prepared.xml, chartXMLRef.current),
|
||||
toolCall.toolCallId,
|
||||
)
|
||||
? commit(onAiPage(prepared.xml), toolCall.toolCallId)
|
||||
: prepared.error
|
||||
|
||||
if (validationError) {
|
||||
@@ -503,8 +524,12 @@ ${finalXml}
|
||||
}
|
||||
|
||||
// All or nothing, checked like the MCP server's edit_diagram.
|
||||
// The model sees the first page, so edits target it.
|
||||
const outcome = editDiagram(currentXml, operations, {})
|
||||
// The model sees its page, so edits target it.
|
||||
const outcome = editDiagram(
|
||||
currentXml,
|
||||
operations,
|
||||
pageSelectorFor(currentXml, aiPageId()),
|
||||
)
|
||||
if (!outcome.ok) {
|
||||
const reason = outcome.pageError
|
||||
? outcome.errors[0]
|
||||
@@ -518,7 +543,7 @@ ${finalXml}
|
||||
|
||||
Current diagram XML:
|
||||
\`\`\`xml
|
||||
${foldCells(currentXml)}
|
||||
${shownToModel(currentXml)}
|
||||
\`\`\`
|
||||
|
||||
Please check the cell IDs and retry.`,
|
||||
@@ -547,7 +572,7 @@ Please check the cell IDs and retry.`,
|
||||
|
||||
Current diagram XML:
|
||||
\`\`\`xml
|
||||
${currentXml ? foldCells(currentXml) : "No XML available"}
|
||||
${currentXml ? shownToModel(currentXml) : "No XML available"}
|
||||
\`\`\`
|
||||
|
||||
Please check cell IDs and retry, or use display_diagram to regenerate.`,
|
||||
@@ -620,10 +645,7 @@ Start your continuation with the NEXT character after where it stopped.`,
|
||||
// originals, so the preview code undoes none of them later
|
||||
const originalXml = takeOriginals()
|
||||
const validationError = prepared.ok
|
||||
? commit(
|
||||
keepFileVars(prepared.xml, chartXMLRef.current),
|
||||
toolCall.toolCallId,
|
||||
)
|
||||
? commit(onAiPage(prepared.xml), toolCall.toolCallId)
|
||||
: prepared.error
|
||||
|
||||
if (validationError) {
|
||||
|
||||
+22
-21
@@ -1,6 +1,6 @@
|
||||
import { decompressPageContent } from "@/packages/mcp-server/src/load-diagram.ts"
|
||||
import { modelOfPage, pageElement } from "@/lib/diagram-pages"
|
||||
|
||||
/** What changed on the first page between two versions of a diagram */
|
||||
/** What changed on one page between two versions of a diagram */
|
||||
export interface ChangeSummary {
|
||||
shapesAdded: number
|
||||
shapesRemoved: number
|
||||
@@ -32,27 +32,20 @@ interface CellInfo {
|
||||
signature: string
|
||||
}
|
||||
|
||||
/** A page's mxGraphModel element, inflated when the page is compressed */
|
||||
function modelOfPage(diagram: Element): Element | null {
|
||||
const model = diagram.querySelector("mxGraphModel")
|
||||
if (model) return model
|
||||
const inflated = decompressPageContent(diagram.textContent || "")
|
||||
if (!inflated) return null
|
||||
const inner = new DOMParser().parseFromString(inflated, "text/xml")
|
||||
return inner.querySelector("mxGraphModel")
|
||||
}
|
||||
|
||||
function parse(xml: string): Document | null {
|
||||
if (!xml?.trim()) return null
|
||||
const doc = new DOMParser().parseFromString(xml, "text/xml")
|
||||
return doc.querySelector("parsererror") ? null : doc
|
||||
}
|
||||
|
||||
/** The first page's mxGraphModel element, whatever wrapper the XML has */
|
||||
function firstPageModel(xml: string): Element | null {
|
||||
/**
|
||||
* A page's mxGraphModel element, whatever wrapper the XML has: the page
|
||||
* with this id, else the first
|
||||
*/
|
||||
function pageModel(xml: string, pageId?: string | null): Element | null {
|
||||
const doc = parse(xml)
|
||||
if (!doc) return null
|
||||
const diagram = doc.querySelector("diagram")
|
||||
const diagram = pageElement(doc, pageId)
|
||||
if (diagram) return modelOfPage(diagram)
|
||||
return doc.querySelector("mxGraphModel") ?? doc.documentElement
|
||||
}
|
||||
@@ -133,10 +126,13 @@ function cellsIn(model: Element | null) {
|
||||
})
|
||||
}
|
||||
|
||||
/** Shapes and connectors of the first page (not the root and its layers) */
|
||||
function collectCells(xml: string): Map<string, CellInfo> {
|
||||
/** Shapes and connectors of one page (not the root and its layers) */
|
||||
function collectCells(
|
||||
xml: string,
|
||||
pageId?: string | null,
|
||||
): Map<string, CellInfo> {
|
||||
const cells = new Map<string, CellInfo>()
|
||||
for (const { id, cell, node } of cellsIn(firstPageModel(xml))) {
|
||||
for (const { id, cell, node } of cellsIn(pageModel(xml, pageId))) {
|
||||
if (id === "0" || cell.getAttribute("parent") === "0") continue
|
||||
cells.set(id, {
|
||||
isEdge: cell.getAttribute("edge") === "1",
|
||||
@@ -146,9 +142,14 @@ function collectCells(xml: string): Map<string, CellInfo> {
|
||||
return cells
|
||||
}
|
||||
|
||||
export function diffDiagrams(beforeXml: string, afterXml: string): DiagramDiff {
|
||||
const before = collectCells(beforeXml)
|
||||
const after = collectCells(afterXml)
|
||||
/** What changed on one page (the first when no id is given) */
|
||||
export function diffDiagrams(
|
||||
beforeXml: string,
|
||||
afterXml: string,
|
||||
pageId?: string | null,
|
||||
): DiagramDiff {
|
||||
const before = collectCells(beforeXml, pageId)
|
||||
const after = collectCells(afterXml, pageId)
|
||||
const summary: ChangeSummary = { ...EMPTY_SUMMARY }
|
||||
const touchedIds: string[] = []
|
||||
|
||||
|
||||
@@ -0,0 +1,138 @@
|
||||
/**
|
||||
* The page the AI works on is the page the user is viewing. These helpers
|
||||
* read that page out of a multi-page document and write a drawing into it
|
||||
* while the other pages stay as they are. A page id that matches no page
|
||||
* (or none at all, as with an external draw.io that cannot tell us) means
|
||||
* the first page, which is what the app always used before.
|
||||
*/
|
||||
|
||||
import { decompressPageContent } from "@/packages/mcp-server/src/load-diagram.ts"
|
||||
import {
|
||||
BLANK_MXFILE,
|
||||
type PageSelector,
|
||||
} from "@/packages/mcp-server/src/pages.ts"
|
||||
|
||||
function parse(xml: string): Document | null {
|
||||
if (!xml?.trim()) return null
|
||||
const doc = new DOMParser().parseFromString(xml, "text/xml")
|
||||
return doc.querySelector("parsererror") ? null : doc
|
||||
}
|
||||
|
||||
/** The <diagram> the AI works on: the one with this id, else the first */
|
||||
export function pageElement(
|
||||
doc: Document,
|
||||
pageId: string | null | undefined,
|
||||
): Element | null {
|
||||
const diagrams = Array.from(doc.getElementsByTagName("diagram"))
|
||||
if (diagrams.length === 0) return null
|
||||
return (
|
||||
(pageId &&
|
||||
diagrams.find(
|
||||
(diagram) => diagram.getAttribute("id") === pageId,
|
||||
)) ||
|
||||
diagrams[0]
|
||||
)
|
||||
}
|
||||
|
||||
/** A page's mxGraphModel element, inflated when the page is compressed */
|
||||
export function modelOfPage(diagram: Element): Element | null {
|
||||
const model = diagram.querySelector("mxGraphModel")
|
||||
if (model) return model
|
||||
const inflated = decompressPageContent(diagram.textContent || "")
|
||||
if (!inflated) return null
|
||||
return parse(inflated)?.querySelector("mxGraphModel") ?? null
|
||||
}
|
||||
|
||||
/**
|
||||
* Selector for editDiagram and applyDiagramOperations: the page with this
|
||||
* id when the document has it, else their default (the first page)
|
||||
*/
|
||||
export function pageSelectorFor(
|
||||
xml: string,
|
||||
pageId: string | null | undefined,
|
||||
): PageSelector {
|
||||
if (!pageId) return {}
|
||||
const doc = parse(xml)
|
||||
const found = doc
|
||||
? Array.from(doc.getElementsByTagName("diagram")).some(
|
||||
(diagram) => diagram.getAttribute("id") === pageId,
|
||||
)
|
||||
: false
|
||||
return found ? { page_id: pageId } : {}
|
||||
}
|
||||
|
||||
/**
|
||||
* The page's mxGraphModel as XML. A bare mxGraphModel is returned as it
|
||||
* is; null when the XML does not parse or has no model.
|
||||
*/
|
||||
export function pageModelXml(
|
||||
xml: string,
|
||||
pageId: string | null | undefined,
|
||||
): string | null {
|
||||
const doc = parse(xml)
|
||||
if (!doc) return null
|
||||
const root = doc.documentElement
|
||||
if (root.nodeName === "mxGraphModel") return xml
|
||||
if (root.nodeName !== "mxfile") return null
|
||||
const diagram = pageElement(doc, pageId)
|
||||
const model = diagram ? modelOfPage(diagram) : null
|
||||
return model ? new XMLSerializer().serializeToString(model) : null
|
||||
}
|
||||
|
||||
/**
|
||||
* The document with one page's content replaced by this model. The page
|
||||
* keeps its id and name, the other pages and the file's attributes (its
|
||||
* variables) stay. Without a document to put the page in, the blank
|
||||
* one-page file is used.
|
||||
*/
|
||||
export function replacePageModel(
|
||||
fileXml: string,
|
||||
pageId: string | null | undefined,
|
||||
modelXml: string,
|
||||
): string {
|
||||
const model = parse(modelXml)
|
||||
if (model?.documentElement.nodeName !== "mxGraphModel") {
|
||||
throw new Error("replacePageModel needs an <mxGraphModel>")
|
||||
}
|
||||
let doc = parse(fileXml)
|
||||
if (doc?.documentElement.nodeName !== "mxfile") doc = parse(BLANK_MXFILE)
|
||||
let diagram = pageElement(doc as Document, pageId)
|
||||
if (!diagram) {
|
||||
doc = parse(BLANK_MXFILE)
|
||||
diagram = pageElement(doc as Document, null)
|
||||
}
|
||||
const target = diagram as Element
|
||||
while (target.firstChild) target.removeChild(target.firstChild)
|
||||
target.appendChild(
|
||||
(doc as Document).importNode(model.documentElement, true),
|
||||
)
|
||||
return new XMLSerializer().serializeToString(doc as Document)
|
||||
}
|
||||
|
||||
/**
|
||||
* Where a drawn diagram goes. One page's worth (bare cells wrapped by
|
||||
* prepareNewDiagram, or a one-page file) replaces the AI's page of the
|
||||
* canvas file; file variables the drawn file sets replace the canvas
|
||||
* file's. A file with several pages replaces the whole document: the model
|
||||
* wrote the pages on purpose.
|
||||
*/
|
||||
export function placeOnPage(
|
||||
drawnXml: string,
|
||||
canvasXml: string,
|
||||
pageId: string | null | undefined,
|
||||
): string {
|
||||
const doc = parse(drawnXml)
|
||||
const drawnFile =
|
||||
doc?.documentElement.nodeName === "mxfile" ? doc.documentElement : null
|
||||
if (drawnFile && drawnFile.getElementsByTagName("diagram").length > 1) {
|
||||
return drawnXml
|
||||
}
|
||||
const model = pageModelXml(drawnXml, null)
|
||||
if (!model) return drawnXml
|
||||
const placed = replacePageModel(canvasXml, pageId, model)
|
||||
const vars = drawnFile?.getAttribute("vars")
|
||||
if (vars === null || vars === undefined) return placed
|
||||
const result = parse(placed) as Document
|
||||
result.documentElement.setAttribute("vars", vars)
|
||||
return new XMLSerializer().serializeToString(result)
|
||||
}
|
||||
+97
-27
@@ -9,7 +9,7 @@
|
||||
* With an external draw.io (cross-origin) none of this is available and the
|
||||
* app uses the postMessage protocol only.
|
||||
*/
|
||||
import { sameFileVars } from "@/lib/diagram-diff"
|
||||
import { isSameDocument, sameFileVars } from "@/lib/diagram-diff"
|
||||
import { hasCells } from "@/packages/mcp-server/src/pages.ts"
|
||||
import { type SelectedCell, useCanvasStore } from "@/stores/canvas-store"
|
||||
|
||||
@@ -152,23 +152,18 @@ function withoutUndo(fn: () => void) {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* replaceDiagramData replaces the current page with one <mxGraphModel>. Only
|
||||
* single-page documents qualify; multi-page ones fall back to a full load.
|
||||
*/
|
||||
function toSinglePageModel(xml: string): string | null {
|
||||
const doc = new DOMParser().parseFromString(xml, "text/xml")
|
||||
if (doc.querySelector("parsererror")) return null
|
||||
const root = doc.documentElement
|
||||
if (root.nodeName === "mxGraphModel") return xml
|
||||
if (root.nodeName !== "mxfile") return null
|
||||
const diagrams = root.getElementsByTagName("diagram")
|
||||
if (diagrams.length !== 1) return null
|
||||
const model = diagrams[0].getElementsByTagName("mxGraphModel")[0]
|
||||
/** Id of the page on the canvas, when draw.io has pages */
|
||||
function currentPageId(): string | null {
|
||||
const id = ui?.currentPage?.getId?.()
|
||||
return id === undefined || id === null ? null : String(id)
|
||||
}
|
||||
|
||||
/** A page's <mxGraphModel> as XML, inflated when the page is compressed */
|
||||
function modelXmlOf(diagram: Element): string | null {
|
||||
const model = diagram.getElementsByTagName("mxGraphModel")[0]
|
||||
if (model) return new XMLSerializer().serializeToString(model)
|
||||
// Compressed page
|
||||
try {
|
||||
const text = diagrams[0].textContent?.trim()
|
||||
const text = diagram.textContent?.trim()
|
||||
const inflated = text ? win?.Graph?.decompress?.(text) : null
|
||||
return typeof inflated === "string" &&
|
||||
inflated.includes("<mxGraphModel")
|
||||
@@ -179,8 +174,62 @@ function toSinglePageModel(xml: string): string | null {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* replaceDiagramData replaces the current page with one <mxGraphModel>:
|
||||
* the document's page with the canvas page's id, or its only page
|
||||
*/
|
||||
function pageModelOf(xml: string): string | null {
|
||||
const doc = new DOMParser().parseFromString(xml, "text/xml")
|
||||
if (doc.querySelector("parsererror")) return null
|
||||
const root = doc.documentElement
|
||||
if (root.nodeName === "mxGraphModel") return xml
|
||||
if (root.nodeName !== "mxfile") return null
|
||||
const diagrams = Array.from(root.getElementsByTagName("diagram"))
|
||||
const id = currentPageId()
|
||||
const diagram =
|
||||
diagrams.length === 1
|
||||
? diagrams[0]
|
||||
: diagrams.find((d) => d.getAttribute("id") === id)
|
||||
return diagram ? modelXmlOf(diagram) : null
|
||||
}
|
||||
|
||||
/**
|
||||
* The document without the current page, for comparing the other pages.
|
||||
* null when the XML is not a document; "" for a single page.
|
||||
*/
|
||||
function withoutCurrentPage(xml: string | null): string | null {
|
||||
if (!xml) return null
|
||||
const doc = new DOMParser().parseFromString(xml, "text/xml")
|
||||
if (doc.querySelector("parsererror")) return null
|
||||
const root = doc.documentElement
|
||||
if (root.nodeName === "mxGraphModel") return ""
|
||||
if (root.nodeName !== "mxfile") return null
|
||||
const diagrams = Array.from(root.getElementsByTagName("diagram"))
|
||||
if (diagrams.length <= 1) return ""
|
||||
const id = currentPageId()
|
||||
for (const diagram of diagrams) {
|
||||
if (diagram.getAttribute("id") === id) root.removeChild(diagram)
|
||||
}
|
||||
return new XMLSerializer().serializeToString(root)
|
||||
}
|
||||
|
||||
/**
|
||||
* The document changes the current page only: its other pages are the
|
||||
* canvas's (same names, cells and page settings; draw.io fills in settings
|
||||
* a loaded file left out, so the text can differ)
|
||||
*/
|
||||
function otherPagesSame(xml: string): boolean {
|
||||
const theirs = withoutCurrentPage(xml)
|
||||
if (theirs === null) return false
|
||||
const pageCount = Array.isArray(ui.pages) ? ui.pages.length : 1
|
||||
if (pageCount <= 1 && theirs === "") return true
|
||||
const ours = withoutCurrentPage(currentFileXml())
|
||||
if (ours === null || ours === "" || theirs === "") return false
|
||||
return isSameDocument(theirs, ours)
|
||||
}
|
||||
|
||||
function replace(xml: string) {
|
||||
const model = toSinglePageModel(xml)
|
||||
const model = pageModelOf(xml)
|
||||
if (!model || typeof ui?.replaceDiagramData !== "function") {
|
||||
throw new Error("Diagram can't be replaced in place")
|
||||
}
|
||||
@@ -202,7 +251,7 @@ function isEmptyModel(): boolean {
|
||||
|
||||
/** Whether a page has shapes; layers (cells under the root) are none */
|
||||
function hasShapes(xml: string): boolean {
|
||||
const model = toSinglePageModel(xml)
|
||||
const model = pageModelOf(xml)
|
||||
if (model === null) return hasCells(xml)
|
||||
const cells = new DOMParser()
|
||||
.parseFromString(model, "text/xml")
|
||||
@@ -256,8 +305,9 @@ export function canReplaceDiagram(xml: string): boolean {
|
||||
warnOnce("replace", "replaceDiagramData not found, using full loads")
|
||||
return false
|
||||
}
|
||||
const pageCount = Array.isArray(ui.pages) ? ui.pages.length : 1
|
||||
if (pageCount > 1) return false
|
||||
// Replacing changes the current page only: a document whose other
|
||||
// pages differ from the canvas's loads in full
|
||||
if (!otherPagesSame(xml)) return false
|
||||
// Replacing the page keeps the file's variables: other ones, or none
|
||||
// over a file with some, load in full
|
||||
if (
|
||||
@@ -268,7 +318,7 @@ export function canReplaceDiagram(xml: string): boolean {
|
||||
) {
|
||||
return false
|
||||
}
|
||||
const model = toSinglePageModel(xml)
|
||||
const model = pageModelOf(xml)
|
||||
// A document with them, or replacing one with them, loads in full
|
||||
return (
|
||||
model !== null &&
|
||||
@@ -285,13 +335,21 @@ export function previewDiagram(xml: string) {
|
||||
if (wasEmpty) fitDiagram()
|
||||
}
|
||||
|
||||
/** Name and id of the page in a single-page mxfile, if it has them */
|
||||
/**
|
||||
* Name and id the document gives the canvas page: of its page with that
|
||||
* id, or of its only page
|
||||
*/
|
||||
function pageOf(xml: string): { name: string | null; id: string | null } {
|
||||
const doc = new DOMParser().parseFromString(xml, "text/xml")
|
||||
const diagram =
|
||||
const diagrams =
|
||||
doc.documentElement?.nodeName === "mxfile"
|
||||
? doc.getElementsByTagName("diagram")[0]
|
||||
: undefined
|
||||
? Array.from(doc.getElementsByTagName("diagram"))
|
||||
: []
|
||||
const id = currentPageId()
|
||||
const diagram =
|
||||
diagrams.length === 1
|
||||
? diagrams[0]
|
||||
: diagrams.find((d) => d.getAttribute("id") === id)
|
||||
return {
|
||||
name: diagram?.getAttribute("name") || null,
|
||||
id: diagram?.getAttribute("id") || null,
|
||||
@@ -322,7 +380,7 @@ function changePageId(page: any, id: string) {
|
||||
export function commitDiagram(xml: string) {
|
||||
const wasEmpty =
|
||||
isEmptyModel() || (previewBase !== null && !hasShapes(previewBase))
|
||||
const base = previewBase ? toSinglePageModel(previewBase) : null
|
||||
const base = previewBase ? pageModelOf(previewBase) : null
|
||||
previewBase = null
|
||||
// Undo goes back to the diagram before streaming started. draw.io's
|
||||
// ReplaceDiagram change keeps the document it replaced for undo: hand it
|
||||
@@ -337,7 +395,7 @@ export function commitDiagram(xml: string) {
|
||||
const page = ui?.currentPage
|
||||
model?.beginUpdate()
|
||||
try {
|
||||
const next = toSinglePageModel(xml)
|
||||
const next = pageModelOf(xml)
|
||||
if (direct && next) {
|
||||
const change = new ReplaceDiagram(ui, parse(next))
|
||||
model.execute(change)
|
||||
@@ -368,6 +426,18 @@ export function resetPreview() {
|
||||
previewBase = null
|
||||
}
|
||||
|
||||
/** Show the page with this id; false when draw.io has no such page */
|
||||
export function selectPage(pageId: string): boolean {
|
||||
try {
|
||||
const page = ui?.getPageById?.(pageId)
|
||||
if (!page || typeof ui.selectPage !== "function") return false
|
||||
if (ui.currentPage !== page) ui.selectPage(page, true)
|
||||
return true
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// When the app last fitted the diagram on its own; a canvas resize right
|
||||
// after (the chat panel sliding in) fits again, unless the user zoomed since
|
||||
let lastAutoFitAt = 0
|
||||
|
||||
+31
-9
@@ -227,12 +227,18 @@ export function convertToLegalXml(xmlString: string): string {
|
||||
}
|
||||
|
||||
/**
|
||||
* Replace nodes in a Draw.io XML diagram
|
||||
* Replace the cells of one page of a Draw.io XML document
|
||||
* @param currentXML - The original Draw.io XML string
|
||||
* @param nodes - The XML string containing new nodes to replace in the diagram
|
||||
* @param pageId - The page whose cells are replaced; the first page when
|
||||
* missing or not found
|
||||
* @returns The updated XML string with replaced nodes
|
||||
*/
|
||||
export function replaceNodes(currentXML: string, nodes: string): string {
|
||||
export function replaceNodes(
|
||||
currentXML: string,
|
||||
nodes: string,
|
||||
pageId?: string | null,
|
||||
): string {
|
||||
// Check for valid inputs
|
||||
if (!currentXML || !nodes) {
|
||||
throw new Error("Both currentXML and nodes must be provided")
|
||||
@@ -251,16 +257,23 @@ export function replaceNodes(currentXML: string, nodes: string): string {
|
||||
|
||||
const nodesDoc = parser.parseFromString(nodesString, "text/xml")
|
||||
|
||||
// The page to draw on, when the document has it
|
||||
const page = pageId
|
||||
? Array.from(currentDoc.getElementsByTagName("diagram")).find(
|
||||
(diagram) => diagram.getAttribute("id") === pageId,
|
||||
)
|
||||
: undefined
|
||||
const scope: ParentNode = page ?? currentDoc
|
||||
// Find the root element in the current document
|
||||
let currentRoot = currentDoc.querySelector("mxGraphModel > root")
|
||||
let currentRoot = scope.querySelector("mxGraphModel > root")
|
||||
if (!currentRoot) {
|
||||
// If no root element is found, create the proper structure
|
||||
const mxGraphModel =
|
||||
currentDoc.querySelector("mxGraphModel") ||
|
||||
scope.querySelector("mxGraphModel") ||
|
||||
currentDoc.createElement("mxGraphModel")
|
||||
|
||||
if (!currentDoc.contains(mxGraphModel)) {
|
||||
currentDoc.appendChild(mxGraphModel)
|
||||
;(page ?? currentDoc).appendChild(mxGraphModel)
|
||||
}
|
||||
|
||||
currentRoot = currentDoc.createElement("root")
|
||||
@@ -330,10 +343,13 @@ export function replaceNodes(currentXML: string, nodes: string): string {
|
||||
|
||||
/**
|
||||
* Decode an xmlsvg export (SVG data URL) into uncompressed diagram XML.
|
||||
* Only the first page is returned; for the full multi-page document use the
|
||||
* autosaved chartXML instead.
|
||||
* One page is returned: the one with the given id, else the first; for the
|
||||
* full multi-page document use the autosaved chartXML instead.
|
||||
*/
|
||||
export function extractDiagramXML(xml_svg_string: string): string {
|
||||
export function extractDiagramXML(
|
||||
xml_svg_string: string,
|
||||
pageId?: string | null,
|
||||
): string {
|
||||
try {
|
||||
// 1. Parse the SVG string (using built-in DOMParser in a browser-like environment)
|
||||
const svgString = atob(xml_svg_string.slice(26))
|
||||
@@ -357,7 +373,13 @@ export function extractDiagramXML(xml_svg_string: string): string {
|
||||
|
||||
// 4. Parse the XML content
|
||||
const xmlDoc = parser.parseFromString(xmlContent, "text/xml")
|
||||
const diagramElement = xmlDoc.querySelector("diagram")
|
||||
const diagrams = Array.from(xmlDoc.getElementsByTagName("diagram"))
|
||||
const diagramElement =
|
||||
(pageId &&
|
||||
diagrams.find(
|
||||
(diagram) => diagram.getAttribute("id") === pageId,
|
||||
)) ||
|
||||
diagrams[0]
|
||||
|
||||
if (!diagramElement) {
|
||||
throw new Error("No diagram element found")
|
||||
|
||||
@@ -53,7 +53,7 @@ export function editDiagram(
|
||||
// Edges already on the page, so a label added to one is not wrapped, and
|
||||
// the layer a compact cell without a parent goes on
|
||||
const page = targetPageXml(xml, selector)
|
||||
const edges = edgeIdsOf(xml)
|
||||
const edges = edgeIdsOf(page)
|
||||
const layer = defaultLayerOf(page)
|
||||
for (const op of operations) {
|
||||
if (op.operation === "delete" || !op.new_xml) {
|
||||
|
||||
@@ -10,6 +10,8 @@ export interface DiagramVersion {
|
||||
number: number
|
||||
/** Full multi-page document after the change */
|
||||
xml: string
|
||||
/** The page the change was made on; older sessions have none (the first page) */
|
||||
pageId?: string
|
||||
/** Diagram before the change; kept only on the latest version */
|
||||
beforeXml?: string
|
||||
/** SVG data URL thumbnail; "" when none could be made */
|
||||
|
||||
@@ -131,7 +131,7 @@ const NEW_CELLS =
|
||||
cell("3", "R&D", 400) +
|
||||
`<UserObject id="4" label="Docs" link="https://example.com"><mxCell style="rounded=1;" vertex="1" parent="1"><mxGeometry x="580" y="40" width="120" height="60" as="geometry"/></mxCell></UserObject>`
|
||||
|
||||
test("display_diagram replaces the document with the fixed diagram", async ({
|
||||
test("display_diagram redraws the page on screen and keeps the other pages", async ({
|
||||
page: p,
|
||||
}) => {
|
||||
const canvas = await mockReplies(p, [
|
||||
@@ -155,9 +155,11 @@ test("display_diagram replaces the document with the fixed diagram", async ({
|
||||
timeout: 15000,
|
||||
})
|
||||
}
|
||||
// The old pages are gone
|
||||
// The first page was redrawn; the second page is still there
|
||||
await expect(canvas.getByText("Old A")).toHaveCount(0)
|
||||
await expect(pageTabs.getByText("Second", { exact: true })).toHaveCount(0)
|
||||
await expect(pageTabs.getByText("Second", { exact: true })).toBeVisible()
|
||||
await pageTabs.getByText("Second", { exact: true }).click()
|
||||
await expect(canvas.getByText("Old B")).toBeVisible()
|
||||
})
|
||||
|
||||
test("an edit with a fixable cell is fixed, not rejected", async ({
|
||||
|
||||
@@ -1001,7 +1001,7 @@ test.describe("Workspace", () => {
|
||||
await expect(canvas.getByText("Old A")).toBeVisible()
|
||||
await sendMessage(page, "Create a flowchart")
|
||||
await waitForComplete(page)
|
||||
// Multi-page documents are loaded in full: the card is the way back
|
||||
// The card is one way back
|
||||
await page.waitForTimeout(2000)
|
||||
await page.locator('[data-testid="version-undo"]').click()
|
||||
await expect(canvas.getByText("Old A")).toBeVisible()
|
||||
@@ -1533,7 +1533,7 @@ test.describe("Workspace", () => {
|
||||
await expect.poll(savedPageId, { timeout: 10000 }).toBe("orig")
|
||||
})
|
||||
|
||||
test("Ctrl+Z after an AI drawing gives the page its id back", async ({
|
||||
test("an AI drawing keeps the page's id, for links to the page", async ({
|
||||
page,
|
||||
}) => {
|
||||
await page.route("**/api/chat", drawThenEdit())
|
||||
@@ -1567,7 +1567,7 @@ test.describe("Workspace", () => {
|
||||
.querySelector("diagram")
|
||||
?.getAttribute("id")
|
||||
})
|
||||
await expect.poll(savedPageId, { timeout: 10000 }).toBe("page-1")
|
||||
await expect.poll(savedPageId, { timeout: 10000 }).toBe("orig")
|
||||
await canvas
|
||||
.locator(".geDiagramContainer")
|
||||
.click({ position: { x: 10, y: 10 } })
|
||||
@@ -1576,7 +1576,62 @@ test.describe("Workspace", () => {
|
||||
await expect.poll(savedPageId, { timeout: 10000 }).toBe("orig")
|
||||
await page.keyboard.press("ControlOrMeta+Shift+z")
|
||||
await expect(canvas.getByText("Process", { exact: true })).toBeVisible()
|
||||
await expect.poll(savedPageId, { timeout: 10000 }).toBe("page-1")
|
||||
await expect.poll(savedPageId, { timeout: 10000 }).toBe("orig")
|
||||
})
|
||||
|
||||
test("the AI reads and draws the page the user is viewing", async ({
|
||||
page,
|
||||
}) => {
|
||||
const bodies: any[] = []
|
||||
await page.route(
|
||||
"**/api/chat",
|
||||
drawThenEdit((body) => bodies.push(body)),
|
||||
)
|
||||
await openApp(page)
|
||||
await openDrawioFile(page, "two.drawio", TWO_PAGE_FILE)
|
||||
const canvas = getIframeContent(page)
|
||||
await expect(canvas.getByText("Old A")).toBeVisible()
|
||||
// The second page, through draw.io's own tabs
|
||||
await drawioTabs(page).getByText("P1", { exact: true }).click()
|
||||
await expect(canvas.getByText("Old B")).toBeVisible()
|
||||
await sendMessage(page, "Create a flowchart")
|
||||
await waitForComplete(page)
|
||||
// The model got the page on screen
|
||||
expect(bodies[0].xml).toContain("Old B")
|
||||
expect(bodies[0].xml).not.toContain("Old A")
|
||||
// Its drawing replaced that page, and only that page
|
||||
await expect(canvas.getByText("Process", { exact: true })).toBeVisible()
|
||||
await expect(canvas.getByText("Old B")).toHaveCount(0)
|
||||
const pagesIn = (xml: string) =>
|
||||
Object.fromEntries(
|
||||
Array.from(
|
||||
xml.matchAll(
|
||||
/<diagram[^>]*\bid="([^"]+)"[^>]*>([\s\S]*?)<\/diagram>/g,
|
||||
),
|
||||
(m) => [m[1], m[2]],
|
||||
),
|
||||
)
|
||||
await expect
|
||||
.poll(
|
||||
async () => {
|
||||
const pages = pagesIn(await savedDiagram(page))
|
||||
return {
|
||||
p0: pages.p0?.includes("Old A"),
|
||||
p1: pages.p1?.includes("Process"),
|
||||
oldB: pages.p1?.includes("Old B"),
|
||||
}
|
||||
},
|
||||
{ timeout: 10000 },
|
||||
)
|
||||
.toEqual({ p0: true, p1: true, oldB: false })
|
||||
// One undo step takes the drawing back, as on a one-page file
|
||||
await canvas
|
||||
.locator(".geDiagramContainer")
|
||||
.click({ position: { x: 10, y: 10 } })
|
||||
await page.keyboard.press("ControlOrMeta+z")
|
||||
await expect(canvas.getByText("Old B")).toBeVisible()
|
||||
await drawioTabs(page).getByText("P0", { exact: true }).click()
|
||||
await expect(canvas.getByText("Old A")).toBeVisible()
|
||||
})
|
||||
|
||||
test("page settings the AI writes in single quotes are applied too", async ({
|
||||
|
||||
@@ -153,9 +153,19 @@ describe("diffDiagrams", () => {
|
||||
expect(touchedIds).toEqual(["u1"])
|
||||
})
|
||||
|
||||
it("only looks at the first page", () => {
|
||||
it("looks at the first page unless told which page", () => {
|
||||
const twoPages = file(pageOf("p1", box("a")), pageOf("p2", box("z")))
|
||||
expect(diffDiagrams("", twoPages).summary.shapesAdded).toBe(1)
|
||||
const changedP2 = file(
|
||||
pageOf("p1", box("a")),
|
||||
pageOf("p2", box("z") + box("y")),
|
||||
)
|
||||
expect(diffDiagrams(twoPages, changedP2, "p2").touchedIds).toEqual([
|
||||
"y",
|
||||
])
|
||||
expect(diffDiagrams(twoPages, changedP2, "p1").touchedIds).toEqual([])
|
||||
// An unknown page is the first one
|
||||
expect(diffDiagrams(twoPages, changedP2, "p9").touchedIds).toEqual([])
|
||||
})
|
||||
|
||||
it("reads compressed pages", () => {
|
||||
|
||||
@@ -0,0 +1,125 @@
|
||||
import pako from "pako"
|
||||
import { describe, expect, it } from "vitest"
|
||||
import {
|
||||
pageModelXml,
|
||||
pageSelectorFor,
|
||||
placeOnPage,
|
||||
replacePageModel,
|
||||
} from "@/lib/diagram-pages"
|
||||
|
||||
const box = (id: string) =>
|
||||
`<mxCell id="${id}" value="${id}" vertex="1" parent="1"><mxGeometry x="0" y="0" width="80" height="40" as="geometry"/></mxCell>`
|
||||
const model = (cells: string) =>
|
||||
`<mxGraphModel><root><mxCell id="0"/><mxCell id="1" parent="0"/>${cells}</root></mxGraphModel>`
|
||||
const page = (id: string, cells: string) =>
|
||||
`<diagram id="${id}" name="Page ${id}">${model(cells)}</diagram>`
|
||||
const packedPage = (id: string, cells: string) => {
|
||||
const packed = Buffer.from(
|
||||
pako.deflateRaw(encodeURIComponent(model(cells))),
|
||||
).toString("base64")
|
||||
return `<diagram id="${id}" name="Page ${id}">${packed}</diagram>`
|
||||
}
|
||||
const TWO_PAGES = `<mxfile vars="{"x":"1"}">${page("a", box("A"))}${page("b", box("B"))}</mxfile>`
|
||||
|
||||
const pagesOf = (xml: string) =>
|
||||
Array.from(
|
||||
new DOMParser()
|
||||
.parseFromString(xml, "text/xml")
|
||||
.getElementsByTagName("diagram"),
|
||||
).map((d) => ({
|
||||
id: d.getAttribute("id"),
|
||||
name: d.getAttribute("name"),
|
||||
cells: Array.from(d.getElementsByTagName("mxCell"))
|
||||
.map((c) => c.getAttribute("id"))
|
||||
.filter((id) => id !== "0" && id !== "1"),
|
||||
}))
|
||||
|
||||
describe("pageModelXml", () => {
|
||||
it("gives the page with the id, and the first page without one", () => {
|
||||
expect(pageModelXml(TWO_PAGES, "b")).toContain('id="B"')
|
||||
expect(pageModelXml(TWO_PAGES, "b")).not.toContain('id="A"')
|
||||
expect(pageModelXml(TWO_PAGES, null)).toContain('id="A"')
|
||||
expect(pageModelXml(TWO_PAGES, "missing")).toContain('id="A"')
|
||||
})
|
||||
|
||||
it("inflates a compressed page and passes a bare model through", () => {
|
||||
const file = `<mxfile>${page("a", box("A"))}${packedPage("b", box("B"))}</mxfile>`
|
||||
expect(pageModelXml(file, "b")).toContain('id="B"')
|
||||
expect(pageModelXml(model(box("Z")), "b")).toBe(model(box("Z")))
|
||||
expect(pageModelXml("<broken", "a")).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe("pageSelectorFor", () => {
|
||||
it("selects the page only when the document has it", () => {
|
||||
expect(pageSelectorFor(TWO_PAGES, "b")).toEqual({ page_id: "b" })
|
||||
expect(pageSelectorFor(TWO_PAGES, "missing")).toEqual({})
|
||||
expect(pageSelectorFor(TWO_PAGES, null)).toEqual({})
|
||||
expect(pageSelectorFor(model(box("Z")), "b")).toEqual({})
|
||||
})
|
||||
})
|
||||
|
||||
describe("replacePageModel", () => {
|
||||
it("replaces one page and keeps the others, the names and the variables", () => {
|
||||
const result = replacePageModel(TWO_PAGES, "b", model(box("N")))
|
||||
expect(pagesOf(result)).toEqual([
|
||||
{ id: "a", name: "Page a", cells: ["A"] },
|
||||
{ id: "b", name: "Page b", cells: ["N"] },
|
||||
])
|
||||
expect(result).toContain('vars="{"x":"1"}"')
|
||||
})
|
||||
|
||||
it("falls back to the first page for an unknown or missing id", () => {
|
||||
for (const id of ["missing", null]) {
|
||||
const result = replacePageModel(TWO_PAGES, id, model(box("N")))
|
||||
expect(pagesOf(result).map((p) => p.cells)).toEqual([["N"], ["B"]])
|
||||
}
|
||||
})
|
||||
|
||||
it("starts from the blank file when there is no document", () => {
|
||||
for (const base of ["", "<mxGraphModel/>", "<broken"]) {
|
||||
const result = replacePageModel(base, "any", model(box("N")))
|
||||
expect(pagesOf(result)).toEqual([
|
||||
{ id: "page-1", name: "Page-1", cells: ["N"] },
|
||||
])
|
||||
}
|
||||
})
|
||||
|
||||
it("refuses anything but a model", () => {
|
||||
expect(() => replacePageModel(TWO_PAGES, "a", "<root/>")).toThrow()
|
||||
})
|
||||
})
|
||||
|
||||
describe("placeOnPage", () => {
|
||||
it("puts one page's worth of drawing on the model's page", () => {
|
||||
const drawn = `<mxfile><diagram id="page-1" name="Page-1">${model(box("N"))}</diagram></mxfile>`
|
||||
const result = placeOnPage(drawn, TWO_PAGES, "b")
|
||||
expect(pagesOf(result)).toEqual([
|
||||
{ id: "a", name: "Page a", cells: ["A"] },
|
||||
{ id: "b", name: "Page b", cells: ["N"] },
|
||||
])
|
||||
})
|
||||
|
||||
it("applies the drawn file's variables, and keeps the canvas's otherwise", () => {
|
||||
const withVars = `<mxfile vars="{"team":"New"}"><diagram id="p" name="P">${model(box("N"))}</diagram></mxfile>`
|
||||
expect(placeOnPage(withVars, TWO_PAGES, "b")).toContain(
|
||||
'vars="{"team":"New"}"',
|
||||
)
|
||||
const without = `<mxfile><diagram id="p" name="P">${model(box("N"))}</diagram></mxfile>`
|
||||
expect(placeOnPage(without, TWO_PAGES, "b")).toContain(
|
||||
'vars="{"x":"1"}"',
|
||||
)
|
||||
})
|
||||
|
||||
it("lets a drawing with several pages replace the document", () => {
|
||||
const drawn = `<mxfile>${page("x", box("X"))}${page("y", box("Y"))}</mxfile>`
|
||||
expect(placeOnPage(drawn, TWO_PAGES, "b")).toBe(drawn)
|
||||
})
|
||||
|
||||
it("draws on the blank file when the canvas is empty", () => {
|
||||
const drawn = `<mxfile><diagram id="page-1" name="Page-1">${model(box("N"))}</diagram></mxfile>`
|
||||
expect(pagesOf(placeOnPage(drawn, "", null))).toEqual([
|
||||
{ id: "page-1", name: "Page-1", cells: ["N"] },
|
||||
])
|
||||
})
|
||||
})
|
||||
@@ -263,3 +263,50 @@ describe("applyDiagramOperations with wrapped cells", () => {
|
||||
expect(errors[0]?.message).toContain("not found")
|
||||
})
|
||||
})
|
||||
|
||||
describe("edit_diagram on one page of a multi-page file", () => {
|
||||
// Page A has an edge "g"; on page B, "g" is a shape on layer "L"
|
||||
const twoPages = `<mxfile><diagram id="pa" name="A"><mxGraphModel><root><mxCell id="0"/><mxCell id="1" parent="0"/>${box("s")}${box("t")}<mxCell id="g" edge="1" parent="1" source="s" target="t"><mxGeometry relative="1" as="geometry"/></mxCell></root></mxGraphModel></diagram><diagram id="pb" name="B"><mxGraphModel><root><mxCell id="0"/><mxCell id="L" parent="0"/>${box("g", "L")}</root></mxGraphModel></diagram></mxfile>`
|
||||
const cellOn = (xml: string, pageId: string, id: string) =>
|
||||
new DOMParser()
|
||||
.parseFromString(xml, "text/xml")
|
||||
.querySelector(`diagram[id="${pageId}"] mxCell[id="${id}"]`)
|
||||
|
||||
it("puts a compact cell without a parent on that page's layer", () => {
|
||||
const outcome = editDiagram(
|
||||
twoPages,
|
||||
[
|
||||
{
|
||||
operation: "add",
|
||||
cell_id: "c",
|
||||
new_xml:
|
||||
'<mxCell id="c" value="c" x="10" y="10" w="80" h="40"/>',
|
||||
},
|
||||
],
|
||||
{ page_id: "pb" },
|
||||
)
|
||||
if (!outcome.ok) throw new Error(outcome.errors.join("; "))
|
||||
expect(cellOn(outcome.xml, "pb", "c")?.getAttribute("parent")).toBe("L")
|
||||
expect(cellOn(outcome.xml, "pa", "c")).toBeNull()
|
||||
})
|
||||
|
||||
it("reads the edges of that page only", () => {
|
||||
// A shape inside the group "g" is no edge label on page B
|
||||
const outcome = editDiagram(
|
||||
twoPages,
|
||||
[
|
||||
{
|
||||
operation: "add",
|
||||
cell_id: "c",
|
||||
new_xml:
|
||||
'<mxCell id="c" value="c" parent="g" x="10" y="10" w="80" h="40"/>',
|
||||
},
|
||||
],
|
||||
{ page_id: "pb" },
|
||||
)
|
||||
if (!outcome.ok) throw new Error(outcome.errors.join("; "))
|
||||
expect(cellOn(outcome.xml, "pb", "c")?.getAttribute("style")).toContain(
|
||||
"whiteSpace=wrap",
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -13,12 +13,14 @@ const AFTER_FIRST_EDIT = page(box("a") + box("b"))
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
loadDiagram: vi.fn((_xml: string, ..._rest: unknown[]) => null),
|
||||
// The canvas for a test that needs another one
|
||||
canvas: null as string | null,
|
||||
}))
|
||||
|
||||
vi.mock("@/contexts/diagram-context", () => ({
|
||||
useDiagram: () => ({
|
||||
chartXML: BEFORE_FIRST_EDIT,
|
||||
chartXMLRef: { current: AFTER_FIRST_EDIT },
|
||||
chartXML: mocks.canvas ?? BEFORE_FIRST_EDIT,
|
||||
chartXMLRef: { current: mocks.canvas ?? AFTER_FIRST_EDIT },
|
||||
loadDiagram: mocks.loadDiagram,
|
||||
}),
|
||||
}))
|
||||
@@ -145,3 +147,81 @@ describe("the streaming preview of edit_diagram", () => {
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe("the streaming preview on the model's page", () => {
|
||||
const box2 = (id: string, parent = "1") =>
|
||||
`<mxCell id="${id}" value="${id}" vertex="1" parent="${parent}"><mxGeometry x="0" y="0" width="80" height="40" as="geometry"/></mxCell>`
|
||||
// Page A has an edge "g"; on page B, "g" is a shape on layer "L"
|
||||
const twoPages = `<mxfile><diagram id="pa" name="A"><mxGraphModel><root><mxCell id="0"/><mxCell id="1" parent="0"/>${box2("s")}${box2("t")}<mxCell id="g" edge="1" parent="1" source="s" target="t"><mxGeometry relative="1" as="geometry"/></mxCell></root></mxGraphModel></diagram><diagram id="pb" name="B"><mxGraphModel><root><mxCell id="0"/><mxCell id="L" parent="0"/>${box2("g", "L")}</root></mxGraphModel></diagram></mxfile>`
|
||||
const cellOn = (xml: string, pageId: string, id: string) =>
|
||||
new DOMParser()
|
||||
.parseFromString(xml, "text/xml")
|
||||
.querySelector(`diagram[id="${pageId}"] mxCell[id="${id}"]`)
|
||||
const stream = (part: object) =>
|
||||
renderHook(() =>
|
||||
useDiagramStreaming({
|
||||
messages: [
|
||||
{ id: "m1", role: "assistant", parts: [part] },
|
||||
] as any,
|
||||
processedToolCallsRef: { current: new Set() },
|
||||
editDiagramOriginalXmlRef: { current: new Map() },
|
||||
loadedMessageIdsRef: { current: new Set() },
|
||||
turnPageIdRef: { current: "pb" },
|
||||
}),
|
||||
)
|
||||
|
||||
it("applies an edit to that page, with its layer and its edges", () => {
|
||||
mocks.loadDiagram.mockClear()
|
||||
mocks.canvas = twoPages
|
||||
try {
|
||||
stream({
|
||||
type: "tool-edit_diagram",
|
||||
toolCallId: "edit-p",
|
||||
state: "input-streaming",
|
||||
input: {
|
||||
operations: [
|
||||
{
|
||||
operation: "add",
|
||||
cell_id: "c",
|
||||
new_xml:
|
||||
'<mxCell id="c" value="c" x="10" y="10" w="80" h="40"/>',
|
||||
},
|
||||
{
|
||||
operation: "add",
|
||||
cell_id: "d",
|
||||
new_xml:
|
||||
'<mxCell id="d" value="d" parent="g" x="10" y="10" w="80" h="40"/>',
|
||||
},
|
||||
],
|
||||
},
|
||||
})
|
||||
const loaded = mocks.loadDiagram.mock.calls.at(-1)?.[0] as string
|
||||
expect(cellOn(loaded, "pb", "c")?.getAttribute("parent")).toBe("L")
|
||||
expect(cellOn(loaded, "pb", "d")?.getAttribute("style")).toContain(
|
||||
"whiteSpace=wrap",
|
||||
)
|
||||
expect(cellOn(loaded, "pa", "c")).toBeNull()
|
||||
} finally {
|
||||
mocks.canvas = null
|
||||
}
|
||||
})
|
||||
|
||||
it("draws display_diagram's cells on that page", () => {
|
||||
mocks.loadDiagram.mockClear()
|
||||
mocks.canvas = twoPages
|
||||
try {
|
||||
stream({
|
||||
type: "tool-display_diagram",
|
||||
toolCallId: "draw-p",
|
||||
state: "input-streaming",
|
||||
input: { xml: box2("n") },
|
||||
})
|
||||
const loaded = mocks.loadDiagram.mock.calls.at(-1)?.[0] as string
|
||||
expect(cellOn(loaded, "pb", "n")).not.toBeNull()
|
||||
expect(cellOn(loaded, "pb", "g")).toBeNull()
|
||||
expect(cellOn(loaded, "pa", "g")).not.toBeNull()
|
||||
} finally {
|
||||
mocks.canvas = null
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
@@ -276,3 +276,102 @@ describe("a cut off drawing", () => {
|
||||
expect(refs.continuationOriginalRef.current).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe("the model's page", () => {
|
||||
const page = (id: string, cells: string) =>
|
||||
`<diagram id="${id}" name="Page ${id}"><mxGraphModel><root><mxCell id="0"/><mxCell id="1" parent="0"/>${cells}</root></mxGraphModel></diagram>`
|
||||
const twoPages = `<mxfile>${page("a", box("A"))}${page("b", box("B"))}</mxfile>`
|
||||
/** Ids of the shapes on a page (not the root cells) */
|
||||
const shapesOn = (xml: string, pageId: string) =>
|
||||
Array.from(
|
||||
new DOMParser()
|
||||
.parseFromString(xml, "text/xml")
|
||||
.querySelectorAll(`diagram[id="${pageId}"] mxCell`),
|
||||
)
|
||||
.map((cell) => cell.getAttribute("id"))
|
||||
.filter((id) => id !== "0" && id !== "1")
|
||||
|
||||
function setupOn(pageId: string | null) {
|
||||
const onDisplayChart = vi.fn(
|
||||
(
|
||||
_xml: string,
|
||||
_skipValidation?: boolean,
|
||||
_mode?: string,
|
||||
_meta?: object,
|
||||
): string | null => null,
|
||||
)
|
||||
const { result } = renderHook(() =>
|
||||
useDiagramToolHandlers({
|
||||
partialXmlRef: { current: "" },
|
||||
continuationOriginalRef: { current: null },
|
||||
editDiagramOriginalXmlRef: { current: new Map() },
|
||||
processedToolCallsRef: { current: new Set() },
|
||||
validationRetryCountRef: { current: 0 },
|
||||
chartXMLRef: { current: twoPages },
|
||||
turnPageIdRef: { current: pageId },
|
||||
onDisplayChart,
|
||||
onFetchChart: async () => twoPages,
|
||||
enableVlmValidation: false,
|
||||
}),
|
||||
)
|
||||
const addToolOutput = vi.fn()
|
||||
const call = (toolName: string, input: object) =>
|
||||
result.current.handleToolCall(
|
||||
{ toolCall: { toolCallId: "call-1", toolName, input } },
|
||||
addToolOutput,
|
||||
)
|
||||
return { onDisplayChart, addToolOutput, call }
|
||||
}
|
||||
|
||||
it("display_diagram draws on that page and keeps the others", async () => {
|
||||
const { onDisplayChart, call } = setupOn("b")
|
||||
await call("display_diagram", { xml: box("N") })
|
||||
const [xml, , mode, meta] = onDisplayChart.mock.calls[0]
|
||||
expect(mode).toBe("commit")
|
||||
expect(meta).toEqual({ toolCallId: "call-1", pageId: "b" })
|
||||
expect(shapesOn(xml, "a")).toEqual(["A"])
|
||||
expect(shapesOn(xml, "b")).toEqual(["N"])
|
||||
// The page keeps its own name
|
||||
expect(xml).toContain('name="Page b"')
|
||||
})
|
||||
|
||||
it("edit_diagram changes that page", async () => {
|
||||
const { onDisplayChart, addToolOutput, call } = setupOn("b")
|
||||
await call("edit_diagram", {
|
||||
operations: [{ operation: "delete", cell_id: "B" }],
|
||||
})
|
||||
expect(addToolOutput.mock.lastCall?.[0].state).toBeUndefined()
|
||||
const [xml] = onDisplayChart.mock.calls.at(-1) as [string]
|
||||
expect(shapesOn(xml, "a")).toEqual(["A"])
|
||||
expect(shapesOn(xml, "b")).toEqual([])
|
||||
})
|
||||
|
||||
it("shows the model only its page when an edit fails", async () => {
|
||||
const { addToolOutput, call } = setupOn("b")
|
||||
await call("edit_diagram", {
|
||||
operations: [{ operation: "delete", cell_id: "missing" }],
|
||||
})
|
||||
const output = addToolOutput.mock.lastCall?.[0]
|
||||
expect(output?.state).toBe("output-error")
|
||||
expect(output?.errorText).toContain('id="B"')
|
||||
expect(output?.errorText).not.toContain('id="A"')
|
||||
})
|
||||
|
||||
it("draws on the first page when the page is not known", async () => {
|
||||
const { onDisplayChart, call } = setupOn(null)
|
||||
await call("display_diagram", { xml: box("N") })
|
||||
const [xml, , , meta] = onDisplayChart.mock.calls[0]
|
||||
expect(meta).toEqual({ toolCallId: "call-1", pageId: null })
|
||||
expect(shapesOn(xml, "a")).toEqual(["N"])
|
||||
expect(shapesOn(xml, "b")).toEqual(["B"])
|
||||
})
|
||||
|
||||
it("lets a drawing with several pages replace the document", async () => {
|
||||
const { onDisplayChart, call } = setupOn("b")
|
||||
const drawn = `<mxfile>${page("x", box("X"))}${page("y", box("Y"))}</mxfile>`
|
||||
await call("display_diagram", { xml: drawn })
|
||||
const [xml] = onDisplayChart.mock.calls[0]
|
||||
expect(shapesOn(xml, "x")).toEqual(["X"])
|
||||
expect(shapesOn(xml, "a")).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,9 +1,12 @@
|
||||
import pako from "pako"
|
||||
import { describe, expect, it } from "vitest"
|
||||
import {
|
||||
cn,
|
||||
extractCompleteMxCells,
|
||||
extractDiagramXML,
|
||||
isMxCellXmlComplete,
|
||||
isRealDiagram,
|
||||
replaceNodes,
|
||||
} from "@/lib/utils"
|
||||
import { BLANK_MXFILE } from "@/packages/mcp-server/src/pages.ts"
|
||||
|
||||
@@ -134,3 +137,63 @@ describe("extractCompleteMxCells", () => {
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
const pageBox = (id: string) => `<mxCell id="${id}" vertex="1" parent="1"/>`
|
||||
const pageModel = (cells: string) =>
|
||||
`<mxGraphModel><root><mxCell id="0"/><mxCell id="1" parent="0"/>${cells}</root></mxGraphModel>`
|
||||
const pageOf = (id: string, cells: string) =>
|
||||
`<diagram id="${id}" name="${id}">${pageModel(cells)}</diagram>`
|
||||
/** Ids of the shapes on a page (not the root cells) */
|
||||
const shapesOn = (xml: string, pageId: string) =>
|
||||
Array.from(
|
||||
new DOMParser()
|
||||
.parseFromString(xml, "text/xml")
|
||||
.querySelectorAll(`diagram[id="${pageId}"] mxCell`),
|
||||
)
|
||||
.map((cell) => cell.getAttribute("id"))
|
||||
.filter((id) => id !== "0" && id !== "1")
|
||||
|
||||
describe("replaceNodes", () => {
|
||||
const two = `<mxfile>${pageOf("a", pageBox("A"))}${pageOf("b", pageBox("B"))}</mxfile>`
|
||||
|
||||
it("replaces the first page's cells by default", () => {
|
||||
const out = replaceNodes(two, pageBox("N"))
|
||||
expect(shapesOn(out, "a")).toEqual(["N"])
|
||||
expect(shapesOn(out, "b")).toEqual(["B"])
|
||||
})
|
||||
|
||||
it("replaces the given page's cells and keeps the others", () => {
|
||||
const out = replaceNodes(two, pageBox("N"), "b")
|
||||
expect(shapesOn(out, "a")).toEqual(["A"])
|
||||
expect(shapesOn(out, "b")).toEqual(["N"])
|
||||
})
|
||||
|
||||
it("uses the first page when the given page is not there", () => {
|
||||
const out = replaceNodes(two, pageBox("N"), "zzz")
|
||||
expect(shapesOn(out, "a")).toEqual(["N"])
|
||||
expect(shapesOn(out, "b")).toEqual(["B"])
|
||||
})
|
||||
})
|
||||
|
||||
describe("extractDiagramXML", () => {
|
||||
const packed = (model: string) =>
|
||||
Buffer.from(pako.deflateRaw(encodeURIComponent(model))).toString(
|
||||
"base64",
|
||||
)
|
||||
const file = `<mxfile><diagram id="a" name="a">${packed(pageModel(pageBox("A")))}</diagram><diagram id="b" name="b">${packed(pageModel(pageBox("B")))}</diagram></mxfile>`
|
||||
const escapeAttr = (s: string) =>
|
||||
s
|
||||
.replace(/&/g, "&")
|
||||
.replace(/"/g, """)
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
const svg = `<svg xmlns="http://www.w3.org/2000/svg" content="${escapeAttr(file)}"></svg>`
|
||||
const dataUrl = `data:image/svg+xml;base64,${Buffer.from(svg).toString("base64")}`
|
||||
|
||||
it("returns the first page by default and a page by its id", () => {
|
||||
expect(extractDiagramXML(dataUrl)).toContain('id="A"')
|
||||
expect(extractDiagramXML(dataUrl, "b")).toContain('id="B"')
|
||||
expect(extractDiagramXML(dataUrl, "b")).not.toContain('id="A"')
|
||||
expect(extractDiagramXML(dataUrl, "zzz")).toContain('id="A"')
|
||||
})
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user