mirror of
https://github.com/DayuanJiang/next-ai-draw-io.git
synced 2026-10-11 12:09:53 +08:00
Compare commits
6
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3d1a020b35 | ||
|
|
8d690503d4 | ||
|
|
4b4f36e374 | ||
|
|
a9091e96f1 | ||
|
|
213b672e92 | ||
|
|
d1ee1594f1 |
@@ -0,0 +1,55 @@
|
|||||||
|
# Issues labelled "wait for confrim" are waiting for the reporter. After 15
|
||||||
|
# days without activity the issue gets a reminder and the "no response"
|
||||||
|
# label; after 15 more days it is closed. A reply from the reporter removes
|
||||||
|
# both labels, so the issue shows up as needing the maintainer again.
|
||||||
|
name: Wait for confirmation
|
||||||
|
|
||||||
|
on:
|
||||||
|
schedule:
|
||||||
|
- cron: "17 3 * * *"
|
||||||
|
workflow_dispatch:
|
||||||
|
issue_comment:
|
||||||
|
types: [created]
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
issues: write
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
remind-and-close:
|
||||||
|
if: github.event_name != 'issue_comment'
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/stale@v9
|
||||||
|
with:
|
||||||
|
only-issue-labels: "wait for confrim"
|
||||||
|
days-before-issue-stale: 15
|
||||||
|
days-before-issue-close: 15
|
||||||
|
days-before-pr-stale: -1
|
||||||
|
days-before-pr-close: -1
|
||||||
|
stale-issue-label: "no response"
|
||||||
|
stale-issue-message: >-
|
||||||
|
Still there? We asked for more information 15 days ago. A reply
|
||||||
|
would help; if we hear nothing in another 15 days this issue will
|
||||||
|
be closed. It can be reopened any time.
|
||||||
|
close-issue-message: >-
|
||||||
|
No reply in 30 days, so this issue is closed for now. If the
|
||||||
|
problem is still there, reply or reopen it.
|
||||||
|
close-issue-reason: not_planned
|
||||||
|
remove-stale-when-updated: true
|
||||||
|
|
||||||
|
reporter-replied:
|
||||||
|
if: >-
|
||||||
|
github.event_name == 'issue_comment' &&
|
||||||
|
contains(github.event.issue.labels.*.name, 'wait for confrim') &&
|
||||||
|
github.event.comment.user.type != 'Bot' &&
|
||||||
|
!contains(fromJSON('["OWNER","MEMBER","COLLABORATOR"]'), github.event.comment.author_association)
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- name: The reporter answered, so the issue is the maintainer's again
|
||||||
|
run: >-
|
||||||
|
gh issue edit "$NUMBER" --repo "$REPO"
|
||||||
|
--remove-label "wait for confrim" --remove-label "no response"
|
||||||
|
env:
|
||||||
|
GH_TOKEN: ${{ github.token }}
|
||||||
|
NUMBER: ${{ github.event.issue.number }}
|
||||||
|
REPO: ${{ github.repository }}
|
||||||
@@ -46,11 +46,6 @@ export function CanvasStage({
|
|||||||
const engine = useChatEngine()
|
const engine = useChatEngine()
|
||||||
const isDark = useSettingsStore((s) => s.isDark)
|
const isDark = useSettingsStore((s) => s.isDark)
|
||||||
const hasEditor = useCanvasStore((s) => s.hasEditor)
|
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
|
// Canvas width when the chat panel starts sliding in or out
|
||||||
const slideStartWidthRef = useRef(0)
|
const slideStartWidthRef = useRef(0)
|
||||||
|
|
||||||
@@ -168,7 +163,7 @@ export function CanvasStage({
|
|||||||
<SelectionAsk
|
<SelectionAsk
|
||||||
width={size.width}
|
width={size.width}
|
||||||
height={size.height}
|
height={size.height}
|
||||||
hidden={engine.isBusy || !onFirstPage}
|
hidden={engine.isBusy}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -42,6 +42,7 @@ import { useValidateDiagram } from "@/hooks/use-validate-diagram"
|
|||||||
import { getApiEndpoint } from "@/lib/base-path"
|
import { getApiEndpoint } from "@/lib/base-path"
|
||||||
import { findCachedResponse } from "@/lib/cached-responses"
|
import { findCachedResponse } from "@/lib/cached-responses"
|
||||||
import { buildChatHeaders } from "@/lib/chat-request"
|
import { buildChatHeaders } from "@/lib/chat-request"
|
||||||
|
import { pageModelXml, placeOnPage } from "@/lib/diagram-pages"
|
||||||
import { EMPTY_DRAWIO_DOCUMENT } from "@/lib/drawio/drawio-config"
|
import { EMPTY_DRAWIO_DOCUMENT } from "@/lib/drawio/drawio-config"
|
||||||
import { formatMessage } from "@/lib/i18n/utils"
|
import { formatMessage } from "@/lib/i18n/utils"
|
||||||
import { isPdfFile, isTextFile } from "@/lib/pdf-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
|
* Snapshots keep the full multi-page document, but the model sees and
|
||||||
* edits the first page, so give it the first page's mxGraphModel.
|
* edits one page, the one the user is viewing (the first when unknown), so
|
||||||
* Older snapshots already hold a single mxGraphModel and are returned as is.
|
* 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
|
if (!xml.includes("<mxfile")) return xml
|
||||||
const doc = new DOMParser().parseFromString(xml, "text/xml")
|
const model = pageModelXml(xml, pageId)
|
||||||
const model = doc.querySelector("diagram")?.querySelector("mxGraphModel")
|
return model ? formatXML(model) : xml
|
||||||
return model ? formatXML(new XMLSerializer().serializeToString(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)
|
// 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)
|
// XML snapshot taken before each user message (keyed by message index)
|
||||||
const xmlSnapshotsRef = useRef<Map<number, string>>(new Map())
|
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)
|
// Index of the user message whose turn is running (versions belong to it)
|
||||||
const currentTurnRef = useRef(0)
|
const currentTurnRef = useRef(0)
|
||||||
// The chat (its generation) the running turn was sent in: a reply that
|
// The chat (its generation) the running turn was sent in: a reply that
|
||||||
@@ -450,6 +459,7 @@ export function ChatEngineProvider({
|
|||||||
validationRetryCountRef,
|
validationRetryCountRef,
|
||||||
// A preview undone just before the tool call is in this one already
|
// A preview undone just before the tool call is in this one already
|
||||||
chartXMLRef: liveChartXMLRef,
|
chartXMLRef: liveChartXMLRef,
|
||||||
|
turnPageIdRef,
|
||||||
onDisplayChart,
|
onDisplayChart,
|
||||||
onFetchChart,
|
onFetchChart,
|
||||||
captureValidationPng,
|
captureValidationPng,
|
||||||
@@ -748,6 +758,7 @@ export function ChatEngineProvider({
|
|||||||
processedToolCallsRef,
|
processedToolCallsRef,
|
||||||
editDiagramOriginalXmlRef,
|
editDiagramOriginalXmlRef,
|
||||||
loadedMessageIdsRef,
|
loadedMessageIdsRef,
|
||||||
|
turnPageIdRef,
|
||||||
})
|
})
|
||||||
|
|
||||||
const { restoreVersion, undoVersion } = useVersions({
|
const { restoreVersion, undoVersion } = useVersions({
|
||||||
@@ -762,14 +773,10 @@ export function ChatEngineProvider({
|
|||||||
// ---------------------------------------------------------------------
|
// ---------------------------------------------------------------------
|
||||||
|
|
||||||
const selection = useCanvasStore((s) => s.selection)
|
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 [dismissedSelectionKey, setDismissedSelectionKey] = useState("")
|
||||||
const selectionKey = selection.map((c) => c.id).join(",")
|
const selectionKey = selection.map((c) => c.id).join(",")
|
||||||
const chatSelection =
|
const chatSelection =
|
||||||
!onFirstPage || selectionKey === dismissedSelectionKey ? [] : selection
|
selectionKey === dismissedSelectionKey ? [] : selection
|
||||||
// Once nothing is selected, picking the same shapes again attaches them
|
// Once nothing is selected, picking the same shapes again attaches them
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (selection.length === 0) setDismissedSelectionKey("")
|
if (selection.length === 0) setDismissedSelectionKey("")
|
||||||
@@ -1178,8 +1185,9 @@ export function ChatEngineProvider({
|
|||||||
.filter((k) => k < beforeIndex)
|
.filter((k) => k < beforeIndex)
|
||||||
.sort((a, b) => b - a)
|
.sort((a, b) => b - a)
|
||||||
return snapshotKeys.length > 0
|
return snapshotKeys.length > 0
|
||||||
? getFirstPageXml(
|
? getPageXml(
|
||||||
xmlSnapshotsRef.current.get(snapshotKeys[0]) || "",
|
xmlSnapshotsRef.current.get(snapshotKeys[0]) || "",
|
||||||
|
viewedPageId(),
|
||||||
)
|
)
|
||||||
: ""
|
: ""
|
||||||
}
|
}
|
||||||
@@ -1198,6 +1206,7 @@ export function ChatEngineProvider({
|
|||||||
continuationOriginalRef.current = null
|
continuationOriginalRef.current = null
|
||||||
stoppedRef.current = false
|
stoppedRef.current = false
|
||||||
currentTurnRef.current = turnIndex
|
currentTurnRef.current = turnIndex
|
||||||
|
turnPageIdRef.current = viewedPageId()
|
||||||
turnChatRef.current = getChatGeneration()
|
turnChatRef.current = getChatGeneration()
|
||||||
// Busy from now on, before the next render says so
|
// Busy from now on, before the next render says so
|
||||||
busyRef.current = true
|
busyRef.current = true
|
||||||
@@ -1254,7 +1263,8 @@ export function ChatEngineProvider({
|
|||||||
const turnIndex = messagesRef.current.length
|
const turnIndex = messagesRef.current.length
|
||||||
const previousXml = getPreviousXml(turnIndex)
|
const previousXml = getPreviousXml(turnIndex)
|
||||||
// Snapshot the full multi-page document (kept fresh by autosave) so
|
// 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)
|
xmlSnapshotsRef.current.set(turnIndex, chartXMLRef.current || chartXml)
|
||||||
sendChatMessage(parts, chartXml, previousXml, turnIndex, selectedCells)
|
sendChatMessage(parts, chartXml, previousXml, turnIndex, selectedCells)
|
||||||
return true
|
return true
|
||||||
@@ -1328,11 +1338,16 @@ export function ChatEngineProvider({
|
|||||||
pageName: "Page-1",
|
pageName: "Page-1",
|
||||||
})
|
})
|
||||||
if (prepared.ok) {
|
if (prepared.ok) {
|
||||||
|
const pageId = viewedPageId()
|
||||||
|
const canvasXml = chartXMLRef.current || ""
|
||||||
onDisplayChart(
|
onDisplayChart(
|
||||||
keepFileVars(prepared.xml, chartXMLRef.current || ""),
|
keepFileVars(
|
||||||
|
placeOnPage(prepared.xml, canvasXml, pageId),
|
||||||
|
canvasXml,
|
||||||
|
),
|
||||||
true,
|
true,
|
||||||
"commit",
|
"commit",
|
||||||
{ toolCallId },
|
{ toolCallId, pageId },
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
clearComposer()
|
clearComposer()
|
||||||
@@ -1492,7 +1507,7 @@ export function ChatEngineProvider({
|
|||||||
})
|
})
|
||||||
sendChatMessage(
|
sendChatMessage(
|
||||||
parts,
|
parts,
|
||||||
getFirstPageXml(savedXml),
|
getPageXml(savedXml, viewedPageId()),
|
||||||
previousXml,
|
previousXml,
|
||||||
index,
|
index,
|
||||||
selectionOf(messages[index]),
|
selectionOf(messages[index]),
|
||||||
|
|||||||
@@ -56,11 +56,11 @@ export function CompareDialog() {
|
|||||||
() => !version || isSameDocument(version.xml, chartXML),
|
() => !version || isSameDocument(version.xml, chartXML),
|
||||||
[version, chartXML],
|
[version, chartXML],
|
||||||
)
|
)
|
||||||
// What the canvas has changed since this version on the first page
|
// What the canvas has changed since this version on the page it was
|
||||||
// (null: nothing there)
|
// made on (null: nothing there)
|
||||||
const changes = useMemo(() => {
|
const changes = useMemo(() => {
|
||||||
if (!version || same) return null
|
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
|
return Object.values(summary).some((n) => n > 0) ? summary : null
|
||||||
}, [version, chartXML, same])
|
}, [version, chartXML, same])
|
||||||
|
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import type { RefObject } from "react"
|
|||||||
import { useCallback, useEffect, useRef } from "react"
|
import { useCallback, useEffect, useRef } from "react"
|
||||||
import type { DiagramOperation, ToolPartLike } from "@/components/chat/types"
|
import type { DiagramOperation, ToolPartLike } from "@/components/chat/types"
|
||||||
import { useDiagram } from "@/contexts/diagram-context"
|
import { useDiagram } from "@/contexts/diagram-context"
|
||||||
|
import { pageSelectorFor } from "@/lib/diagram-pages"
|
||||||
import {
|
import {
|
||||||
convertToLegalXml,
|
convertToLegalXml,
|
||||||
extractCompleteMxCells,
|
extractCompleteMxCells,
|
||||||
@@ -15,6 +16,7 @@ import {
|
|||||||
expandCompactCells,
|
expandCompactCells,
|
||||||
} from "@/packages/mcp-server/src/compact-cells.ts"
|
} from "@/packages/mcp-server/src/compact-cells.ts"
|
||||||
import { applyDiagramOperations } from "@/packages/mcp-server/src/diagram-operations.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 { BLANK_MXFILE } from "@/packages/mcp-server/src/pages.ts"
|
||||||
import {
|
import {
|
||||||
addDefaultStyles,
|
addDefaultStyles,
|
||||||
@@ -44,6 +46,8 @@ interface UseDiagramStreamingParams {
|
|||||||
processedToolCallsRef: RefObject<Set<string>>
|
processedToolCallsRef: RefObject<Set<string>>
|
||||||
editDiagramOriginalXmlRef: RefObject<Map<string, string>>
|
editDiagramOriginalXmlRef: RefObject<Map<string, string>>
|
||||||
loadedMessageIdsRef: RefObject<Set<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,
|
processedToolCallsRef,
|
||||||
editDiagramOriginalXmlRef,
|
editDiagramOriginalXmlRef,
|
||||||
loadedMessageIdsRef,
|
loadedMessageIdsRef,
|
||||||
|
turnPageIdRef,
|
||||||
}: UseDiagramStreamingParams) {
|
}: UseDiagramStreamingParams) {
|
||||||
const { chartXML, chartXMLRef, loadDiagram } = useDiagram()
|
const { chartXML, chartXMLRef, loadDiagram } = useDiagram()
|
||||||
const previousXML = useRef<string>("")
|
const previousXML = useRef<string>("")
|
||||||
@@ -102,10 +107,15 @@ export function useDiagramStreaming({
|
|||||||
if (testDoc.querySelector("parsererror")) return
|
if (testDoc.querySelector("parsererror")) return
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Replace the first page's cells so other pages stay intact.
|
// Replace the model's page's cells so other pages stay
|
||||||
// An empty canvas gets a default mxfile to put the cells in.
|
// intact. An empty canvas gets a default mxfile to put the
|
||||||
|
// cells in.
|
||||||
const baseXML = chartXML || BLANK_MXFILE
|
const baseXML = chartXML || BLANK_MXFILE
|
||||||
const replacedXML = replaceNodes(baseXML, convertedXml)
|
const replacedXML = replaceNodes(
|
||||||
|
baseXML,
|
||||||
|
convertedXml,
|
||||||
|
turnPageIdRef?.current,
|
||||||
|
)
|
||||||
previousXML.current = convertedXml
|
previousXML.current = convertedXml
|
||||||
loadDiagram(replacedXML, true, "preview")
|
loadDiagram(replacedXML, true, "preview")
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -220,8 +230,15 @@ export function useDiagramStreaming({
|
|||||||
if (lastProcessedXmlRef.current.get(opsKey) === ops) continue
|
if (lastProcessedXmlRef.current.get(opsKey) === ops) continue
|
||||||
lastProcessedXmlRef.current.set(opsKey, ops)
|
lastProcessedXmlRef.current.set(opsKey, ops)
|
||||||
try {
|
try {
|
||||||
const edges = edgeIdsOf(originalXml)
|
// The model's page: the edges on it and its first layer,
|
||||||
const layer = defaultLayerOf(originalXml)
|
// 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(
|
const { result } = applyDiagramOperations(
|
||||||
originalXml,
|
originalXml,
|
||||||
completeOps.map((op) =>
|
completeOps.map((op) =>
|
||||||
@@ -235,6 +252,7 @@ export function useDiagramStreaming({
|
|||||||
}
|
}
|
||||||
: op,
|
: op,
|
||||||
),
|
),
|
||||||
|
selector,
|
||||||
)
|
)
|
||||||
if (lastProcessedXmlRef.current.get(resultKey) === result) {
|
if (lastProcessedXmlRef.current.get(resultKey) === result) {
|
||||||
continue
|
continue
|
||||||
|
|||||||
@@ -46,15 +46,17 @@ export function useVersions({
|
|||||||
// (effect cleanups run before the streaming hook commits). The handler
|
// (effect cleanups run before the streaming hook commits). The handler
|
||||||
// only reads refs and stable functions.
|
// only reads refs and stable functions.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setCommitHandler(({ beforeXml, afterXml, toolCallId }) => {
|
setCommitHandler(({ beforeXml, afterXml, toolCallId, pageId }) => {
|
||||||
if (restoringRef.current) return
|
if (restoringRef.current) return
|
||||||
const { summary, touchedIds, fromScratch } = diffDiagrams(
|
const { summary, touchedIds, fromScratch } = diffDiagrams(
|
||||||
beforeXml,
|
beforeXml,
|
||||||
afterXml,
|
afterXml,
|
||||||
|
pageId,
|
||||||
)
|
)
|
||||||
const id = useVersionsStore.getState().addVersion({
|
const id = useVersionsStore.getState().addVersion({
|
||||||
xml: afterXml,
|
xml: afterXml,
|
||||||
beforeXml,
|
beforeXml,
|
||||||
|
...(pageId && { pageId }),
|
||||||
turnIndex: currentTurnRef.current,
|
turnIndex: currentTurnRef.current,
|
||||||
toolCallId,
|
toolCallId,
|
||||||
summary,
|
summary,
|
||||||
|
|||||||
@@ -23,12 +23,14 @@ import {
|
|||||||
previewDiagram,
|
previewDiagram,
|
||||||
resetPreview,
|
resetPreview,
|
||||||
revertPreview,
|
revertPreview,
|
||||||
|
selectPage,
|
||||||
} from "@/lib/drawio/editor-bridge"
|
} from "@/lib/drawio/editor-bridge"
|
||||||
import {
|
import {
|
||||||
BLANK_MXFILE,
|
BLANK_MXFILE,
|
||||||
normalizeToMxfile,
|
normalizeToMxfile,
|
||||||
} from "@/packages/mcp-server/src/pages.ts"
|
} from "@/packages/mcp-server/src/pages.ts"
|
||||||
import { validateAndFixXml } from "@/packages/mcp-server/src/xml-validation.ts"
|
import { validateAndFixXml } from "@/packages/mcp-server/src/xml-validation.ts"
|
||||||
|
import { useCanvasStore } from "@/stores/canvas-store"
|
||||||
import { extractDiagramXML, isRealDiagram } from "../lib/utils"
|
import { extractDiagramXML, isRealDiagram } from "../lib/utils"
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -54,6 +56,8 @@ export interface DiagramCommit {
|
|||||||
beforeXml: string
|
beforeXml: string
|
||||||
afterXml: string
|
afterXml: string
|
||||||
toolCallId?: string
|
toolCallId?: string
|
||||||
|
/** The page the change was made on; null or absent: the first page */
|
||||||
|
pageId?: string | null
|
||||||
}
|
}
|
||||||
|
|
||||||
interface DiagramContextType {
|
interface DiagramContextType {
|
||||||
@@ -65,12 +69,12 @@ interface DiagramContextType {
|
|||||||
chart: string,
|
chart: string,
|
||||||
skipValidation?: boolean,
|
skipValidation?: boolean,
|
||||||
mode?: LoadMode,
|
mode?: LoadMode,
|
||||||
meta?: { toolCallId?: string },
|
meta?: { toolCallId?: string; pageId?: string | null },
|
||||||
) => string | null
|
) => string | null
|
||||||
// Returns the export's tag (empty when draw.io is not there yet)
|
// Returns the export's tag (empty when draw.io is not there yet)
|
||||||
handleExport: () => string
|
handleExport: () => string
|
||||||
// Pending exports by tag; a plain export's resolver gets the first
|
// Pending exports by tag; a plain export's resolver gets the XML of the
|
||||||
// page's XML
|
// page on screen
|
||||||
exportResolversRef: React.MutableRefObject<
|
exportResolversRef: React.MutableRefObject<
|
||||||
Record<string, (data: string, xml?: string) => void>
|
Record<string, (data: string, xml?: string) => void>
|
||||||
>
|
>
|
||||||
@@ -134,17 +138,28 @@ export function DiagramProvider({ children }: { children: React.ReactNode }) {
|
|||||||
null,
|
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
|
// 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,
|
// reports "load": until then the editor shows the diagram from before,
|
||||||
// so later changes go the same way and keep their order
|
// so later changes go the same way and keep their order
|
||||||
const fullLoad = (xml: string) => {
|
const fullLoad = (xml: string) => {
|
||||||
if (!drawioRef.current) return
|
if (!drawioRef.current) return
|
||||||
|
const pageId = useCanvasStore.getState().currentPageId
|
||||||
|
pageAfterLoadRef.current =
|
||||||
|
pageId && xml.includes(`id="${pageId}"`) ? pageId : null
|
||||||
pendingLoadsRef.current++
|
pendingLoadsRef.current++
|
||||||
drawioRef.current.load({ xml })
|
drawioRef.current.load({ xml })
|
||||||
}
|
}
|
||||||
|
|
||||||
const onDrawioLoad = () => {
|
const onDrawioLoad = () => {
|
||||||
pendingLoadsRef.current = Math.max(0, pendingLoadsRef.current - 1)
|
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
|
// Only set ready state once to prevent infinite loops
|
||||||
if (hasCalledOnLoadRef.current) return
|
if (hasCalledOnLoadRef.current) return
|
||||||
hasCalledOnLoadRef.current = true
|
hasCalledOnLoadRef.current = true
|
||||||
@@ -241,7 +256,7 @@ export function DiagramProvider({ children }: { children: React.ReactNode }) {
|
|||||||
chart: string,
|
chart: string,
|
||||||
skipValidation?: boolean,
|
skipValidation?: boolean,
|
||||||
mode: LoadMode = "load",
|
mode: LoadMode = "load",
|
||||||
meta?: { toolCallId?: string },
|
meta?: { toolCallId?: string; pageId?: string | null },
|
||||||
): string | null => {
|
): string | null => {
|
||||||
// The editor bridge is shared: a page that is gone (another language
|
// The editor bridge is shared: a page that is gone (another language
|
||||||
// mounted a new one) must not change the new page's canvas
|
// mounted a new one) must not change the new page's canvas
|
||||||
@@ -308,6 +323,7 @@ export function DiagramProvider({ children }: { children: React.ReactNode }) {
|
|||||||
beforeXml,
|
beforeXml,
|
||||||
afterXml: xmlToLoad,
|
afterXml: xmlToLoad,
|
||||||
toolCallId: meta?.toolCallId,
|
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
|
// data.xml from xmlsvg exports has compressed <diagram> payloads that
|
||||||
// would break edit_diagram/display_diagram. Autosave keeps chartXML
|
// would break edit_diagram/display_diagram. Autosave keeps chartXML
|
||||||
// up to date with the full uncompressed multi-page document (#879).
|
// 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)
|
setLatestSvg(data.data)
|
||||||
|
|
||||||
// The chat's own export (onFetchChart), not another one in flight
|
// The chat's own export (onFetchChart), not another one in flight
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import type {
|
|||||||
ValidationStatus,
|
ValidationStatus,
|
||||||
} from "@/components/chat/ValidationCard"
|
} from "@/components/chat/ValidationCard"
|
||||||
import type { LoadMode } from "@/contexts/diagram-context"
|
import type { LoadMode } from "@/contexts/diagram-context"
|
||||||
|
import { pageModelXml, pageSelectorFor, placeOnPage } from "@/lib/diagram-pages"
|
||||||
import type { ValidationResult } from "@/lib/diagram-validator"
|
import type { ValidationResult } from "@/lib/diagram-validator"
|
||||||
import { formatValidationFeedback } from "@/lib/diagram-validator"
|
import { formatValidationFeedback } from "@/lib/diagram-validator"
|
||||||
import { isMxCellXmlComplete } from "@/lib/utils"
|
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"
|
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" }
|
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)
|
// Failed VLM validations in the current user turn (reset on each user message)
|
||||||
validationRetryCountRef: RefObject<number>
|
validationRetryCountRef: RefObject<number>
|
||||||
chartXMLRef: RefObject<string>
|
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: (
|
onDisplayChart: (
|
||||||
xml: string,
|
xml: string,
|
||||||
skipValidation?: boolean,
|
skipValidation?: boolean,
|
||||||
mode?: LoadMode,
|
mode?: LoadMode,
|
||||||
meta?: { toolCallId?: string },
|
meta?: { toolCallId?: string; pageId?: string | null },
|
||||||
) => string | null
|
) => string | null
|
||||||
onFetchChart: () => Promise<string>
|
onFetchChart: () => Promise<string>
|
||||||
captureValidationPng?: () => Promise<string | null>
|
captureValidationPng?: () => Promise<string | null>
|
||||||
@@ -119,6 +124,7 @@ export function useDiagramToolHandlers({
|
|||||||
processedToolCallsRef,
|
processedToolCallsRef,
|
||||||
validationRetryCountRef,
|
validationRetryCountRef,
|
||||||
chartXMLRef,
|
chartXMLRef,
|
||||||
|
turnPageIdRef,
|
||||||
onDisplayChart,
|
onDisplayChart,
|
||||||
onFetchChart,
|
onFetchChart,
|
||||||
captureValidationPng,
|
captureValidationPng,
|
||||||
@@ -182,11 +188,29 @@ export function useDiagramToolHandlers({
|
|||||||
const commit = (xml: string, toolCallId: string) => {
|
const commit = (xml: string, toolCallId: string) => {
|
||||||
const original = continuationOriginalRef.current
|
const original = continuationOriginalRef.current
|
||||||
if (original !== null) onDisplayChart(original, true, "revert")
|
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
|
if (!error) continuationOriginalRef.current = null
|
||||||
return error
|
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 takeOriginals = (): string | undefined => {
|
||||||
const [originalXml] = editDiagramOriginalXmlRef.current.values()
|
const [originalXml] = editDiagramOriginalXmlRef.current.values()
|
||||||
for (const id of editDiagramOriginalXmlRef.current.keys()) {
|
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
|
// finds the diagram already in place
|
||||||
const prepared = prepareNewDiagram(finalXml, NEW_PAGE)
|
const prepared = prepareNewDiagram(finalXml, NEW_PAGE)
|
||||||
const validationError = prepared.ok
|
const validationError = prepared.ok
|
||||||
? commit(
|
? commit(onAiPage(prepared.xml), toolCall.toolCallId)
|
||||||
keepFileVars(prepared.xml, chartXMLRef.current),
|
|
||||||
toolCall.toolCallId,
|
|
||||||
)
|
|
||||||
: prepared.error
|
: prepared.error
|
||||||
|
|
||||||
if (validationError) {
|
if (validationError) {
|
||||||
@@ -503,8 +524,12 @@ ${finalXml}
|
|||||||
}
|
}
|
||||||
|
|
||||||
// All or nothing, checked like the MCP server's edit_diagram.
|
// All or nothing, checked like the MCP server's edit_diagram.
|
||||||
// The model sees the first page, so edits target it.
|
// The model sees its page, so edits target it.
|
||||||
const outcome = editDiagram(currentXml, operations, {})
|
const outcome = editDiagram(
|
||||||
|
currentXml,
|
||||||
|
operations,
|
||||||
|
pageSelectorFor(currentXml, aiPageId()),
|
||||||
|
)
|
||||||
if (!outcome.ok) {
|
if (!outcome.ok) {
|
||||||
const reason = outcome.pageError
|
const reason = outcome.pageError
|
||||||
? outcome.errors[0]
|
? outcome.errors[0]
|
||||||
@@ -518,7 +543,7 @@ ${finalXml}
|
|||||||
|
|
||||||
Current diagram XML:
|
Current diagram XML:
|
||||||
\`\`\`xml
|
\`\`\`xml
|
||||||
${foldCells(currentXml)}
|
${shownToModel(currentXml)}
|
||||||
\`\`\`
|
\`\`\`
|
||||||
|
|
||||||
Please check the cell IDs and retry.`,
|
Please check the cell IDs and retry.`,
|
||||||
@@ -547,7 +572,7 @@ Please check the cell IDs and retry.`,
|
|||||||
|
|
||||||
Current diagram XML:
|
Current diagram XML:
|
||||||
\`\`\`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.`,
|
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
|
// originals, so the preview code undoes none of them later
|
||||||
const originalXml = takeOriginals()
|
const originalXml = takeOriginals()
|
||||||
const validationError = prepared.ok
|
const validationError = prepared.ok
|
||||||
? commit(
|
? commit(onAiPage(prepared.xml), toolCall.toolCallId)
|
||||||
keepFileVars(prepared.xml, chartXMLRef.current),
|
|
||||||
toolCall.toolCallId,
|
|
||||||
)
|
|
||||||
: prepared.error
|
: prepared.error
|
||||||
|
|
||||||
if (validationError) {
|
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 {
|
export interface ChangeSummary {
|
||||||
shapesAdded: number
|
shapesAdded: number
|
||||||
shapesRemoved: number
|
shapesRemoved: number
|
||||||
@@ -32,27 +32,20 @@ interface CellInfo {
|
|||||||
signature: string
|
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 {
|
function parse(xml: string): Document | null {
|
||||||
if (!xml?.trim()) return null
|
if (!xml?.trim()) return null
|
||||||
const doc = new DOMParser().parseFromString(xml, "text/xml")
|
const doc = new DOMParser().parseFromString(xml, "text/xml")
|
||||||
return doc.querySelector("parsererror") ? null : doc
|
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)
|
const doc = parse(xml)
|
||||||
if (!doc) return null
|
if (!doc) return null
|
||||||
const diagram = doc.querySelector("diagram")
|
const diagram = pageElement(doc, pageId)
|
||||||
if (diagram) return modelOfPage(diagram)
|
if (diagram) return modelOfPage(diagram)
|
||||||
return doc.querySelector("mxGraphModel") ?? doc.documentElement
|
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) */
|
/** Shapes and connectors of one page (not the root and its layers) */
|
||||||
function collectCells(xml: string): Map<string, CellInfo> {
|
function collectCells(
|
||||||
|
xml: string,
|
||||||
|
pageId?: string | null,
|
||||||
|
): Map<string, CellInfo> {
|
||||||
const cells = new 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
|
if (id === "0" || cell.getAttribute("parent") === "0") continue
|
||||||
cells.set(id, {
|
cells.set(id, {
|
||||||
isEdge: cell.getAttribute("edge") === "1",
|
isEdge: cell.getAttribute("edge") === "1",
|
||||||
@@ -146,9 +142,14 @@ function collectCells(xml: string): Map<string, CellInfo> {
|
|||||||
return cells
|
return cells
|
||||||
}
|
}
|
||||||
|
|
||||||
export function diffDiagrams(beforeXml: string, afterXml: string): DiagramDiff {
|
/** What changed on one page (the first when no id is given) */
|
||||||
const before = collectCells(beforeXml)
|
export function diffDiagrams(
|
||||||
const after = collectCells(afterXml)
|
beforeXml: string,
|
||||||
|
afterXml: string,
|
||||||
|
pageId?: string | null,
|
||||||
|
): DiagramDiff {
|
||||||
|
const before = collectCells(beforeXml, pageId)
|
||||||
|
const after = collectCells(afterXml, pageId)
|
||||||
const summary: ChangeSummary = { ...EMPTY_SUMMARY }
|
const summary: ChangeSummary = { ...EMPTY_SUMMARY }
|
||||||
const touchedIds: string[] = []
|
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
|
* With an external draw.io (cross-origin) none of this is available and the
|
||||||
* app uses the postMessage protocol only.
|
* 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 { hasCells } from "@/packages/mcp-server/src/pages.ts"
|
||||||
import { type SelectedCell, useCanvasStore } from "@/stores/canvas-store"
|
import { type SelectedCell, useCanvasStore } from "@/stores/canvas-store"
|
||||||
|
|
||||||
@@ -152,23 +152,18 @@ function withoutUndo(fn: () => void) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/** Id of the page on the canvas, when draw.io has pages */
|
||||||
* replaceDiagramData replaces the current page with one <mxGraphModel>. Only
|
function currentPageId(): string | null {
|
||||||
* single-page documents qualify; multi-page ones fall back to a full load.
|
const id = ui?.currentPage?.getId?.()
|
||||||
*/
|
return id === undefined || id === null ? null : String(id)
|
||||||
function toSinglePageModel(xml: string): string | null {
|
}
|
||||||
const doc = new DOMParser().parseFromString(xml, "text/xml")
|
|
||||||
if (doc.querySelector("parsererror")) return null
|
/** A page's <mxGraphModel> as XML, inflated when the page is compressed */
|
||||||
const root = doc.documentElement
|
function modelXmlOf(diagram: Element): string | null {
|
||||||
if (root.nodeName === "mxGraphModel") return xml
|
const model = diagram.getElementsByTagName("mxGraphModel")[0]
|
||||||
if (root.nodeName !== "mxfile") return null
|
|
||||||
const diagrams = root.getElementsByTagName("diagram")
|
|
||||||
if (diagrams.length !== 1) return null
|
|
||||||
const model = diagrams[0].getElementsByTagName("mxGraphModel")[0]
|
|
||||||
if (model) return new XMLSerializer().serializeToString(model)
|
if (model) return new XMLSerializer().serializeToString(model)
|
||||||
// Compressed page
|
|
||||||
try {
|
try {
|
||||||
const text = diagrams[0].textContent?.trim()
|
const text = diagram.textContent?.trim()
|
||||||
const inflated = text ? win?.Graph?.decompress?.(text) : null
|
const inflated = text ? win?.Graph?.decompress?.(text) : null
|
||||||
return typeof inflated === "string" &&
|
return typeof inflated === "string" &&
|
||||||
inflated.includes("<mxGraphModel")
|
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) {
|
function replace(xml: string) {
|
||||||
const model = toSinglePageModel(xml)
|
const model = pageModelOf(xml)
|
||||||
if (!model || typeof ui?.replaceDiagramData !== "function") {
|
if (!model || typeof ui?.replaceDiagramData !== "function") {
|
||||||
throw new Error("Diagram can't be replaced in place")
|
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 */
|
/** Whether a page has shapes; layers (cells under the root) are none */
|
||||||
function hasShapes(xml: string): boolean {
|
function hasShapes(xml: string): boolean {
|
||||||
const model = toSinglePageModel(xml)
|
const model = pageModelOf(xml)
|
||||||
if (model === null) return hasCells(xml)
|
if (model === null) return hasCells(xml)
|
||||||
const cells = new DOMParser()
|
const cells = new DOMParser()
|
||||||
.parseFromString(model, "text/xml")
|
.parseFromString(model, "text/xml")
|
||||||
@@ -256,8 +305,9 @@ export function canReplaceDiagram(xml: string): boolean {
|
|||||||
warnOnce("replace", "replaceDiagramData not found, using full loads")
|
warnOnce("replace", "replaceDiagramData not found, using full loads")
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
const pageCount = Array.isArray(ui.pages) ? ui.pages.length : 1
|
// Replacing changes the current page only: a document whose other
|
||||||
if (pageCount > 1) return false
|
// 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
|
// Replacing the page keeps the file's variables: other ones, or none
|
||||||
// over a file with some, load in full
|
// over a file with some, load in full
|
||||||
if (
|
if (
|
||||||
@@ -268,7 +318,7 @@ export function canReplaceDiagram(xml: string): boolean {
|
|||||||
) {
|
) {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
const model = toSinglePageModel(xml)
|
const model = pageModelOf(xml)
|
||||||
// A document with them, or replacing one with them, loads in full
|
// A document with them, or replacing one with them, loads in full
|
||||||
return (
|
return (
|
||||||
model !== null &&
|
model !== null &&
|
||||||
@@ -285,13 +335,21 @@ export function previewDiagram(xml: string) {
|
|||||||
if (wasEmpty) fitDiagram()
|
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 } {
|
function pageOf(xml: string): { name: string | null; id: string | null } {
|
||||||
const doc = new DOMParser().parseFromString(xml, "text/xml")
|
const doc = new DOMParser().parseFromString(xml, "text/xml")
|
||||||
const diagram =
|
const diagrams =
|
||||||
doc.documentElement?.nodeName === "mxfile"
|
doc.documentElement?.nodeName === "mxfile"
|
||||||
? doc.getElementsByTagName("diagram")[0]
|
? Array.from(doc.getElementsByTagName("diagram"))
|
||||||
: undefined
|
: []
|
||||||
|
const id = currentPageId()
|
||||||
|
const diagram =
|
||||||
|
diagrams.length === 1
|
||||||
|
? diagrams[0]
|
||||||
|
: diagrams.find((d) => d.getAttribute("id") === id)
|
||||||
return {
|
return {
|
||||||
name: diagram?.getAttribute("name") || null,
|
name: diagram?.getAttribute("name") || null,
|
||||||
id: diagram?.getAttribute("id") || null,
|
id: diagram?.getAttribute("id") || null,
|
||||||
@@ -322,7 +380,7 @@ function changePageId(page: any, id: string) {
|
|||||||
export function commitDiagram(xml: string) {
|
export function commitDiagram(xml: string) {
|
||||||
const wasEmpty =
|
const wasEmpty =
|
||||||
isEmptyModel() || (previewBase !== null && !hasShapes(previewBase))
|
isEmptyModel() || (previewBase !== null && !hasShapes(previewBase))
|
||||||
const base = previewBase ? toSinglePageModel(previewBase) : null
|
const base = previewBase ? pageModelOf(previewBase) : null
|
||||||
previewBase = null
|
previewBase = null
|
||||||
// Undo goes back to the diagram before streaming started. draw.io's
|
// Undo goes back to the diagram before streaming started. draw.io's
|
||||||
// ReplaceDiagram change keeps the document it replaced for undo: hand it
|
// ReplaceDiagram change keeps the document it replaced for undo: hand it
|
||||||
@@ -337,7 +395,7 @@ export function commitDiagram(xml: string) {
|
|||||||
const page = ui?.currentPage
|
const page = ui?.currentPage
|
||||||
model?.beginUpdate()
|
model?.beginUpdate()
|
||||||
try {
|
try {
|
||||||
const next = toSinglePageModel(xml)
|
const next = pageModelOf(xml)
|
||||||
if (direct && next) {
|
if (direct && next) {
|
||||||
const change = new ReplaceDiagram(ui, parse(next))
|
const change = new ReplaceDiagram(ui, parse(next))
|
||||||
model.execute(change)
|
model.execute(change)
|
||||||
@@ -368,6 +426,18 @@ export function resetPreview() {
|
|||||||
previewBase = null
|
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
|
// 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
|
// after (the chat panel sliding in) fits again, unless the user zoomed since
|
||||||
let lastAutoFitAt = 0
|
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 currentXML - The original Draw.io XML string
|
||||||
* @param nodes - The XML string containing new nodes to replace in the diagram
|
* @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
|
* @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
|
// Check for valid inputs
|
||||||
if (!currentXML || !nodes) {
|
if (!currentXML || !nodes) {
|
||||||
throw new Error("Both currentXML and nodes must be provided")
|
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")
|
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
|
// Find the root element in the current document
|
||||||
let currentRoot = currentDoc.querySelector("mxGraphModel > root")
|
let currentRoot = scope.querySelector("mxGraphModel > root")
|
||||||
if (!currentRoot) {
|
if (!currentRoot) {
|
||||||
// If no root element is found, create the proper structure
|
// If no root element is found, create the proper structure
|
||||||
const mxGraphModel =
|
const mxGraphModel =
|
||||||
currentDoc.querySelector("mxGraphModel") ||
|
scope.querySelector("mxGraphModel") ||
|
||||||
currentDoc.createElement("mxGraphModel")
|
currentDoc.createElement("mxGraphModel")
|
||||||
|
|
||||||
if (!currentDoc.contains(mxGraphModel)) {
|
if (!currentDoc.contains(mxGraphModel)) {
|
||||||
currentDoc.appendChild(mxGraphModel)
|
;(page ?? currentDoc).appendChild(mxGraphModel)
|
||||||
}
|
}
|
||||||
|
|
||||||
currentRoot = currentDoc.createElement("root")
|
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.
|
* 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
|
* One page is returned: the one with the given id, else the first; for the
|
||||||
* autosaved chartXML instead.
|
* 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 {
|
try {
|
||||||
// 1. Parse the SVG string (using built-in DOMParser in a browser-like environment)
|
// 1. Parse the SVG string (using built-in DOMParser in a browser-like environment)
|
||||||
const svgString = atob(xml_svg_string.slice(26))
|
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
|
// 4. Parse the XML content
|
||||||
const xmlDoc = parser.parseFromString(xmlContent, "text/xml")
|
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) {
|
if (!diagramElement) {
|
||||||
throw new Error("No diagram element found")
|
throw new Error("No diagram element found")
|
||||||
|
|||||||
Generated
+36
-36
@@ -75,7 +75,7 @@
|
|||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@anthropic-ai/tokenizer": "^0.0.4",
|
"@anthropic-ai/tokenizer": "^0.0.4",
|
||||||
"@biomejs/biome": "2.5.7",
|
"@biomejs/biome": "2.5.15",
|
||||||
"@playwright/test": "^1.57.0",
|
"@playwright/test": "^1.57.0",
|
||||||
"@tailwindcss/postcss": "^4",
|
"@tailwindcss/postcss": "^4",
|
||||||
"@tailwindcss/typography": "^0.5.19",
|
"@tailwindcss/typography": "^0.5.19",
|
||||||
@@ -2093,9 +2093,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@biomejs/biome": {
|
"node_modules/@biomejs/biome": {
|
||||||
"version": "2.5.7",
|
"version": "2.5.15",
|
||||||
"resolved": "https://registry.npmjs.org/@biomejs/biome/-/biome-2.5.7.tgz",
|
"resolved": "https://registry.npmjs.org/@biomejs/biome/-/biome-2.5.15.tgz",
|
||||||
"integrity": "sha512-zr8K/DcY5tYsQOQwqMJ0AWElo6QgmgNI7idXgXLhevVszlt8RGVpesEJPqx3ThazLaOwjJ5Y8fz3BtH5fGZNsw==",
|
"integrity": "sha512-WZTW4slm/pdkh92K6t/3aEN++44JD1PQ7squ7RCMLI1flHGl43DVOIGXvCjHiZgBMO1V4uYxoFNtqrIA4qGuIQ==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT OR Apache-2.0",
|
"license": "MIT OR Apache-2.0",
|
||||||
"bin": {
|
"bin": {
|
||||||
@@ -2109,20 +2109,20 @@
|
|||||||
"url": "https://opencollective.com/biome"
|
"url": "https://opencollective.com/biome"
|
||||||
},
|
},
|
||||||
"optionalDependencies": {
|
"optionalDependencies": {
|
||||||
"@biomejs/cli-darwin-arm64": "2.5.7",
|
"@biomejs/cli-darwin-arm64": "2.5.15",
|
||||||
"@biomejs/cli-darwin-x64": "2.5.7",
|
"@biomejs/cli-darwin-x64": "2.5.15",
|
||||||
"@biomejs/cli-linux-arm64": "2.5.7",
|
"@biomejs/cli-linux-arm64": "2.5.15",
|
||||||
"@biomejs/cli-linux-arm64-musl": "2.5.7",
|
"@biomejs/cli-linux-arm64-musl": "2.5.15",
|
||||||
"@biomejs/cli-linux-x64": "2.5.7",
|
"@biomejs/cli-linux-x64": "2.5.15",
|
||||||
"@biomejs/cli-linux-x64-musl": "2.5.7",
|
"@biomejs/cli-linux-x64-musl": "2.5.15",
|
||||||
"@biomejs/cli-win32-arm64": "2.5.7",
|
"@biomejs/cli-win32-arm64": "2.5.15",
|
||||||
"@biomejs/cli-win32-x64": "2.5.7"
|
"@biomejs/cli-win32-x64": "2.5.15"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@biomejs/cli-darwin-arm64": {
|
"node_modules/@biomejs/cli-darwin-arm64": {
|
||||||
"version": "2.5.7",
|
"version": "2.5.15",
|
||||||
"resolved": "https://registry.npmjs.org/@biomejs/cli-darwin-arm64/-/cli-darwin-arm64-2.5.7.tgz",
|
"resolved": "https://registry.npmjs.org/@biomejs/cli-darwin-arm64/-/cli-darwin-arm64-2.5.15.tgz",
|
||||||
"integrity": "sha512-vxo/Ls3/PYdQWyLhYYcgMOCzQypAjcY+iihS8M0wW03l16TCLW4zqZzGo75gm1VdCMj38hTVZ31KBWrZ4G9dJw==",
|
"integrity": "sha512-BZVzFhJ/mUvTLMYbc9x6el0o2Uv5zP7bACYyhFb6fSc1giroXm7PL0a5KqMJ9F+/BnkYRjagD/ROSDVOZ6eapg==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
@@ -2137,9 +2137,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@biomejs/cli-darwin-x64": {
|
"node_modules/@biomejs/cli-darwin-x64": {
|
||||||
"version": "2.5.7",
|
"version": "2.5.15",
|
||||||
"resolved": "https://registry.npmjs.org/@biomejs/cli-darwin-x64/-/cli-darwin-x64-2.5.7.tgz",
|
"resolved": "https://registry.npmjs.org/@biomejs/cli-darwin-x64/-/cli-darwin-x64-2.5.15.tgz",
|
||||||
"integrity": "sha512-Cd3Ga61amT/Yl/0x8elP5hhGYaFy4bw6WuysTgf7oo8TA5tJ5A1k+DkVoJ2BHbTVil51gTX9VPzArnrlLJ3Kyg==",
|
"integrity": "sha512-V5Kw63V+fVGNFhFrizDxMbGXAzZCh8kYzJpy3GI6gTapWDg7bZK9oYC9CbVgQSEdTnupz0U5Efz8Ev+5vdBBXw==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
@@ -2154,9 +2154,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@biomejs/cli-linux-arm64": {
|
"node_modules/@biomejs/cli-linux-arm64": {
|
||||||
"version": "2.5.7",
|
"version": "2.5.15",
|
||||||
"resolved": "https://registry.npmjs.org/@biomejs/cli-linux-arm64/-/cli-linux-arm64-2.5.7.tgz",
|
"resolved": "https://registry.npmjs.org/@biomejs/cli-linux-arm64/-/cli-linux-arm64-2.5.15.tgz",
|
||||||
"integrity": "sha512-rR2QE0yF2GYSuYuKIa7pKvODGJqnOH+2eDREAM8wV+mWKSkMQKdAp4zXEZfTaxY8PMoNONnpgSWcBCyLDPDOKg==",
|
"integrity": "sha512-XaG7P7eeSLYETD3K9grfB0mQmpLQZjygdbsxekWBJAg4am79fGAtRpfRyNnTvXB2fZFNnJZG3nXm7HBat0+VUw==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
@@ -2174,9 +2174,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@biomejs/cli-linux-arm64-musl": {
|
"node_modules/@biomejs/cli-linux-arm64-musl": {
|
||||||
"version": "2.5.7",
|
"version": "2.5.15",
|
||||||
"resolved": "https://registry.npmjs.org/@biomejs/cli-linux-arm64-musl/-/cli-linux-arm64-musl-2.5.7.tgz",
|
"resolved": "https://registry.npmjs.org/@biomejs/cli-linux-arm64-musl/-/cli-linux-arm64-musl-2.5.15.tgz",
|
||||||
"integrity": "sha512-xPI5yB6XlpDbNkS+bm1t42olw5c4l3UrlOmLg7KtLJvjvkNF/1V4tnUgfkylGIeb3u/T+BzMGYqgQhzjAoJzuQ==",
|
"integrity": "sha512-tGzZUTcJCV7tj5Adh/Gn6nR4MycqA3iohq2LEcrarAgRBkU0h0OKd2UzCRkOsOYOuUGaUJb4YmqRqyy2feGGSw==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
@@ -2194,9 +2194,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@biomejs/cli-linux-x64": {
|
"node_modules/@biomejs/cli-linux-x64": {
|
||||||
"version": "2.5.7",
|
"version": "2.5.15",
|
||||||
"resolved": "https://registry.npmjs.org/@biomejs/cli-linux-x64/-/cli-linux-x64-2.5.7.tgz",
|
"resolved": "https://registry.npmjs.org/@biomejs/cli-linux-x64/-/cli-linux-x64-2.5.15.tgz",
|
||||||
"integrity": "sha512-FQgqJhscrqJUFptGaRSUJWlXAExwWcDwLuK49dvKfkQ1bB5SEEyFssnsxQY83Xm6jR0EbbX3+8+D5bfvYqUG2Q==",
|
"integrity": "sha512-xE4iEW/3LqlYj9GFgGrFsFSH51dEEpUbYWBfeOv0q87WkUDxK2o/HhreSb7qMJqck70RVM6Lg96hgedcAYcOkA==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
@@ -2214,9 +2214,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@biomejs/cli-linux-x64-musl": {
|
"node_modules/@biomejs/cli-linux-x64-musl": {
|
||||||
"version": "2.5.7",
|
"version": "2.5.15",
|
||||||
"resolved": "https://registry.npmjs.org/@biomejs/cli-linux-x64-musl/-/cli-linux-x64-musl-2.5.7.tgz",
|
"resolved": "https://registry.npmjs.org/@biomejs/cli-linux-x64-musl/-/cli-linux-x64-musl-2.5.15.tgz",
|
||||||
"integrity": "sha512-rE5VZi+qtmPgQH+l7jVxYoZ18b/TiHEhulhMpjmCZH1PltSbjRcxNWywC3HZ9tYottG7ORkeTtoscBilKSBm0g==",
|
"integrity": "sha512-IlxUcyxilVGPsE008x13pWdkTbU3nQpP2i9b5UrbOYj6goBkKsRX1XB7yJLcV+O1H9LBchIIm4adOaVLZBX0ZQ==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
@@ -2234,9 +2234,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@biomejs/cli-win32-arm64": {
|
"node_modules/@biomejs/cli-win32-arm64": {
|
||||||
"version": "2.5.7",
|
"version": "2.5.15",
|
||||||
"resolved": "https://registry.npmjs.org/@biomejs/cli-win32-arm64/-/cli-win32-arm64-2.5.7.tgz",
|
"resolved": "https://registry.npmjs.org/@biomejs/cli-win32-arm64/-/cli-win32-arm64-2.5.15.tgz",
|
||||||
"integrity": "sha512-Oq4x0CCwP4jirrcTywXs5kOGZ4v5vuEP+gWrbtjApOA2CL9F3F9GlIdQIci8AKSCa/zURanMRpX/4wQ7Am6hHg==",
|
"integrity": "sha512-2kPKzhNlm8C+Ru3GcO0Me2ODkCBLrVOUNuyi84RJyWDVKAA4+Kjj3jMjL938lF6BRfvltC4vB2nPY9SvN51Ovg==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
@@ -2251,9 +2251,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@biomejs/cli-win32-x64": {
|
"node_modules/@biomejs/cli-win32-x64": {
|
||||||
"version": "2.5.7",
|
"version": "2.5.15",
|
||||||
"resolved": "https://registry.npmjs.org/@biomejs/cli-win32-x64/-/cli-win32-x64-2.5.7.tgz",
|
"resolved": "https://registry.npmjs.org/@biomejs/cli-win32-x64/-/cli-win32-x64-2.5.15.tgz",
|
||||||
"integrity": "sha512-V+0wu/nrj2S+MhP4EQ0uHNolP0IALEsz45pg0WoKkHfDeh0+ItHwP/p7bX5RPoMOl9NkpHYWdYPhIcy2mACHvQ==",
|
"integrity": "sha512-yAzh4UqEImV6Hcy0zjUqNfJAsUhoD64FBQCdTG8Md/Z2wQeVP/ZmJ3XRYAs1g8J9fjTFyl2fCtc5FB5til47+g==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
|
|||||||
+1
-1
@@ -110,7 +110,7 @@
|
|||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@anthropic-ai/tokenizer": "^0.0.4",
|
"@anthropic-ai/tokenizer": "^0.0.4",
|
||||||
"@biomejs/biome": "2.5.7",
|
"@biomejs/biome": "2.5.15",
|
||||||
"@playwright/test": "^1.57.0",
|
"@playwright/test": "^1.57.0",
|
||||||
"@tailwindcss/postcss": "^4",
|
"@tailwindcss/postcss": "^4",
|
||||||
"@tailwindcss/typography": "^0.5.19",
|
"@tailwindcss/typography": "^0.5.19",
|
||||||
|
|||||||
@@ -123,7 +123,7 @@ Use the standard MCP configuration with:
|
|||||||
| `get_drawing_guide` | Return the drawing rules again, for example after a long conversation was compacted |
|
| `get_drawing_guide` | Return the drawing rules again, for example after a long conversation was compacted |
|
||||||
| `get_shape_library` | Return the shapes and icon styles of a library such as `aws4`, `azure2`, or `kubernetes` |
|
| `get_shape_library` | Return the shapes and icon styles of a library such as `aws4`, `azure2`, or `kubernetes` |
|
||||||
| `create_new_diagram` | Create a new diagram from XML; a plain list of `mxCell` elements is enough |
|
| `create_new_diagram` | Create a new diagram from XML; a plain list of `mxCell` elements is enough |
|
||||||
| `load_diagram` | Load a `.drawio` file from disk into the session (handles compressed files) |
|
| `load_diagram` | Load a `.drawio` file into the session, from a `path` on disk or from its `xml` content (handles compressed files) |
|
||||||
| `edit_diagram` | Edit diagram by ID-based operations (update/add/delete cells); all or nothing |
|
| `edit_diagram` | Edit diagram by ID-based operations (update/add/delete cells); all or nothing |
|
||||||
| `get_diagram` | Get the current diagram XML, including your edits in the browser |
|
| `get_diagram` | Get the current diagram XML, including your edits in the browser |
|
||||||
| `screenshot_diagram` | Return a PNG of a page so the AI can check the rendered diagram |
|
| `screenshot_diagram` | Return a PNG of a page so the AI can check the rendered diagram |
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ import {
|
|||||||
export const DRAWING_GUIDE = `# Draw.io drawing guide
|
export const DRAWING_GUIDE = `# Draw.io drawing guide
|
||||||
|
|
||||||
## Workflow
|
## Workflow
|
||||||
- create_new_diagram draws a new diagram and REPLACES the whole document. add_page adds another tab. edit_diagram changes cells of an existing page. load_diagram opens a .drawio file (the server reads the file itself). get_diagram returns the current XML, including the user's manual edits. export_diagram saves to a file.
|
- create_new_diagram draws a new diagram and REPLACES the whole document. add_page adds another tab. edit_diagram changes cells of an existing page. load_diagram opens a .drawio file (the server reads the file itself, or takes the file's content as its 'xml' argument when you already have it in hand). get_diagram returns the current XML, including the user's manual edits. export_diagram saves to a file.
|
||||||
- Before drawing, describe your layout plan in 2-3 sentences, so shapes do not overlap and edges do not cross shapes.
|
- Before drawing, describe your layout plan in 2-3 sentences, so shapes do not overlap and edges do not cross shapes.
|
||||||
- Send XML only through tool calls, never in chat text. Never draw a box just to send the user a message.
|
- Send XML only through tool calls, never in chat text. Never draw a box just to send the user a message.
|
||||||
- Before using any icon library (AWS, Azure, GCP, Kubernetes, Cisco, BPMN, Material Design, web icons...), call get_shape_library and use the exact style names it returns. NEVER guess icon style names. For AWS, use the AWS 2025 icons (library aws4).
|
- Before using any icon library (AWS, Azure, GCP, Kubernetes, Cisco, BPMN, Material Design, web icons...), call get_shape_library and use the exact style names it returns. NEVER guess icon style names. For AWS, use the AWS 2025 icons (library aws4).
|
||||||
|
|||||||
@@ -53,7 +53,7 @@ export function editDiagram(
|
|||||||
// Edges already on the page, so a label added to one is not wrapped, and
|
// 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
|
// the layer a compact cell without a parent goes on
|
||||||
const page = targetPageXml(xml, selector)
|
const page = targetPageXml(xml, selector)
|
||||||
const edges = edgeIdsOf(xml)
|
const edges = edgeIdsOf(page)
|
||||||
const layer = defaultLayerOf(page)
|
const layer = defaultLayerOf(page)
|
||||||
for (const op of operations) {
|
for (const op of operations) {
|
||||||
if (op.operation === "delete" || !op.new_xml) {
|
if (op.operation === "delete" || !op.new_xml) {
|
||||||
|
|||||||
@@ -455,21 +455,54 @@ registerWriteTool(
|
|||||||
{
|
{
|
||||||
title: "Load .drawio file",
|
title: "Load .drawio file",
|
||||||
description:
|
description:
|
||||||
"Load a .drawio file from disk into the current session, REPLACING the entire diagram (all pages). " +
|
"Load a .drawio diagram into the current session, REPLACING the entire diagram (all pages). " +
|
||||||
"The server reads the file directly — you do NOT need to read the file yourself or pass its XML through create_new_diagram. " +
|
"Provide ONE of two mutually exclusive sources: 'path' (the server reads the file from disk — you do NOT need to read the file yourself or pass its XML through create_new_diagram) " +
|
||||||
"Handles both plain-XML and draw.io's compressed save format.\n\n" +
|
"or 'xml' (the raw file content you already have — from another tool, a repository read, or an API response — so no temporary file needs to be written first). " +
|
||||||
"After loading, call get_diagram before edit_diagram — you haven't seen the file's cell IDs or structure yet.",
|
"Both accept plain XML and draw.io's compressed save format.\n\n" +
|
||||||
|
"After loading from 'path' (or from compressed 'xml'), call get_diagram before edit_diagram — you haven't seen the file's cell IDs yet. " +
|
||||||
|
"Plain-XML 'xml' content you supplied yourself is already known and can be edited immediately.",
|
||||||
inputSchema: {
|
inputSchema: {
|
||||||
path: z
|
path: z
|
||||||
.string()
|
.string()
|
||||||
|
.optional()
|
||||||
.describe(
|
.describe(
|
||||||
"Absolute path to the .drawio file to load (e.g. /Users/me/diagram.drawio or ~/diagram.drawio). Relative paths resolve against the MCP server's working directory, which is often not your project.",
|
"Path to the .drawio file to load (e.g. /Users/me/diagram.drawio or ~/diagram.drawio). Relative paths resolve against the MCP server's working directory, which is often not your project. Mutually exclusive with 'xml'.",
|
||||||
|
),
|
||||||
|
xml: z
|
||||||
|
.string()
|
||||||
|
.optional()
|
||||||
|
.describe(
|
||||||
|
"Raw .drawio file content: a plain <mxfile>/<mxGraphModel>, or draw.io's compressed save format. Use when the content is already in hand (another tool's output, a repository read, an API response). Mutually exclusive with 'path'.",
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
annotations: { openWorldHint: false },
|
annotations: { openWorldHint: false },
|
||||||
},
|
},
|
||||||
async ({ path }) => {
|
async ({ path, xml: inlineXml }) => {
|
||||||
try {
|
try {
|
||||||
|
// Argument validation comes before the session check: a bad
|
||||||
|
// argument is a caller error and should be reported as such.
|
||||||
|
if (path !== undefined && inlineXml !== undefined) {
|
||||||
|
return {
|
||||||
|
content: [
|
||||||
|
{
|
||||||
|
type: "text",
|
||||||
|
text: "Error: Provide either 'path' or 'xml', not both.",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
isError: true,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (path === undefined && inlineXml === undefined) {
|
||||||
|
return {
|
||||||
|
content: [
|
||||||
|
{
|
||||||
|
type: "text",
|
||||||
|
text: "Error: Provide either 'path' (a .drawio file to read) or 'xml' (the file's content).",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
isError: true,
|
||||||
|
}
|
||||||
|
}
|
||||||
if (!currentSession) {
|
if (!currentSession) {
|
||||||
return {
|
return {
|
||||||
content: [
|
content: [
|
||||||
@@ -482,29 +515,37 @@ registerWriteTool(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const fs = await import("node:fs/promises")
|
// Exactly one of path/xml is present (validated above).
|
||||||
const nodePath = await import("node:path")
|
let content = ""
|
||||||
const absolutePath = nodePath.resolve(expandHome(path))
|
let sourceLabel = ""
|
||||||
|
if (inlineXml !== undefined) {
|
||||||
|
content = inlineXml
|
||||||
|
sourceLabel = "inline XML"
|
||||||
|
} else if (path !== undefined) {
|
||||||
|
const fs = await import("node:fs/promises")
|
||||||
|
const nodePath = await import("node:path")
|
||||||
|
const absolutePath = nodePath.resolve(expandHome(path))
|
||||||
|
|
||||||
let content: string
|
try {
|
||||||
try {
|
// A pipe or device could be read forever, and the other
|
||||||
// A pipe or device could be read forever, and the other
|
// write tools wait for this one
|
||||||
// write tools wait for this one
|
if (!(await fs.stat(absolutePath)).isFile()) {
|
||||||
if (!(await fs.stat(absolutePath)).isFile()) {
|
throw new Error("not a regular file")
|
||||||
throw new Error("not a regular file")
|
}
|
||||||
}
|
content = await fs.readFile(absolutePath, "utf-8")
|
||||||
content = await fs.readFile(absolutePath, "utf-8")
|
} catch (e) {
|
||||||
} catch (e) {
|
const msg = e instanceof Error ? e.message : String(e)
|
||||||
const msg = e instanceof Error ? e.message : String(e)
|
return {
|
||||||
return {
|
content: [
|
||||||
content: [
|
{
|
||||||
{
|
type: "text",
|
||||||
type: "text",
|
text: `Error: Cannot read file ${absolutePath}: ${msg}`,
|
||||||
text: `Error: Cannot read file ${absolutePath}: ${msg}`,
|
},
|
||||||
},
|
],
|
||||||
],
|
isError: true,
|
||||||
isError: true,
|
}
|
||||||
}
|
}
|
||||||
|
sourceLabel = absolutePath
|
||||||
}
|
}
|
||||||
|
|
||||||
const loaded = parseDrawioFileContent(content)
|
const loaded = parseDrawioFileContent(content)
|
||||||
@@ -517,7 +558,7 @@ registerWriteTool(
|
|||||||
const xml = loaded.xml
|
const xml = loaded.xml
|
||||||
|
|
||||||
log.info(
|
log.info(
|
||||||
`Loading diagram from ${absolutePath} (${xml.length} chars)`,
|
`Loading diagram from ${sourceLabel} (${xml.length} chars)`,
|
||||||
)
|
)
|
||||||
|
|
||||||
// Save the user's current state before replacing (same flow as
|
// Save the user's current state before replacing (same flow as
|
||||||
@@ -537,10 +578,17 @@ registerWriteTool(
|
|||||||
currentSession.xml = xml
|
currentSession.xml = xml
|
||||||
currentSession.version++
|
currentSession.version++
|
||||||
setState(currentSession.id, xml)
|
setState(currentSession.id, xml)
|
||||||
// Deliberately NOT marking the loaded XML as seen: the model only
|
// Edit-gate semantics by source:
|
||||||
// supplied a path, so it doesn't know the file's cell IDs. The
|
// - 'path': the model only supplied a path, so it doesn't know
|
||||||
// edit gate will require one get_diagram before edits.
|
// the file's cell IDs — keep the gate (one get_diagram first).
|
||||||
currentSession.lastSeenXml = ""
|
// - plain 'xml': the model supplied the exact content, same
|
||||||
|
// rationale as create_new_diagram — record it as seen.
|
||||||
|
// - compressed 'xml': the session now holds the decompressed
|
||||||
|
// form, which the model cannot derive from the compressed
|
||||||
|
// input — keep the gate.
|
||||||
|
const markSeen =
|
||||||
|
inlineXml !== undefined && !loaded.hadCompressedPages
|
||||||
|
currentSession.lastSeenXml = markSeen ? xml : ""
|
||||||
|
|
||||||
addHistory(currentSession.id, xml, "")
|
addHistory(currentSession.id, xml, "")
|
||||||
|
|
||||||
@@ -551,13 +599,17 @@ registerWriteTool(
|
|||||||
? `Pages (${pages.length}): ${pages.map((p) => `[${p.index}] id=${p.id} name="${p.name}" cells=${p.cellCount}`).join(" | ")}`
|
? `Pages (${pages.length}): ${pages.map((p) => `[${p.index}] id=${p.id} name="${p.name}" cells=${p.cellCount}`).join(" | ")}`
|
||||||
: "no pages parsed"
|
: "no pages parsed"
|
||||||
|
|
||||||
log.info(`Diagram loaded from file (${pageSummary})`)
|
log.info(`Diagram loaded (${pageSummary})`)
|
||||||
|
|
||||||
|
const gateHint = markSeen
|
||||||
|
? ""
|
||||||
|
: "\n\nCall get_diagram before edit_diagram — you haven't seen this file's cell IDs yet."
|
||||||
|
|
||||||
return {
|
return {
|
||||||
content: [
|
content: [
|
||||||
{
|
{
|
||||||
type: "text",
|
type: "text",
|
||||||
text: `Diagram loaded from ${absolutePath}!\n\nThe diagram is now visible in your browser.\n\n${pageSummary}\n\nCall get_diagram before edit_diagram — you haven't seen this file's cell IDs yet.`,
|
text: `Diagram loaded from ${sourceLabel}!\n\nThe diagram is now visible in your browser.\n\n${pageSummary}${gateHint}`,
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
}
|
}
|
||||||
@@ -572,7 +624,6 @@ registerWriteTool(
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
// Tool: edit_diagram
|
// Tool: edit_diagram
|
||||||
registerWriteTool(
|
registerWriteTool(
|
||||||
"edit_diagram",
|
"edit_diagram",
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ import {
|
|||||||
import { getXmlSyntaxError } from "./xml-syntax.ts"
|
import { getXmlSyntaxError } from "./xml-syntax.ts"
|
||||||
|
|
||||||
export type LoadResult =
|
export type LoadResult =
|
||||||
| { ok: true; xml: string }
|
| { ok: true; xml: string; hadCompressedPages: boolean }
|
||||||
| { ok: false; error: string }
|
| { ok: false; error: string }
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -101,5 +101,9 @@ export function parseDrawioFileContent(content: string): LoadResult {
|
|||||||
decompressedAny = true
|
decompressedAny = true
|
||||||
}
|
}
|
||||||
// Nothing changed — keep the file's own serialisation.
|
// Nothing changed — keep the file's own serialisation.
|
||||||
return { ok: true, xml: decompressedAny ? serializeMxfile(doc) : trimmed }
|
return {
|
||||||
|
ok: true,
|
||||||
|
xml: decompressedAny ? serializeMxfile(doc) : trimmed,
|
||||||
|
hadCompressedPages: decompressedAny,
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -54,7 +54,11 @@ describe("decompressPageContent", () => {
|
|||||||
describe("parseDrawioFileContent", () => {
|
describe("parseDrawioFileContent", () => {
|
||||||
it("passes a plain-XML mxfile through unchanged", () => {
|
it("passes a plain-XML mxfile through unchanged", () => {
|
||||||
const r = parseDrawioFileContent(PLAIN_MXFILE)
|
const r = parseDrawioFileContent(PLAIN_MXFILE)
|
||||||
expect(r).toEqual({ ok: true, xml: PLAIN_MXFILE })
|
expect(r).toEqual({
|
||||||
|
ok: true,
|
||||||
|
xml: PLAIN_MXFILE,
|
||||||
|
hadCompressedPages: false,
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
it("wraps a bare mxGraphModel into a one-page mxfile", () => {
|
it("wraps a bare mxGraphModel into a one-page mxfile", () => {
|
||||||
@@ -103,7 +107,11 @@ describe("parseDrawioFileContent", () => {
|
|||||||
it("keeps empty pages as-is", () => {
|
it("keeps empty pages as-is", () => {
|
||||||
const withEmpty = `<mxfile><diagram id="a" name="Page-1">${MODEL_XML}</diagram><diagram id="b" name="Empty"></diagram></mxfile>`
|
const withEmpty = `<mxfile><diagram id="a" name="Page-1">${MODEL_XML}</diagram><diagram id="b" name="Empty"></diagram></mxfile>`
|
||||||
const r = parseDrawioFileContent(withEmpty)
|
const r = parseDrawioFileContent(withEmpty)
|
||||||
expect(r).toEqual({ ok: true, xml: withEmpty })
|
expect(r).toEqual({
|
||||||
|
ok: true,
|
||||||
|
xml: withEmpty,
|
||||||
|
hadCompressedPages: false,
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
it("rejects empty files", () => {
|
it("rejects empty files", () => {
|
||||||
@@ -124,3 +132,30 @@ describe("parseDrawioFileContent", () => {
|
|||||||
if (!r.ok) expect(r.error).toContain('"Broken"')
|
if (!r.ok) expect(r.error).toContain('"Broken"')
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
describe("hadCompressedPages", () => {
|
||||||
|
it("is false for a plain-XML mxfile", () => {
|
||||||
|
const r = parseDrawioFileContent(PLAIN_MXFILE)
|
||||||
|
expect(r.ok).toBe(true)
|
||||||
|
if (r.ok) expect(r.hadCompressedPages).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it("is false for a bare mxGraphModel", () => {
|
||||||
|
const r = parseDrawioFileContent(MODEL_XML)
|
||||||
|
expect(r.ok).toBe(true)
|
||||||
|
if (r.ok) expect(r.hadCompressedPages).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it("is true for a fully compressed mxfile", () => {
|
||||||
|
const r = parseDrawioFileContent(COMPRESSED_MXFILE)
|
||||||
|
expect(r.ok).toBe(true)
|
||||||
|
if (r.ok) expect(r.hadCompressedPages).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it("is true for a mixed plain/compressed file", () => {
|
||||||
|
const mixed = `<mxfile><diagram id="a" name="Plain">${MODEL_XML}</diagram><diagram id="b" name="Squeezed">${drawioCompress(MODEL_XML)}</diagram></mxfile>`
|
||||||
|
const r = parseDrawioFileContent(mixed)
|
||||||
|
expect(r.ok).toBe(true)
|
||||||
|
if (r.ok) expect(r.hadCompressedPages).toBe(true)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|||||||
@@ -188,3 +188,51 @@ describe("MCP server wiring", () => {
|
|||||||
expect(text.length).toBeLessThanOrEqual(15000)
|
expect(text.length).toBeLessThanOrEqual(15000)
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
describe("load_diagram dual-source arguments", () => {
|
||||||
|
it("advertises both optional 'path' and 'xml' sources", async () => {
|
||||||
|
const resp = await send("tools/list", {})
|
||||||
|
const load = resp.result.tools.find(
|
||||||
|
(t: { name: string }) => t.name === "load_diagram",
|
||||||
|
)
|
||||||
|
const props = load?.inputSchema?.properties ?? {}
|
||||||
|
expect(props.path).toBeTruthy()
|
||||||
|
expect(props.xml).toBeTruthy()
|
||||||
|
const required: string[] = load?.inputSchema?.required ?? []
|
||||||
|
expect(required).not.toContain("path")
|
||||||
|
expect(required).not.toContain("xml")
|
||||||
|
})
|
||||||
|
|
||||||
|
it("rejects passing both 'path' and 'xml'", async () => {
|
||||||
|
// Argument validation fires before the session check: no session
|
||||||
|
// exists in this harness, so a both-args call must report the
|
||||||
|
// mutual-exclusion error, not "No active session".
|
||||||
|
const resp = await send("tools/call", {
|
||||||
|
name: "load_diagram",
|
||||||
|
arguments: { path: "/tmp/x.drawio", xml: "<mxfile/>" },
|
||||||
|
})
|
||||||
|
expect(resp.error, JSON.stringify(resp.error)).toBeUndefined()
|
||||||
|
expect(resp.result?.isError).toBe(true)
|
||||||
|
expect(resp.result?.content?.[0]?.text).toContain("not both")
|
||||||
|
})
|
||||||
|
|
||||||
|
it("rejects passing neither 'path' nor 'xml'", async () => {
|
||||||
|
const resp = await send("tools/call", {
|
||||||
|
name: "load_diagram",
|
||||||
|
arguments: {},
|
||||||
|
})
|
||||||
|
expect(resp.result?.isError).toBe(true)
|
||||||
|
expect(resp.result?.content?.[0]?.text).toContain("either 'path'")
|
||||||
|
})
|
||||||
|
|
||||||
|
it("accepts 'xml' alone as a source (fails only on the missing session)", async () => {
|
||||||
|
const resp = await send("tools/call", {
|
||||||
|
name: "load_diagram",
|
||||||
|
arguments: {
|
||||||
|
xml: '<mxfile><diagram id="p" name="P"><mxGraphModel><root><mxCell id="0"/><mxCell id="1" parent="0"/></root></mxGraphModel></diagram></mxfile>',
|
||||||
|
},
|
||||||
|
})
|
||||||
|
expect(resp.result?.isError).toBe(true)
|
||||||
|
expect(resp.result?.content?.[0]?.text).toContain("No active session")
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|||||||
@@ -10,6 +10,8 @@ export interface DiagramVersion {
|
|||||||
number: number
|
number: number
|
||||||
/** Full multi-page document after the change */
|
/** Full multi-page document after the change */
|
||||||
xml: string
|
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 */
|
/** Diagram before the change; kept only on the latest version */
|
||||||
beforeXml?: string
|
beforeXml?: string
|
||||||
/** SVG data URL thumbnail; "" when none could be made */
|
/** SVG data URL thumbnail; "" when none could be made */
|
||||||
|
|||||||
@@ -131,7 +131,7 @@ const NEW_CELLS =
|
|||||||
cell("3", "R&D", 400) +
|
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>`
|
`<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,
|
page: p,
|
||||||
}) => {
|
}) => {
|
||||||
const canvas = await mockReplies(p, [
|
const canvas = await mockReplies(p, [
|
||||||
@@ -155,9 +155,11 @@ test("display_diagram replaces the document with the fixed diagram", async ({
|
|||||||
timeout: 15000,
|
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(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 ({
|
test("an edit with a fixable cell is fixed, not rejected", async ({
|
||||||
|
|||||||
@@ -108,6 +108,25 @@ async function boxOf(locator: Locator) {
|
|||||||
return box
|
return box
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Click the canvas where no shape is, to deselect: the corner of the
|
||||||
|
* container farthest from the shape. A fixed spot near the top-left corner
|
||||||
|
* is not safe, as a click on a shape may scroll it into that corner.
|
||||||
|
*/
|
||||||
|
async function clickEmptyCanvas(page: Page, shape: Locator) {
|
||||||
|
const container = getIframeContent(page).locator(".geDiagramContainer")
|
||||||
|
const box = await boxOf(container)
|
||||||
|
const shapeBox = await boxOf(shape)
|
||||||
|
const shapeX = shapeBox.x + shapeBox.width / 2
|
||||||
|
const shapeY = shapeBox.y + shapeBox.height / 2
|
||||||
|
await container.click({
|
||||||
|
position: {
|
||||||
|
x: shapeX < box.x + box.width / 2 ? box.width - 20 : 20,
|
||||||
|
y: shapeY < box.y + box.height / 2 ? box.height - 20 : 20,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
/** draw.io's own Undo button: draw.io sets "disabled" with nothing to undo */
|
/** draw.io's own Undo button: draw.io sets "disabled" with nothing to undo */
|
||||||
function drawioUndo(page: Page) {
|
function drawioUndo(page: Page) {
|
||||||
return getIframeContent(page).locator('.geSimpleMainMenu a[title="Undo"]')
|
return getIframeContent(page).locator('.geSimpleMainMenu a[title="Undo"]')
|
||||||
@@ -1001,7 +1020,7 @@ test.describe("Workspace", () => {
|
|||||||
await expect(canvas.getByText("Old A")).toBeVisible()
|
await expect(canvas.getByText("Old A")).toBeVisible()
|
||||||
await sendMessage(page, "Create a flowchart")
|
await sendMessage(page, "Create a flowchart")
|
||||||
await waitForComplete(page)
|
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.waitForTimeout(2000)
|
||||||
await page.locator('[data-testid="version-undo"]').click()
|
await page.locator('[data-testid="version-undo"]').click()
|
||||||
await expect(canvas.getByText("Old A")).toBeVisible()
|
await expect(canvas.getByText("Old A")).toBeVisible()
|
||||||
@@ -1533,7 +1552,7 @@ test.describe("Workspace", () => {
|
|||||||
await expect.poll(savedPageId, { timeout: 10000 }).toBe("orig")
|
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,
|
page,
|
||||||
}) => {
|
}) => {
|
||||||
await page.route("**/api/chat", drawThenEdit())
|
await page.route("**/api/chat", drawThenEdit())
|
||||||
@@ -1567,7 +1586,7 @@ test.describe("Workspace", () => {
|
|||||||
.querySelector("diagram")
|
.querySelector("diagram")
|
||||||
?.getAttribute("id")
|
?.getAttribute("id")
|
||||||
})
|
})
|
||||||
await expect.poll(savedPageId, { timeout: 10000 }).toBe("page-1")
|
await expect.poll(savedPageId, { timeout: 10000 }).toBe("orig")
|
||||||
await canvas
|
await canvas
|
||||||
.locator(".geDiagramContainer")
|
.locator(".geDiagramContainer")
|
||||||
.click({ position: { x: 10, y: 10 } })
|
.click({ position: { x: 10, y: 10 } })
|
||||||
@@ -1576,7 +1595,62 @@ test.describe("Workspace", () => {
|
|||||||
await expect.poll(savedPageId, { timeout: 10000 }).toBe("orig")
|
await expect.poll(savedPageId, { timeout: 10000 }).toBe("orig")
|
||||||
await page.keyboard.press("ControlOrMeta+Shift+z")
|
await page.keyboard.press("ControlOrMeta+Shift+z")
|
||||||
await expect(canvas.getByText("Process", { exact: true })).toBeVisible()
|
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 ({
|
test("page settings the AI writes in single quotes are applied too", async ({
|
||||||
@@ -4457,9 +4531,7 @@ test.describe("Edge cases", () => {
|
|||||||
})
|
})
|
||||||
await sendMessage(page, "Make this red")
|
await sendMessage(page, "Make this red")
|
||||||
// Deselected while the diagram exports
|
// Deselected while the diagram exports
|
||||||
await canvas
|
await clickEmptyCanvas(page, shape)
|
||||||
.locator(".geDiagramContainer")
|
|
||||||
.click({ position: { x: 10, y: 10 } })
|
|
||||||
await expect(chip).toHaveCount(0)
|
await expect(chip).toHaveCount(0)
|
||||||
await page.evaluate(() => (window as any).__releaseExports())
|
await page.evaluate(() => (window as any).__releaseExports())
|
||||||
await waitForCompleteCount(page, 2)
|
await waitForCompleteCount(page, 2)
|
||||||
|
|||||||
@@ -153,9 +153,19 @@ describe("diffDiagrams", () => {
|
|||||||
expect(touchedIds).toEqual(["u1"])
|
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")))
|
const twoPages = file(pageOf("p1", box("a")), pageOf("p2", box("z")))
|
||||||
expect(diffDiagrams("", twoPages).summary.shapesAdded).toBe(1)
|
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", () => {
|
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")
|
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(() => ({
|
const mocks = vi.hoisted(() => ({
|
||||||
loadDiagram: vi.fn((_xml: string, ..._rest: unknown[]) => null),
|
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", () => ({
|
vi.mock("@/contexts/diagram-context", () => ({
|
||||||
useDiagram: () => ({
|
useDiagram: () => ({
|
||||||
chartXML: BEFORE_FIRST_EDIT,
|
chartXML: mocks.canvas ?? BEFORE_FIRST_EDIT,
|
||||||
chartXMLRef: { current: AFTER_FIRST_EDIT },
|
chartXMLRef: { current: mocks.canvas ?? AFTER_FIRST_EDIT },
|
||||||
loadDiagram: mocks.loadDiagram,
|
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()
|
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 { describe, expect, it } from "vitest"
|
||||||
import {
|
import {
|
||||||
cn,
|
cn,
|
||||||
extractCompleteMxCells,
|
extractCompleteMxCells,
|
||||||
|
extractDiagramXML,
|
||||||
isMxCellXmlComplete,
|
isMxCellXmlComplete,
|
||||||
isRealDiagram,
|
isRealDiagram,
|
||||||
|
replaceNodes,
|
||||||
} from "@/lib/utils"
|
} from "@/lib/utils"
|
||||||
import { BLANK_MXFILE } from "@/packages/mcp-server/src/pages.ts"
|
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