mirror of
https://github.com/DayuanJiang/next-ai-draw-io.git
synced 2026-10-11 03:59:58 +08:00
Compare commits
10
Commits
feat/admin-copy
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
213b672e92 | ||
|
|
d1ee1594f1 | ||
|
|
cd5352ca88 | ||
|
|
498ee628f1 | ||
|
|
a6e812d3a5 | ||
|
|
02eaa0bd22 | ||
|
|
4cba863fd9 | ||
|
|
7682e479e3 | ||
|
|
a7ae114edf | ||
|
|
e117095c7b |
@@ -86,6 +86,9 @@ jobs:
|
|||||||
artifact-configuration-slug: 'windows-exe'
|
artifact-configuration-slug: 'windows-exe'
|
||||||
github-artifact-id: ${{ steps.upload-unsigned.outputs.artifact-id }}
|
github-artifact-id: ${{ steps.upload-unsigned.outputs.artifact-id }}
|
||||||
wait-for-completion: true
|
wait-for-completion: true
|
||||||
|
# The release-signing policy needs a manual approval in SignPath;
|
||||||
|
# the default 600 s wait made the job fail before anyone could approve.
|
||||||
|
wait-for-completion-timeout-in-seconds: 3600
|
||||||
output-artifact-directory: release-signed
|
output-artifact-directory: release-signed
|
||||||
|
|
||||||
- name: Upload signed artifacts to release
|
- name: Upload signed artifacts to release
|
||||||
|
|||||||
+27
-10
@@ -55,12 +55,16 @@ import { allowPrivateUrls, isPrivateUrl } from "@/lib/ssrf-protection"
|
|||||||
import { getSystemPrompt } from "@/lib/system-prompts"
|
import { getSystemPrompt } from "@/lib/system-prompts"
|
||||||
import { normalizeBaseUrl } from "@/lib/types/model-config"
|
import { normalizeBaseUrl } from "@/lib/types/model-config"
|
||||||
import { getUserIdFromRequest } from "@/lib/user-id"
|
import { getUserIdFromRequest } from "@/lib/user-id"
|
||||||
|
import { foldCells } from "@/packages/mcp-server/src/compact-cells.ts"
|
||||||
import { hasCells } from "@/packages/mcp-server/src/pages.ts"
|
import { hasCells } from "@/packages/mcp-server/src/pages.ts"
|
||||||
import {
|
import {
|
||||||
getShapeLibrary,
|
getShapeLibrary,
|
||||||
SHAPE_LIBRARY_LIST,
|
SHAPE_LIBRARY_LIST,
|
||||||
} from "@/packages/mcp-server/src/shape-library.ts"
|
} from "@/packages/mcp-server/src/shape-library.ts"
|
||||||
import { SWIMLANE_EXAMPLE } from "@/packages/mcp-server/src/xml-examples.ts"
|
import {
|
||||||
|
STYLE_CLASS_EXAMPLE,
|
||||||
|
SWIMLANE_EXAMPLE,
|
||||||
|
} from "@/packages/mcp-server/src/xml-examples.ts"
|
||||||
|
|
||||||
// No explicit cap: a reasoning model can spend minutes planning before it emits
|
// No explicit cap: a reasoning model can spend minutes planning before it emits
|
||||||
// the tool call, so take whatever the host allows. Vercel's own default is 300s,
|
// the tool call, so take whatever the host allows. Vercel's own default is 300s,
|
||||||
@@ -98,6 +102,11 @@ function createCachedStreamResponse(xml: string): Response {
|
|||||||
|
|
||||||
// Responses streamed from the model, whose trace streamText's callbacks end
|
// Responses streamed from the model, whose trace streamText's callbacks end
|
||||||
const modelStreamResponses = new WeakSet<Response>()
|
const modelStreamResponses = new WeakSet<Response>()
|
||||||
|
// A Request's signal follows the client's disconnect only while the Request
|
||||||
|
// object itself is alive: once it is garbage collected, the abort is lost
|
||||||
|
// (nodejs/undici#3644) and a stopped chat would run on at the provider.
|
||||||
|
// Each request is kept as long as its answer streams.
|
||||||
|
const requestOfResponse = new WeakMap<Response, Request>()
|
||||||
|
|
||||||
// Inner handler function
|
// Inner handler function
|
||||||
const DEBUG_LLM_PAYLOAD = process.env.DEBUG_LLM_PAYLOAD === "true"
|
const DEBUG_LLM_PAYLOAD = process.env.DEBUG_LLM_PAYLOAD === "true"
|
||||||
@@ -515,18 +524,20 @@ ${userInputText}
|
|||||||
SINGLE_SYSTEM_PROVIDERS.has(resolvedProvider) || isCustomOpenAIEndpoint
|
SINGLE_SYSTEM_PROVIDERS.has(resolvedProvider) || isCustomOpenAIEndpoint
|
||||||
|
|
||||||
const selectionContext = formatSelectionContext(body.selectedCells)
|
const selectionContext = formatSelectionContext(body.selectedCells)
|
||||||
|
// The model reads the diagram in the compact notation it writes
|
||||||
|
// (compact-cells.ts); the canvas itself keeps the full XML
|
||||||
const xmlContext = `${
|
const xmlContext = `${
|
||||||
previousXml
|
previousXml
|
||||||
? `Previous diagram XML (before user's last message):
|
? `Previous diagram XML (before user's last message):
|
||||||
"""xml
|
"""xml
|
||||||
${previousXml}
|
${foldCells(previousXml)}
|
||||||
"""
|
"""
|
||||||
|
|
||||||
`
|
`
|
||||||
: ""
|
: ""
|
||||||
}Current diagram XML (AUTHORITATIVE - the source of truth):
|
}Current diagram XML (AUTHORITATIVE - the source of truth), shown in the same compact notation you write (shapes with x, y, w, h; edges with source and target):
|
||||||
"""xml
|
"""xml
|
||||||
${xml || ""}
|
${foldCells(xml || "")}
|
||||||
"""
|
"""
|
||||||
|
|
||||||
IMPORTANT: The "Current diagram XML" is the SINGLE SOURCE OF TRUTH for what's on the canvas right now. The user can manually add, delete, or modify shapes directly in draw.io. Always count and describe elements based on the CURRENT XML, not on what you previously generated. If both previous and current XML are shown, compare them to understand what the user changed.${selectionContext ? `\n\n${selectionContext}` : ""}`
|
IMPORTANT: The "Current diagram XML" is the SINGLE SOURCE OF TRUTH for what's on the canvas right now. The user can manually add, delete, or modify shapes directly in draw.io. Always count and describe elements based on the CURRENT XML, not on what you previously generated. If both previous and current XML are shown, compare them to understand what the user changed.${selectionContext ? `\n\n${selectionContext}` : ""}`
|
||||||
@@ -654,19 +665,24 @@ IMPORTANT: The "Current diagram XML" is the SINGLE SOURCE OF TRUTH for what's on
|
|||||||
tools: {
|
tools: {
|
||||||
// Client-side tool that will be executed on the client
|
// Client-side tool that will be executed on the client
|
||||||
display_diagram: {
|
display_diagram: {
|
||||||
description: `Display a diagram on draw.io. Pass ONLY the mxCell elements - wrapper tags and root cells are added automatically.
|
description: `Display a diagram on draw.io. Pass ONLY the mxStyle definitions and the mxCell elements - wrapper tags and root cells are added automatically.
|
||||||
|
|
||||||
VALIDATION RULES (XML will be rejected if violated):
|
VALIDATION RULES (XML will be rejected if violated):
|
||||||
1. Generate ONLY mxCell elements - NO wrapper tags (<mxfile>, <mxGraphModel>, <root>)
|
1. Generate ONLY mxStyle definitions and mxCell elements - NO wrapper tags (<mxfile>, <mxGraphModel>, <root>)
|
||||||
2. Do NOT include root cells (id="0" or id="1") - they are added automatically
|
2. Do NOT include root cells (id="0" or id="1") - they are added automatically
|
||||||
3. All mxCell elements must be siblings - never nested
|
3. All mxCell elements must be siblings - never nested
|
||||||
4. Every mxCell needs a unique id (start from "2")
|
4. Every mxCell needs a unique id (start from "2")
|
||||||
5. Every mxCell needs a valid parent attribute (use "1" for top-level)
|
5. parent defaults to "1"; write parent="<container-id>" only for shapes inside a container
|
||||||
6. Escape special chars in values: < > & "
|
6. Escape special chars in values: < > & "
|
||||||
|
|
||||||
|
A shape is one self-closing mxCell with x, y, w and h; an edge is one with source and target (a cell with source or target is always an edge). vertex="1", edge="1", parent="1" and the mxGeometry element are added automatically. Write parent only for a shape inside a container, and an mxGeometry element only for edge waypoints or for a separate label cell placed on an edge: <mxCell id="9" value="yes" style="edgeLabel;" parent="<edge id>" connectable="0"><mxGeometry x="-0.5" relative="1" as="geometry"/></mxCell>. An edge's own text simply goes in its value.
|
||||||
|
|
||||||
Example (generate ONLY this - no wrapper tags):
|
Example (generate ONLY this - no wrapper tags):
|
||||||
${SWIMLANE_EXAMPLE}
|
${SWIMLANE_EXAMPLE}
|
||||||
|
|
||||||
|
Styles: define a style used by several cells ONCE with <mxStyle name="..." value="..."/> before the cells and use the name in style like a CSS class; overrides after the name win. Name only styles that two or more cells share; names must not be draw.io's own style names (text, ellipse, rhombus, swimlane, label, image, blue, green, red, gray, yellow, orange, purple, pink). A definition applies to the call it is in. html=1 and whiteSpace=wrap are added automatically, never write them. Labels are HTML: use <br> for a line break, never \\n; a literal < or > is written &lt; or &gt;.
|
||||||
|
${STYLE_CLASS_EXAMPLE}
|
||||||
|
|
||||||
Notes:
|
Notes:
|
||||||
- For AWS diagrams, use **AWS 2025 icons**.
|
- For AWS diagrams, use **AWS 2025 icons**.
|
||||||
- For animated connectors, add "flowAnimation=1" to edge style.
|
- For animated connectors, add "flowAnimation=1" to edge style.
|
||||||
@@ -685,12 +701,12 @@ Operations:
|
|||||||
- add: Add a new cell. Provide cell_id (new unique id) and new_xml.
|
- add: Add a new cell. Provide cell_id (new unique id) and new_xml.
|
||||||
- delete: Remove a cell. Cascade is automatic: children AND edges (source/target) are auto-deleted. Only specify ONE cell_id.
|
- delete: Remove a cell. Cascade is automatic: children AND edges (source/target) are auto-deleted. Only specify ONE cell_id.
|
||||||
|
|
||||||
For update/add, new_xml must be a complete mxCell element including mxGeometry.
|
For update/add, new_xml is the complete mxCell in the compact form (a shape with x, y, w, h; an edge with source and target), with its complete style (named styles are not available in edit_diagram).
|
||||||
|
|
||||||
⚠️ JSON ESCAPING: Every " inside new_xml MUST be escaped as \\". Example: id=\\"5\\" value=\\"Label\\"
|
⚠️ JSON ESCAPING: Every " inside new_xml MUST be escaped as \\". Example: id=\\"5\\" value=\\"Label\\"
|
||||||
|
|
||||||
Example - Add a rectangle:
|
Example - Add a rectangle:
|
||||||
{"operations": [{"operation": "add", "cell_id": "rect-1", "new_xml": "<mxCell id=\\"rect-1\\" value=\\"Hello\\" style=\\"rounded=0;\\" vertex=\\"1\\" parent=\\"1\\"><mxGeometry x=\\"100\\" y=\\"100\\" width=\\"120\\" height=\\"60\\" as=\\"geometry\\"/></mxCell>"}]}
|
{"operations": [{"operation": "add", "cell_id": "rect-1", "new_xml": "<mxCell id=\\"rect-1\\" value=\\"Hello\\" style=\\"rounded=1;\\" x=\\"100\\" y=\\"100\\" w=\\"120\\" h=\\"60\\"/>"}]}
|
||||||
|
|
||||||
Example - Delete container (children & edges auto-deleted):
|
Example - Delete container (children & edges auto-deleted):
|
||||||
{"operations": [{"operation": "delete", "cell_id": "2"}]}`,
|
{"operations": [{"operation": "delete", "cell_id": "2"}]}`,
|
||||||
@@ -730,7 +746,7 @@ CRITICAL INSTRUCTIONS:
|
|||||||
3. Complete the remaining mxCell elements
|
3. Complete the remaining mxCell elements
|
||||||
4. If still truncated, call append_diagram again with the next fragment
|
4. If still truncated, call append_diagram again with the next fragment
|
||||||
|
|
||||||
Example: If previous output ended with '<mxCell id="x" style="rounded=1', continue with ';" vertex="1">...' and complete the remaining elements.`,
|
Example: If previous output ended with '<mxCell id="x" style="rounded=1', continue with ';" x="40" y="40" w="120" h="60"/>' and complete the remaining elements.`,
|
||||||
inputSchema: z.object({
|
inputSchema: z.object({
|
||||||
xml: z
|
xml: z
|
||||||
.string()
|
.string()
|
||||||
@@ -787,6 +803,7 @@ Call this tool to get shape names and usage syntax for a specific library.`,
|
|||||||
},
|
},
|
||||||
})
|
})
|
||||||
modelStreamResponses.add(response)
|
modelStreamResponses.add(response)
|
||||||
|
requestOfResponse.set(response, req)
|
||||||
return response
|
return response
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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,13 +5,25 @@ 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,
|
||||||
replaceNodes,
|
replaceNodes,
|
||||||
} from "@/lib/utils"
|
} from "@/lib/utils"
|
||||||
|
import {
|
||||||
|
defaultLayerOf,
|
||||||
|
expandCompactCells,
|
||||||
|
} 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 {
|
||||||
|
addDefaultStyles,
|
||||||
|
applyStyleClasses,
|
||||||
|
edgeIdsOf,
|
||||||
|
readStyleClasses,
|
||||||
|
} from "@/packages/mcp-server/src/style-classes.ts"
|
||||||
|
|
||||||
// Helper to extract complete operations from streaming input
|
// Helper to extract complete operations from streaming input
|
||||||
function getCompleteOperations(
|
function getCompleteOperations(
|
||||||
@@ -34,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>
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -50,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>("")
|
||||||
@@ -71,9 +86,17 @@ export function useDiagramStreaming({
|
|||||||
// so far. The tool handler validates and loads the final diagram.
|
// so far. The tool handler validates and loads the final diagram.
|
||||||
const handleDisplayChart = useCallback(
|
const handleDisplayChart = useCallback(
|
||||||
(xml: string) => {
|
(xml: string) => {
|
||||||
const completeCells = extractCompleteMxCells(xml || "")
|
// Named styles come before the cells, so the ones written so
|
||||||
|
// far are known by the time their cells stream in
|
||||||
|
const { classes, xml: cellsXml } = readStyleClasses(xml || "")
|
||||||
|
const completeCells = extractCompleteMxCells(cellsXml)
|
||||||
if (!completeCells) return
|
if (!completeCells) return
|
||||||
const convertedXml = convertToLegalXml(completeCells)
|
const convertedXml = addDefaultStyles(
|
||||||
|
applyStyleClasses(
|
||||||
|
expandCompactCells(convertToLegalXml(completeCells)),
|
||||||
|
classes,
|
||||||
|
),
|
||||||
|
)
|
||||||
if (convertedXml === previousXML.current) return
|
if (convertedXml === previousXML.current) return
|
||||||
|
|
||||||
// Skip this update while the cells written so far don't parse
|
// Skip this update while the cells written so far don't parse
|
||||||
@@ -84,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) {
|
||||||
@@ -202,9 +230,29 @@ 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 {
|
||||||
|
// The model's page: the edges on it and its first layer,
|
||||||
|
// as editDiagram reads them
|
||||||
|
const selector = pageSelectorFor(
|
||||||
|
originalXml,
|
||||||
|
turnPageIdRef?.current,
|
||||||
|
)
|
||||||
|
const page = targetPageXml(originalXml, selector)
|
||||||
|
const edges = edgeIdsOf(page)
|
||||||
|
const layer = defaultLayerOf(page)
|
||||||
const { result } = applyDiagramOperations(
|
const { result } = applyDiagramOperations(
|
||||||
originalXml,
|
originalXml,
|
||||||
completeOps,
|
completeOps.map((op) =>
|
||||||
|
op.new_xml
|
||||||
|
? {
|
||||||
|
...op,
|
||||||
|
new_xml: addDefaultStyles(
|
||||||
|
expandCompactCells(op.new_xml, layer),
|
||||||
|
edges,
|
||||||
|
),
|
||||||
|
}
|
||||||
|
: 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
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
appId: com.nextaidrawio.app
|
appId: com.nextaidrawio.app
|
||||||
productName: Next AI Draw.io
|
productName: Next AI Draw.io
|
||||||
copyright: Copyright © 2024 Next AI Draw.io
|
copyright: Copyright © 2024 Next AI Draw.io
|
||||||
electronVersion: 39.2.7
|
|
||||||
|
|
||||||
directories:
|
directories:
|
||||||
output: release
|
output: release
|
||||||
|
|||||||
@@ -5,15 +5,19 @@ 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"
|
||||||
|
import { foldCells } from "@/packages/mcp-server/src/compact-cells.ts"
|
||||||
import { editDiagram } from "@/packages/mcp-server/src/edit-diagram.ts"
|
import { editDiagram } from "@/packages/mcp-server/src/edit-diagram.ts"
|
||||||
import { prepareNewDiagram } from "@/packages/mcp-server/src/new-diagram.ts"
|
import { prepareNewDiagram } from "@/packages/mcp-server/src/new-diagram.ts"
|
||||||
|
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" }
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -81,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>
|
||||||
@@ -117,6 +124,7 @@ export function useDiagramToolHandlers({
|
|||||||
processedToolCallsRef,
|
processedToolCallsRef,
|
||||||
validationRetryCountRef,
|
validationRetryCountRef,
|
||||||
chartXMLRef,
|
chartXMLRef,
|
||||||
|
turnPageIdRef,
|
||||||
onDisplayChart,
|
onDisplayChart,
|
||||||
onFetchChart,
|
onFetchChart,
|
||||||
captureValidationPng,
|
captureValidationPng,
|
||||||
@@ -180,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()) {
|
||||||
@@ -263,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) {
|
||||||
@@ -501,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]
|
||||||
@@ -516,7 +543,7 @@ ${finalXml}
|
|||||||
|
|
||||||
Current diagram XML:
|
Current diagram XML:
|
||||||
\`\`\`xml
|
\`\`\`xml
|
||||||
${currentXml}
|
${shownToModel(currentXml)}
|
||||||
\`\`\`
|
\`\`\`
|
||||||
|
|
||||||
Please check the cell IDs and retry.`,
|
Please check the cell IDs and retry.`,
|
||||||
@@ -545,7 +572,7 @@ Please check the cell IDs and retry.`,
|
|||||||
|
|
||||||
Current diagram XML:
|
Current diagram XML:
|
||||||
\`\`\`xml
|
\`\`\`xml
|
||||||
${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.`,
|
||||||
@@ -575,7 +602,10 @@ Use display_diagram to create the complete diagram, or edit_diagram to change th
|
|||||||
// Detect if LLM incorrectly started fresh instead of continuing
|
// Detect if LLM incorrectly started fresh instead of continuing
|
||||||
// LLM should only output bare mxCells now, so wrapper tags indicate error
|
// LLM should only output bare mxCells now, so wrapper tags indicate error
|
||||||
const trimmed = xml.trim()
|
const trimmed = xml.trim()
|
||||||
|
// Definitions at the start are a restart only once cells were written
|
||||||
const isFreshStart =
|
const isFreshStart =
|
||||||
|
(trimmed.startsWith("<mxStyle") &&
|
||||||
|
hasCells(partialXmlRef.current)) ||
|
||||||
trimmed.startsWith("<mxGraphModel") ||
|
trimmed.startsWith("<mxGraphModel") ||
|
||||||
trimmed.startsWith("<root") ||
|
trimmed.startsWith("<root") ||
|
||||||
trimmed.startsWith("<mxfile") ||
|
trimmed.startsWith("<mxfile") ||
|
||||||
@@ -615,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) {
|
||||||
|
|||||||
+133
-125
@@ -740,146 +740,154 @@ export const CACHED_EXAMPLE_RESPONSES: CachedResponse[] = [
|
|||||||
{
|
{
|
||||||
promptText: "Draw a cat for me",
|
promptText: "Draw a cat for me",
|
||||||
hasImage: false,
|
hasImage: false,
|
||||||
xml: `<mxCell id="2" value="" style="ellipse;whiteSpace=wrap;html=1;aspect=fixed;fillColor=#FFE6CC;strokeColor=#000000;strokeWidth=2;" vertex="1" parent="1">
|
xml: `<mxCell id="2" edge="1" parent="1" style="curved=1;endArrow=none;html=1;strokeWidth=14;strokeColor=#F4A460;" value="">
|
||||||
<mxGeometry x="300" y="150" width="120" height="120" as="geometry"/>
|
<mxGeometry relative="1" as="geometry">
|
||||||
</mxCell>
|
|
||||||
|
|
||||||
|
|
||||||
<mxCell id="3" value="" style="triangle;whiteSpace=wrap;html=1;fillColor=#FFE6CC;strokeColor=#000000;strokeWidth=2;rotation=30;" vertex="1" parent="1">
|
|
||||||
<mxGeometry x="280" y="120" width="50" height="60" as="geometry"/>
|
|
||||||
</mxCell>
|
|
||||||
|
|
||||||
|
|
||||||
<mxCell id="4" value="" style="triangle;whiteSpace=wrap;html=1;fillColor=#FFE6CC;strokeColor=#000000;strokeWidth=2;rotation=-30;" vertex="1" parent="1">
|
|
||||||
<mxGeometry x="390" y="120" width="50" height="60" as="geometry"/>
|
|
||||||
</mxCell>
|
|
||||||
|
|
||||||
|
|
||||||
<mxCell id="5" value="" style="triangle;whiteSpace=wrap;html=1;fillColor=#FFB6C1;strokeColor=none;rotation=30;" vertex="1" parent="1">
|
|
||||||
<mxGeometry x="290" y="135" width="30" height="35" as="geometry"/>
|
|
||||||
</mxCell>
|
|
||||||
|
|
||||||
|
|
||||||
<mxCell id="6" value="" style="triangle;whiteSpace=wrap;html=1;fillColor=#FFB6C1;strokeColor=none;rotation=-30;" vertex="1" parent="1">
|
|
||||||
<mxGeometry x="400" y="135" width="30" height="35" as="geometry"/>
|
|
||||||
</mxCell>
|
|
||||||
|
|
||||||
|
|
||||||
<mxCell id="7" value="" style="ellipse;whiteSpace=wrap;html=1;aspect=fixed;fillColor=#000000;strokeColor=#000000;" vertex="1" parent="1">
|
|
||||||
<mxGeometry x="325" y="185" width="15" height="15" as="geometry"/>
|
|
||||||
</mxCell>
|
|
||||||
|
|
||||||
|
|
||||||
<mxCell id="8" value="" style="ellipse;whiteSpace=wrap;html=1;aspect=fixed;fillColor=#000000;strokeColor=#000000;" vertex="1" parent="1">
|
|
||||||
<mxGeometry x="380" y="185" width="15" height="15" as="geometry"/>
|
|
||||||
</mxCell>
|
|
||||||
|
|
||||||
|
|
||||||
<mxCell id="9" value="" style="triangle;whiteSpace=wrap;html=1;fillColor=#FFB6C1;strokeColor=#000000;strokeWidth=1;rotation=180;" vertex="1" parent="1">
|
|
||||||
<mxGeometry x="350" y="210" width="20" height="15" as="geometry"/>
|
|
||||||
</mxCell>
|
|
||||||
|
|
||||||
|
|
||||||
<mxCell id="10" value="" style="curved=1;endArrow=none;html=1;strokeColor=#000000;strokeWidth=2;exitX=0.5;exitY=1;exitDx=0;exitDy=0;" edge="1" parent="1">
|
|
||||||
<mxGeometry width="50" height="50" relative="1" as="geometry">
|
|
||||||
<mxPoint x="360" y="220" as="sourcePoint"/>
|
|
||||||
<mxPoint x="340" y="235" as="targetPoint"/>
|
|
||||||
<Array as="points">
|
<Array as="points">
|
||||||
<mxPoint x="355" y="230"/>
|
<mxPoint x="580" y="480"/>
|
||||||
|
<mxPoint x="600" y="380"/>
|
||||||
</Array>
|
</Array>
|
||||||
|
<mxPoint x="490" y="480" as="sourcePoint"/>
|
||||||
|
<mxPoint x="560" y="330" as="targetPoint"/>
|
||||||
</mxGeometry>
|
</mxGeometry>
|
||||||
</mxCell>
|
</mxCell>
|
||||||
|
|
||||||
|
|
||||||
<mxCell id="11" value="" style="curved=1;endArrow=none;html=1;strokeColor=#000000;strokeWidth=2;" edge="1" parent="1">
|
<mxCell id="3" parent="1" style="ellipse;whiteSpace=wrap;html=1;fillColor=#F4A460;strokeColor=#8B5A2B;strokeWidth=2;" value="" vertex="1">
|
||||||
<mxGeometry width="50" height="50" relative="1" as="geometry">
|
<mxGeometry height="190" width="200" x="320" y="320" as="geometry"/>
|
||||||
<mxPoint x="360" y="220" as="sourcePoint"/>
|
</mxCell>
|
||||||
<mxPoint x="380" y="235" as="targetPoint"/>
|
|
||||||
|
|
||||||
|
<mxCell id="4" parent="1" style="ellipse;whiteSpace=wrap;html=1;fillColor=#FFE4C4;strokeColor=none;" value="" vertex="1">
|
||||||
|
<mxGeometry height="120" width="100" x="370" y="360" as="geometry"/>
|
||||||
|
</mxCell>
|
||||||
|
|
||||||
|
|
||||||
|
<mxCell id="5" parent="1" style="ellipse;whiteSpace=wrap;html=1;fillColor=#F4A460;strokeColor=#8B5A2B;strokeWidth=2;" value="" vertex="1">
|
||||||
|
<mxGeometry height="40" width="60" x="350" y="480" as="geometry"/>
|
||||||
|
</mxCell>
|
||||||
|
|
||||||
|
|
||||||
|
<mxCell id="6" parent="1" style="ellipse;whiteSpace=wrap;html=1;fillColor=#F4A460;strokeColor=#8B5A2B;strokeWidth=2;" value="" vertex="1">
|
||||||
|
<mxGeometry height="40" width="60" x="430" y="480" as="geometry"/>
|
||||||
|
</mxCell>
|
||||||
|
|
||||||
|
|
||||||
|
<mxCell id="7" parent="1" style="triangle;direction=north;whiteSpace=wrap;html=1;fillColor=#F4A460;strokeColor=#8B5A2B;strokeWidth=2;rotation=-15;" value="" vertex="1">
|
||||||
|
<mxGeometry height="80" width="70" x="310" y="110" as="geometry"/>
|
||||||
|
</mxCell>
|
||||||
|
|
||||||
|
|
||||||
|
<mxCell id="8" parent="1" style="triangle;direction=north;whiteSpace=wrap;html=1;fillColor=#F4A460;strokeColor=#8B5A2B;strokeWidth=2;rotation=15;" value="" vertex="1">
|
||||||
|
<mxGeometry height="80" width="70" x="460" y="110" as="geometry"/>
|
||||||
|
</mxCell>
|
||||||
|
|
||||||
|
|
||||||
|
<mxCell id="9" parent="1" style="triangle;direction=north;whiteSpace=wrap;html=1;fillColor=#FFB6C1;strokeColor=none;rotation=-15;" value="" vertex="1">
|
||||||
|
<mxGeometry height="45" width="36" x="327" y="135" as="geometry"/>
|
||||||
|
</mxCell>
|
||||||
|
|
||||||
|
|
||||||
|
<mxCell id="10" parent="1" style="triangle;direction=north;whiteSpace=wrap;html=1;fillColor=#FFB6C1;strokeColor=none;rotation=15;" value="" vertex="1">
|
||||||
|
<mxGeometry height="45" width="36" x="477" y="135" as="geometry"/>
|
||||||
|
</mxCell>
|
||||||
|
|
||||||
|
|
||||||
|
<mxCell id="11" parent="1" style="ellipse;whiteSpace=wrap;html=1;fillColor=#F4A460;strokeColor=#8B5A2B;strokeWidth=2;" value="" vertex="1">
|
||||||
|
<mxGeometry height="200" width="240" x="300" y="140" as="geometry"/>
|
||||||
|
</mxCell>
|
||||||
|
|
||||||
|
|
||||||
|
<mxCell id="12" parent="1" style="ellipse;whiteSpace=wrap;html=1;fillColor=#333333;strokeColor=none;" value="" vertex="1">
|
||||||
|
<mxGeometry height="44" width="36" x="355" y="200" as="geometry"/>
|
||||||
|
</mxCell>
|
||||||
|
|
||||||
|
|
||||||
|
<mxCell id="13" parent="1" style="ellipse;whiteSpace=wrap;html=1;fillColor=#333333;strokeColor=none;" value="" vertex="1">
|
||||||
|
<mxGeometry height="44" width="36" x="449" y="200" as="geometry"/>
|
||||||
|
</mxCell>
|
||||||
|
|
||||||
|
|
||||||
|
<mxCell id="14" parent="1" style="ellipse;whiteSpace=wrap;html=1;fillColor=#FFFFFF;strokeColor=none;" value="" vertex="1">
|
||||||
|
<mxGeometry height="13" width="13" x="363" y="207" as="geometry"/>
|
||||||
|
</mxCell>
|
||||||
|
|
||||||
|
|
||||||
|
<mxCell id="15" parent="1" style="ellipse;whiteSpace=wrap;html=1;fillColor=#FFFFFF;strokeColor=none;" value="" vertex="1">
|
||||||
|
<mxGeometry height="13" width="13" x="457" y="207" as="geometry"/>
|
||||||
|
</mxCell>
|
||||||
|
|
||||||
|
|
||||||
|
<mxCell id="16" parent="1" style="ellipse;whiteSpace=wrap;html=1;fillColor=#FFB6C1;strokeColor=none;opacity=70;" value="" vertex="1">
|
||||||
|
<mxGeometry height="22" width="40" x="325" y="255" as="geometry"/>
|
||||||
|
</mxCell>
|
||||||
|
|
||||||
|
|
||||||
|
<mxCell id="17" parent="1" style="ellipse;whiteSpace=wrap;html=1;fillColor=#FFB6C1;strokeColor=none;opacity=70;" value="" vertex="1">
|
||||||
|
<mxGeometry height="22" width="40" x="475" y="255" as="geometry"/>
|
||||||
|
</mxCell>
|
||||||
|
|
||||||
|
|
||||||
|
<mxCell id="18" parent="1" style="triangle;direction=south;whiteSpace=wrap;html=1;fillColor=#FF69B4;strokeColor=none;" value="" vertex="1">
|
||||||
|
<mxGeometry height="16" width="24" x="408" y="252" as="geometry"/>
|
||||||
|
</mxCell>
|
||||||
|
|
||||||
|
|
||||||
|
<mxCell id="19" edge="1" parent="1" style="curved=1;endArrow=none;html=1;strokeWidth=2;strokeColor=#333333;" value="">
|
||||||
|
<mxGeometry relative="1" as="geometry">
|
||||||
<Array as="points">
|
<Array as="points">
|
||||||
<mxPoint x="365" y="230"/>
|
<mxPoint x="410" y="288"/>
|
||||||
</Array>
|
</Array>
|
||||||
|
<mxPoint x="400" y="278" as="sourcePoint"/>
|
||||||
|
<mxPoint x="420" y="270" as="targetPoint"/>
|
||||||
</mxGeometry>
|
</mxGeometry>
|
||||||
</mxCell>
|
</mxCell>
|
||||||
|
|
||||||
|
|
||||||
<mxCell id="12" value="" style="endArrow=none;html=1;strokeColor=#000000;strokeWidth=1.5;" edge="1" parent="1">
|
<mxCell id="20" edge="1" parent="1" style="curved=1;endArrow=none;html=1;strokeWidth=2;strokeColor=#333333;" value="">
|
||||||
<mxGeometry width="50" height="50" relative="1" as="geometry">
|
<mxGeometry relative="1" as="geometry">
|
||||||
<mxPoint x="310" y="200" as="sourcePoint"/>
|
|
||||||
<mxPoint x="260" y="195" as="targetPoint"/>
|
|
||||||
</mxGeometry>
|
|
||||||
</mxCell>
|
|
||||||
|
|
||||||
|
|
||||||
<mxCell id="13" value="" style="endArrow=none;html=1;strokeColor=#000000;strokeWidth=1.5;" edge="1" parent="1">
|
|
||||||
<mxGeometry width="50" height="50" relative="1" as="geometry">
|
|
||||||
<mxPoint x="310" y="210" as="sourcePoint"/>
|
|
||||||
<mxPoint x="260" y="210" as="targetPoint"/>
|
|
||||||
</mxGeometry>
|
|
||||||
</mxCell>
|
|
||||||
|
|
||||||
|
|
||||||
<mxCell id="14" value="" style="endArrow=none;html=1;strokeColor=#000000;strokeWidth=1.5;" edge="1" parent="1">
|
|
||||||
<mxGeometry width="50" height="50" relative="1" as="geometry">
|
|
||||||
<mxPoint x="310" y="220" as="sourcePoint"/>
|
|
||||||
<mxPoint x="260" y="225" as="targetPoint"/>
|
|
||||||
</mxGeometry>
|
|
||||||
</mxCell>
|
|
||||||
|
|
||||||
|
|
||||||
<mxCell id="15" value="" style="endArrow=none;html=1;strokeColor=#000000;strokeWidth=1.5;" edge="1" parent="1">
|
|
||||||
<mxGeometry width="50" height="50" relative="1" as="geometry">
|
|
||||||
<mxPoint x="410" y="200" as="sourcePoint"/>
|
|
||||||
<mxPoint x="460" y="195" as="targetPoint"/>
|
|
||||||
</mxGeometry>
|
|
||||||
</mxCell>
|
|
||||||
|
|
||||||
|
|
||||||
<mxCell id="16" value="" style="endArrow=none;html=1;strokeColor=#000000;strokeWidth=1.5;" edge="1" parent="1">
|
|
||||||
<mxGeometry width="50" height="50" relative="1" as="geometry">
|
|
||||||
<mxPoint x="410" y="210" as="sourcePoint"/>
|
|
||||||
<mxPoint x="460" y="210" as="targetPoint"/>
|
|
||||||
</mxGeometry>
|
|
||||||
</mxCell>
|
|
||||||
|
|
||||||
|
|
||||||
<mxCell id="17" value="" style="endArrow=none;html=1;strokeColor=#000000;strokeWidth=1.5;" edge="1" parent="1">
|
|
||||||
<mxGeometry width="50" height="50" relative="1" as="geometry">
|
|
||||||
<mxPoint x="410" y="220" as="sourcePoint"/>
|
|
||||||
<mxPoint x="460" y="225" as="targetPoint"/>
|
|
||||||
</mxGeometry>
|
|
||||||
</mxCell>
|
|
||||||
|
|
||||||
|
|
||||||
<mxCell id="18" value="" style="ellipse;whiteSpace=wrap;html=1;fillColor=#FFE6CC;strokeColor=#000000;strokeWidth=2;" vertex="1" parent="1">
|
|
||||||
<mxGeometry x="285" y="250" width="150" height="180" as="geometry"/>
|
|
||||||
</mxCell>
|
|
||||||
|
|
||||||
|
|
||||||
<mxCell id="19" value="" style="ellipse;whiteSpace=wrap;html=1;fillColor=#FFFFFF;strokeColor=none;" vertex="1" parent="1">
|
|
||||||
<mxGeometry x="315" y="280" width="90" height="120" as="geometry"/>
|
|
||||||
</mxCell>
|
|
||||||
|
|
||||||
|
|
||||||
<mxCell id="20" value="" style="ellipse;whiteSpace=wrap;html=1;fillColor=#FFE6CC;strokeColor=#000000;strokeWidth=2;" vertex="1" parent="1">
|
|
||||||
<mxGeometry x="300" y="410" width="40" height="50" as="geometry"/>
|
|
||||||
</mxCell>
|
|
||||||
|
|
||||||
|
|
||||||
<mxCell id="21" value="" style="ellipse;whiteSpace=wrap;html=1;fillColor=#FFE6CC;strokeColor=#000000;strokeWidth=2;" vertex="1" parent="1">
|
|
||||||
<mxGeometry x="380" y="410" width="40" height="50" as="geometry"/>
|
|
||||||
</mxCell>
|
|
||||||
|
|
||||||
|
|
||||||
<mxCell id="22" value="" style="curved=1;endArrow=none;html=1;strokeColor=#000000;strokeWidth=3;fillColor=#FFE6CC;" edge="1" parent="1">
|
|
||||||
<mxGeometry width="50" height="50" relative="1" as="geometry">
|
|
||||||
<mxPoint x="285" y="340" as="sourcePoint"/>
|
|
||||||
<mxPoint x="240" y="260" as="targetPoint"/>
|
|
||||||
<Array as="points">
|
<Array as="points">
|
||||||
<mxPoint x="260" y="350"/>
|
<mxPoint x="430" y="288"/>
|
||||||
<mxPoint x="240" y="320"/>
|
|
||||||
<mxPoint x="235" y="290"/>
|
|
||||||
</Array>
|
</Array>
|
||||||
|
<mxPoint x="420" y="270" as="sourcePoint"/>
|
||||||
|
<mxPoint x="440" y="278" as="targetPoint"/>
|
||||||
</mxGeometry>
|
</mxGeometry>
|
||||||
|
</mxCell>
|
||||||
|
|
||||||
|
|
||||||
|
<mxCell id="21" edge="1" parent="1" style="endArrow=none;html=1;strokeWidth=2;strokeColor=#5C3A1A;" value="">
|
||||||
|
<mxGeometry relative="1" as="geometry">
|
||||||
|
<mxPoint x="370" y="262" as="sourcePoint"/>
|
||||||
|
<mxPoint x="270" y="245" as="targetPoint"/>
|
||||||
|
</mxGeometry>
|
||||||
|
</mxCell>
|
||||||
|
|
||||||
|
|
||||||
|
<mxCell id="22" edge="1" parent="1" style="endArrow=none;html=1;strokeWidth=2;strokeColor=#5C3A1A;" value="">
|
||||||
|
<mxGeometry relative="1" as="geometry">
|
||||||
|
<mxPoint x="370" y="272" as="sourcePoint"/>
|
||||||
|
<mxPoint x="268" y="278" as="targetPoint"/>
|
||||||
|
</mxGeometry>
|
||||||
|
</mxCell>
|
||||||
|
|
||||||
|
|
||||||
|
<mxCell id="23" edge="1" parent="1" style="endArrow=none;html=1;strokeWidth=2;strokeColor=#5C3A1A;" value="">
|
||||||
|
<mxGeometry relative="1" as="geometry">
|
||||||
|
<mxPoint x="470" y="262" as="sourcePoint"/>
|
||||||
|
<mxPoint x="570" y="245" as="targetPoint"/>
|
||||||
|
</mxGeometry>
|
||||||
|
</mxCell>
|
||||||
|
|
||||||
|
|
||||||
|
<mxCell id="24" edge="1" parent="1" style="endArrow=none;html=1;strokeWidth=2;strokeColor=#5C3A1A;" value="">
|
||||||
|
<mxGeometry relative="1" as="geometry">
|
||||||
|
<mxPoint x="470" y="272" as="sourcePoint"/>
|
||||||
|
<mxPoint x="572" y="278" as="targetPoint"/>
|
||||||
|
</mxGeometry>
|
||||||
|
</mxCell>
|
||||||
|
|
||||||
|
|
||||||
|
<mxCell id="25" parent="1" style="text;html=1;align=center;verticalAlign=middle;fontSize=22;fontStyle=1;fontColor=#FF69B4;" value="Meow~ ♥" vertex="1">
|
||||||
|
<mxGeometry height="40" width="120" x="530" y="120" as="geometry"/>
|
||||||
</mxCell>`,
|
</mxCell>`,
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
|
|||||||
+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
|
||||||
|
|||||||
@@ -118,7 +118,7 @@
|
|||||||
"customSystemMessage": "Custom instructions",
|
"customSystemMessage": "Custom instructions",
|
||||||
"customSystemMessageDescription": "Add custom instructions appended to the AI's system prompt.",
|
"customSystemMessageDescription": "Add custom instructions appended to the AI's system prompt.",
|
||||||
"customSystemMessagePlaceholder": "e.g., Always use blue color scheme for diagrams...",
|
"customSystemMessagePlaceholder": "e.g., Always use blue color scheme for diagrams...",
|
||||||
"maxOutputTokens": "Max output tokens",
|
"maxOutputTokens": "Output token limit",
|
||||||
"maxOutputTokensDescription": "Budget for one reply, shared by thinking and the diagram XML. Raise it if the AI keeps thinking and no diagram appears. Leave empty for the default.",
|
"maxOutputTokensDescription": "Budget for one reply, shared by thinking and the diagram XML. Raise it if the AI keeps thinking and no diagram appears. Leave empty for the default.",
|
||||||
"tabModels": "Models",
|
"tabModels": "Models",
|
||||||
"tabAbout": "About",
|
"tabAbout": "About",
|
||||||
@@ -482,46 +482,46 @@
|
|||||||
},
|
},
|
||||||
"admin": {
|
"admin": {
|
||||||
"title": "Admin Settings",
|
"title": "Admin Settings",
|
||||||
"loginPrompt": "Enter the admin password (the ADMIN_PASSWORD environment variable) to manage server settings.",
|
"loginPrompt": "Enter the admin password (the ADMIN_PASSWORD environment variable)",
|
||||||
"password": "Password",
|
"password": "Password",
|
||||||
"signIn": "Sign In",
|
"signIn": "Sign In",
|
||||||
"signingIn": "Signing In…",
|
"signingIn": "Signing in…",
|
||||||
"loginFailed": "Login failed",
|
"loginFailed": "Login failed",
|
||||||
"precedence": "File overrides env · env overrides defaults",
|
"precedence": "Priority: this page > environment variables > defaults",
|
||||||
"notWritable": "The settings file is not writable on this deployment (serverless platforms have no persistent disk). Settings are shown read-only — configure via environment variables instead.",
|
"notWritable": "Settings can't be saved on this deployment, only viewed; use environment variables instead",
|
||||||
"settingGroups": "Setting groups",
|
"settingGroups": "Setting groups",
|
||||||
"enabled": "Enabled",
|
"enabled": "On",
|
||||||
"disabled": "Disabled",
|
"disabled": "Off",
|
||||||
"enableGroup": "Enable {group}",
|
"enableGroup": "Turn on {group}",
|
||||||
"unsavedChanges": "Unsaved changes",
|
"unsavedChanges": "Unsaved changes",
|
||||||
"saved": "Settings saved. Changes apply immediately.",
|
"saved": "Saved. Changes apply right away, except items marked \"Needs restart\"",
|
||||||
"saveFailed": "Save failed. Check your connection and try again.",
|
"saveFailed": "Save failed. Check your connection and try again",
|
||||||
"invalidSettings": "Some settings are invalid.",
|
"invalidSettings": "Some settings are invalid; see the notes under them",
|
||||||
"discard": "Discard",
|
"discard": "Discard changes",
|
||||||
"saveChanges": "Save Changes",
|
"saveChanges": "Save changes",
|
||||||
"saving": "Saving…",
|
"saving": "Saving…",
|
||||||
"sourceSaved": "Saved",
|
"sourceSaved": "This page",
|
||||||
"sourceEnv": "Env",
|
"sourceEnv": "Env",
|
||||||
"sourceSavedTitle": "Set in the admin settings file",
|
"sourceSavedTitle": "Set on this page",
|
||||||
"sourceEnvTitle": "Set by an environment variable",
|
"sourceEnvTitle": "From an environment variable",
|
||||||
"restartRequired": "Restart Required",
|
"restartRequired": "Needs restart",
|
||||||
"modified": "Modified",
|
"modified": "Modified",
|
||||||
"notSet": "Not set",
|
"notSet": "Not set",
|
||||||
"savedReplace": "Saved ({hint}) — type to replace",
|
"savedReplace": "Saved ({hint}); type a new value to replace it",
|
||||||
"showValue": "Show value",
|
"showValue": "Show",
|
||||||
"hideValue": "Hide value",
|
"hideValue": "Hide",
|
||||||
"removeValue": "Remove value",
|
"removeValue": "Clear",
|
||||||
"removeValueTitle": "Remove the stored value",
|
"removeValueTitle": "Clear the saved value",
|
||||||
"resetToDefault": "Reset to default",
|
"resetToDefault": "Reset to default",
|
||||||
"models": "Models",
|
"models": "Models",
|
||||||
"modelsDescription": "Server-side providers and models available to all users — no personal API key needed. The default provider's first model is used when users don't pick one.",
|
"modelsDescription": "Models every user can use, no personal API key needed",
|
||||||
"addProviderHint": "Add a provider to offer server-side models to all users.",
|
"addProviderHint": "Add a provider to offer models to all users",
|
||||||
"selectProviderHint": "Select or add a provider to configure its credentials and models.",
|
"selectProviderHint": "Select or add a provider, then enter its key and models",
|
||||||
"addProviderToOfferModels": "Add at least one model to expose this provider to users.",
|
"addProviderToOfferModels": "Add at least one model before users can see this provider",
|
||||||
"managedViaEnv": "(managed via env)",
|
"managedViaEnv": "(environment variables)",
|
||||||
"envReadOnly": "Defined in AI_MODELS_CONFIG / ai-models.json — read-only here. Edit the environment configuration to change it.",
|
"envReadOnly": "Defined in AI_MODELS_CONFIG / ai-models.json; view only here, edit it there",
|
||||||
"defaultModel": "Default Model",
|
"defaultModel": "Default Model",
|
||||||
"noModelsConfigured": "No models configured",
|
"noModelsConfigured": "No models yet",
|
||||||
"modelCount": "{count} model",
|
"modelCount": "{count} model",
|
||||||
"modelCountPlural": "{count} models",
|
"modelCountPlural": "{count} models",
|
||||||
"default": "Default",
|
"default": "Default",
|
||||||
@@ -531,65 +531,65 @@
|
|||||||
"addModel": "Add model",
|
"addModel": "Add model",
|
||||||
"suggested": "Suggested",
|
"suggested": "Suggested",
|
||||||
"test": "Test",
|
"test": "Test",
|
||||||
"testOk": "OK ({ms}ms)",
|
"testOk": "Passed ({ms} ms)",
|
||||||
"testFailed": "Failed",
|
"testFailed": "Failed",
|
||||||
"removeModel": "Remove {model}",
|
"removeModel": "Delete {model}",
|
||||||
"deleteProviderTitle": "Delete {name}?",
|
"deleteProviderTitle": "Delete {name}?",
|
||||||
"deleteProviderDesc": "Its credentials and models will be removed from the server after you save.",
|
"deleteProviderDesc": "After you save, its key and models are deleted from the server",
|
||||||
"cancel": "Cancel",
|
"cancel": "Cancel",
|
||||||
"delete": "Delete",
|
"delete": "Delete",
|
||||||
"groups": {
|
"groups": {
|
||||||
"generation": {
|
"generation": {
|
||||||
"title": "Generation",
|
"title": "Replies",
|
||||||
"description": "Output parameters applied to all chat requests."
|
"description": "How the AI replies, for every conversation"
|
||||||
},
|
},
|
||||||
"access": {
|
"access": {
|
||||||
"title": "Access Control",
|
"title": "Access",
|
||||||
"description": "Restrict who can use this deployment."
|
"description": "Limit who can use this site"
|
||||||
},
|
},
|
||||||
"features": {
|
"features": {
|
||||||
"title": "Features",
|
"title": "Optional Features",
|
||||||
"description": "Optional features and security toggles."
|
"description": "Extra features and safety options"
|
||||||
},
|
},
|
||||||
"observability": {
|
"observability": {
|
||||||
"title": "Observability",
|
"title": "Call Logs",
|
||||||
"description": "Langfuse tracing for LLM calls."
|
"description": "Record model calls with Langfuse"
|
||||||
},
|
},
|
||||||
"quota": {
|
"quota": {
|
||||||
"title": "Quota & Rate Limits",
|
"title": "Usage Limits",
|
||||||
"description": "Per-IP usage limits. Enforcement requires a DynamoDB table."
|
"description": "Per-IP limits; they take effect only with a DynamoDB table"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"settings": {
|
"settings": {
|
||||||
"TEMPERATURE": {
|
"TEMPERATURE": {
|
||||||
"label": "Temperature",
|
"label": "Temperature",
|
||||||
"description": "Leave unset for reasoning models that reject temperature."
|
"description": "Higher is more random; leave empty for reasoning models that don't support it"
|
||||||
},
|
},
|
||||||
"MAX_OUTPUT_TOKENS": {
|
"MAX_OUTPUT_TOKENS": {
|
||||||
"label": "Max Output Tokens"
|
"label": "Output Token Limit"
|
||||||
},
|
},
|
||||||
"ACCESS_CODE_LIST": {
|
"ACCESS_CODE_LIST": {
|
||||||
"label": "Access Codes",
|
"label": "Access Codes",
|
||||||
"description": "Comma-separated list. Users must enter one to chat. Empty = open access."
|
"description": "Separate several with commas; users must enter one to chat; empty means no limit"
|
||||||
},
|
},
|
||||||
"ENABLE_VLM_VALIDATION": {
|
"ENABLE_VLM_VALIDATION": {
|
||||||
"label": "VLM Diagram Validation",
|
"label": "Diagram Check",
|
||||||
"description": "Visually validate generated diagrams with a vision model."
|
"description": "After drawing, a model that can see images checks a screenshot"
|
||||||
},
|
},
|
||||||
"VALIDATION_MODEL": {
|
"VALIDATION_MODEL": {
|
||||||
"label": "Validation Model",
|
"label": "Check Model",
|
||||||
"description": "Falls back to the default AI model when empty."
|
"description": "Empty means the default model"
|
||||||
},
|
},
|
||||||
"VALIDATION_TIMEOUT": {
|
"VALIDATION_TIMEOUT": {
|
||||||
"label": "Validation Timeout (ms)"
|
"label": "Check Timeout (ms)"
|
||||||
},
|
},
|
||||||
"ENABLE_HISTORY_XML_REPLACE": {
|
"ENABLE_HISTORY_XML_REPLACE": {
|
||||||
"label": "History XML Compression",
|
"label": "Trim Old Diagrams",
|
||||||
"description": "Replace old diagram XML in history with placeholders."
|
"description": "Old diagrams in the chat history are kept as a short marker"
|
||||||
},
|
},
|
||||||
"ALLOW_PRIVATE_URLS": {
|
"ALLOW_PRIVATE_URLS": {
|
||||||
"label": "Allow Private URLs",
|
"label": "Allow Private URLs",
|
||||||
"description": "Turn off to block requests to private IPs and internal hostnames (SSRF protection)."
|
"description": "When off, custom model URLs can't point to private networks; safer"
|
||||||
},
|
},
|
||||||
"LANGFUSE_PUBLIC_KEY": {
|
"LANGFUSE_PUBLIC_KEY": {
|
||||||
"label": "Langfuse Public Key"
|
"label": "Langfuse Public Key"
|
||||||
@@ -598,29 +598,29 @@
|
|||||||
"label": "Langfuse Secret Key"
|
"label": "Langfuse Secret Key"
|
||||||
},
|
},
|
||||||
"LANGFUSE_BASEURL": {
|
"LANGFUSE_BASEURL": {
|
||||||
"label": "Langfuse Base URL"
|
"label": "Langfuse URL"
|
||||||
},
|
},
|
||||||
"DAILY_REQUEST_LIMIT": {
|
"DAILY_REQUEST_LIMIT": {
|
||||||
"label": "Daily Request Limit",
|
"label": "Daily Requests",
|
||||||
"description": "Per IP per day."
|
"description": "Requests per IP per day"
|
||||||
},
|
},
|
||||||
"DAILY_TOKEN_LIMIT": {
|
"DAILY_TOKEN_LIMIT": {
|
||||||
"label": "Daily Token Limit",
|
"label": "Daily Tokens",
|
||||||
"description": "Per IP per day."
|
"description": "Tokens per IP per day"
|
||||||
},
|
},
|
||||||
"TPM_LIMIT": {
|
"TPM_LIMIT": {
|
||||||
"label": "Tokens Per Minute"
|
"label": "Tokens Per Minute"
|
||||||
},
|
},
|
||||||
"DYNAMODB_QUOTA_TABLE": {
|
"DYNAMODB_QUOTA_TABLE": {
|
||||||
"label": "DynamoDB Table",
|
"label": "DynamoDB Table Name",
|
||||||
"description": "Quota enforcement is disabled when empty."
|
"description": "Empty means no usage limits"
|
||||||
},
|
},
|
||||||
"DYNAMODB_REGION": {
|
"DYNAMODB_REGION": {
|
||||||
"label": "DynamoDB Region"
|
"label": "DynamoDB Region"
|
||||||
},
|
},
|
||||||
"QUOTA_TIMEZONE": {
|
"QUOTA_TIMEZONE": {
|
||||||
"label": "Quota Timezone",
|
"label": "Time Zone",
|
||||||
"description": "Timezone for the daily reset boundary."
|
"description": "Daily usage resets at midnight in this time zone"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -118,7 +118,7 @@
|
|||||||
"customSystemMessage": "カスタム指示",
|
"customSystemMessage": "カスタム指示",
|
||||||
"customSystemMessageDescription": "AIのシステムプロンプトに追加されるカスタム指示を入力します。",
|
"customSystemMessageDescription": "AIのシステムプロンプトに追加されるカスタム指示を入力します。",
|
||||||
"customSystemMessagePlaceholder": "例:ダイアグラムには常に青色のカラースキームを使用...",
|
"customSystemMessagePlaceholder": "例:ダイアグラムには常に青色のカラースキームを使用...",
|
||||||
"maxOutputTokens": "最大出力トークン数",
|
"maxOutputTokens": "出力トークン上限",
|
||||||
"maxOutputTokensDescription": "1回の応答の予算で、思考過程とダイアグラムの XML が共有します。AI が考え続けてダイアグラムが生成されない場合は大きくしてください。空欄ならデフォルト値を使います。",
|
"maxOutputTokensDescription": "1回の応答の予算で、思考過程とダイアグラムの XML が共有します。AI が考え続けてダイアグラムが生成されない場合は大きくしてください。空欄ならデフォルト値を使います。",
|
||||||
"tabModels": "モデル",
|
"tabModels": "モデル",
|
||||||
"tabAbout": "このアプリについて",
|
"tabAbout": "このアプリについて",
|
||||||
@@ -482,114 +482,114 @@
|
|||||||
},
|
},
|
||||||
"admin": {
|
"admin": {
|
||||||
"title": "管理者設定",
|
"title": "管理者設定",
|
||||||
"loginPrompt": "サーバー設定を管理するには、管理者パスワード(ADMIN_PASSWORD 環境変数)を入力してください。",
|
"loginPrompt": "管理者パスワード(環境変数 ADMIN_PASSWORD)を入力してください",
|
||||||
"password": "パスワード",
|
"password": "パスワード",
|
||||||
"signIn": "ログイン",
|
"signIn": "ログイン",
|
||||||
"signingIn": "ログイン中…",
|
"signingIn": "ログイン中…",
|
||||||
"loginFailed": "ログインに失敗しました",
|
"loginFailed": "ログインに失敗しました",
|
||||||
"precedence": "ファイルが環境変数を上書き · 環境変数がデフォルトを上書き",
|
"precedence": "優先順位:このページ > 環境変数 > 既定値",
|
||||||
"notWritable": "このデプロイ環境では設定ファイルに書き込めません(サーバーレス環境には永続ディスクがありません)。設定は読み取り専用で表示されます——代わりに環境変数で構成してください。",
|
"notWritable": "この環境では設定を保存できず、表示のみです。環境変数で設定してください",
|
||||||
"settingGroups": "設定グループ",
|
"settingGroups": "設定の分類",
|
||||||
"enabled": "有効",
|
"enabled": "オン",
|
||||||
"disabled": "無効",
|
"disabled": "オフ",
|
||||||
"enableGroup": "{group} を有効化",
|
"enableGroup": "{group} をオンにする",
|
||||||
"unsavedChanges": "未保存の変更があります",
|
"unsavedChanges": "未保存の変更があります",
|
||||||
"saved": "設定を保存しました。変更は即座に反映されます。",
|
"saved": "保存しました。「再起動が必要」の項目以外はすぐに反映されます",
|
||||||
"saveFailed": "保存に失敗しました。接続を確認して再試行してください。",
|
"saveFailed": "保存に失敗しました。接続を確認してもう一度お試しください",
|
||||||
"invalidSettings": "一部の設定が無効です。",
|
"invalidSettings": "入力に誤りがある設定があります。項目の下の説明を確認してください",
|
||||||
"discard": "破棄",
|
"discard": "変更を破棄",
|
||||||
"saveChanges": "変更を保存",
|
"saveChanges": "変更を保存",
|
||||||
"saving": "保存中…",
|
"saving": "保存中…",
|
||||||
"sourceSaved": "保存済み",
|
"sourceSaved": "このページ",
|
||||||
"sourceEnv": "環境変数",
|
"sourceEnv": "環境変数",
|
||||||
"sourceSavedTitle": "管理者設定ファイルで設定",
|
"sourceSavedTitle": "このページで設定した値",
|
||||||
"sourceEnvTitle": "環境変数で設定",
|
"sourceEnvTitle": "環境変数による設定",
|
||||||
"restartRequired": "再起動が必要",
|
"restartRequired": "再起動が必要",
|
||||||
"modified": "変更済み",
|
"modified": "変更済み",
|
||||||
"notSet": "未設定",
|
"notSet": "未設定",
|
||||||
"savedReplace": "保存済み({hint})——入力して置き換え",
|
"savedReplace": "保存済み({hint})。新しい値を入力すると置き換わります",
|
||||||
"showValue": "値を表示",
|
"showValue": "表示",
|
||||||
"hideValue": "値を非表示",
|
"hideValue": "非表示",
|
||||||
"removeValue": "値を削除",
|
"removeValue": "クリア",
|
||||||
"removeValueTitle": "保存された値を削除",
|
"removeValueTitle": "保存した値をクリア",
|
||||||
"resetToDefault": "デフォルトに戻す",
|
"resetToDefault": "デフォルトに戻す",
|
||||||
"models": "モデル",
|
"models": "モデル",
|
||||||
"modelsDescription": "全ユーザーが利用できるサーバー側のプロバイダーとモデル——個人の API キーは不要です。ユーザーがモデルを選択しない場合、デフォルトプロバイダーの最初のモデルが使用されます。",
|
"modelsDescription": "全ユーザーが使えるモデル。個人の API キーは不要です",
|
||||||
"addProviderHint": "プロバイダーを追加して、全ユーザーにサーバー側モデルを提供します。",
|
"addProviderHint": "プロバイダーを追加して、全ユーザーにモデルを提供します",
|
||||||
"selectProviderHint": "プロバイダーを選択または追加して、その資格情報とモデルを構成します。",
|
"selectProviderHint": "プロバイダーを選択または追加し、キーとモデルを入力します",
|
||||||
"addProviderToOfferModels": "ユーザーにこのプロバイダーを公開するには、モデルを少なくとも 1 つ追加してください。",
|
"addProviderToOfferModels": "モデルを 1 つ以上追加すると、ユーザーにこのプロバイダーが表示されます",
|
||||||
"managedViaEnv": "(環境変数で管理)",
|
"managedViaEnv": "(環境変数)",
|
||||||
"envReadOnly": "AI_MODELS_CONFIG / ai-models.json で定義——ここでは読み取り専用です。変更するには環境構成を編集してください。",
|
"envReadOnly": "AI_MODELS_CONFIG / ai-models.json で定義されています。ここでは表示のみで、変更はそちらで行ってください",
|
||||||
"defaultModel": "デフォルトモデル",
|
"defaultModel": "デフォルトモデル",
|
||||||
"noModelsConfigured": "モデルが構成されていません",
|
"noModelsConfigured": "モデルはまだありません",
|
||||||
"modelCount": "{count} 個のモデル",
|
"modelCount": "{count} 個のモデル",
|
||||||
"modelCountPlural": "{count} 個のモデル",
|
"modelCountPlural": "{count} 個のモデル",
|
||||||
"default": "デフォルト",
|
"default": "デフォルト",
|
||||||
"setAsDefault": "デフォルトプロバイダーに設定",
|
"setAsDefault": "既定のプロバイダーにする",
|
||||||
"defaultProvider": "デフォルトプロバイダー",
|
"defaultProvider": "既定のプロバイダー",
|
||||||
"modelIdPlaceholder": "モデル ID…",
|
"modelIdPlaceholder": "モデル ID…",
|
||||||
"addModel": "モデルを追加",
|
"addModel": "モデルを追加",
|
||||||
"suggested": "おすすめ",
|
"suggested": "おすすめ",
|
||||||
"test": "テスト",
|
"test": "テスト",
|
||||||
"testOk": "正常({ms}ms)",
|
"testOk": "成功({ms} ms)",
|
||||||
"testFailed": "失敗",
|
"testFailed": "失敗",
|
||||||
"removeModel": "{model} を削除",
|
"removeModel": "{model} を削除",
|
||||||
"deleteProviderTitle": "{name} を削除しますか?",
|
"deleteProviderTitle": "{name} を削除しますか?",
|
||||||
"deleteProviderDesc": "保存後、その資格情報とモデルはサーバーから削除されます。",
|
"deleteProviderDesc": "保存すると、このプロバイダーのキーとモデルがサーバーから削除されます",
|
||||||
"cancel": "キャンセル",
|
"cancel": "キャンセル",
|
||||||
"delete": "削除",
|
"delete": "削除",
|
||||||
"groups": {
|
"groups": {
|
||||||
"generation": {
|
"generation": {
|
||||||
"title": "生成",
|
"title": "応答の設定",
|
||||||
"description": "すべてのチャットリクエストに適用される出力パラメーター。"
|
"description": "AI の応答のしかたを決めます。すべての会話に適用されます"
|
||||||
},
|
},
|
||||||
"access": {
|
"access": {
|
||||||
"title": "アクセス制御",
|
"title": "アクセス権",
|
||||||
"description": "このデプロイを使用できるユーザーを制限します。"
|
"description": "このサイトを使える人を制限します"
|
||||||
},
|
},
|
||||||
"features": {
|
"features": {
|
||||||
"title": "機能",
|
"title": "オプション機能",
|
||||||
"description": "オプション機能とセキュリティの切り替え。"
|
"description": "追加機能と安全のための設定"
|
||||||
},
|
},
|
||||||
"observability": {
|
"observability": {
|
||||||
"title": "オブザーバビリティ",
|
"title": "呼び出し記録",
|
||||||
"description": "LLM 呼び出しの Langfuse トレース。"
|
"description": "モデルの呼び出しを Langfuse に記録します"
|
||||||
},
|
},
|
||||||
"quota": {
|
"quota": {
|
||||||
"title": "クォータとレート制限",
|
"title": "使用量の制限",
|
||||||
"description": "IP ごとの使用制限。強制には DynamoDB テーブルが必要です。"
|
"description": "IP ごとの制限。DynamoDB テーブルがあるときだけ有効です"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"settings": {
|
"settings": {
|
||||||
"TEMPERATURE": {
|
"TEMPERATURE": {
|
||||||
"label": "温度",
|
"label": "温度",
|
||||||
"description": "温度を受け付けない推論モデルの場合は未設定のままにしてください。"
|
"description": "高いほどランダムになります。温度に対応しない推論モデルでは空にしてください"
|
||||||
},
|
},
|
||||||
"MAX_OUTPUT_TOKENS": {
|
"MAX_OUTPUT_TOKENS": {
|
||||||
"label": "最大出力トークン数"
|
"label": "出力トークン上限"
|
||||||
},
|
},
|
||||||
"ACCESS_CODE_LIST": {
|
"ACCESS_CODE_LIST": {
|
||||||
"label": "アクセスコード",
|
"label": "アクセスコード",
|
||||||
"description": "カンマ区切りのリスト。チャットにはいずれかの入力が必要です。空 = オープンアクセス。"
|
"description": "複数はカンマ区切り。いずれかを入力しないと使えません。空なら制限なし"
|
||||||
},
|
},
|
||||||
"ENABLE_VLM_VALIDATION": {
|
"ENABLE_VLM_VALIDATION": {
|
||||||
"label": "VLM 図検証",
|
"label": "図のチェック",
|
||||||
"description": "ビジョンモデルで生成された図を視覚的に検証します。"
|
"description": "描画後、画像を読めるモデルがスクリーンショットをチェックします"
|
||||||
},
|
},
|
||||||
"VALIDATION_MODEL": {
|
"VALIDATION_MODEL": {
|
||||||
"label": "検証モデル",
|
"label": "チェック用モデル",
|
||||||
"description": "空の場合はデフォルトの AI モデルにフォールバックします。"
|
"description": "空なら既定のモデルを使います"
|
||||||
},
|
},
|
||||||
"VALIDATION_TIMEOUT": {
|
"VALIDATION_TIMEOUT": {
|
||||||
"label": "検証タイムアウト(ms)"
|
"label": "チェックのタイムアウト(ms)"
|
||||||
},
|
},
|
||||||
"ENABLE_HISTORY_XML_REPLACE": {
|
"ENABLE_HISTORY_XML_REPLACE": {
|
||||||
"label": "履歴 XML 圧縮",
|
"label": "古い図を省略",
|
||||||
"description": "履歴内の古い図 XML をプレースホルダーで置き換えます。"
|
"description": "会話履歴の古い図は短い印だけ残します"
|
||||||
},
|
},
|
||||||
"ALLOW_PRIVATE_URLS": {
|
"ALLOW_PRIVATE_URLS": {
|
||||||
"label": "プライベート URL を許可",
|
"label": "内部ネットワークへのアクセス",
|
||||||
"description": "オフにすると、プライベート IP や内部ホスト名へのリクエストをブロックします(SSRF 保護)。"
|
"description": "オフにすると、ユーザーが指定するモデルの URL に内部アドレスを使えなくなり、安全です"
|
||||||
},
|
},
|
||||||
"LANGFUSE_PUBLIC_KEY": {
|
"LANGFUSE_PUBLIC_KEY": {
|
||||||
"label": "Langfuse Public Key"
|
"label": "Langfuse Public Key"
|
||||||
@@ -598,29 +598,29 @@
|
|||||||
"label": "Langfuse Secret Key"
|
"label": "Langfuse Secret Key"
|
||||||
},
|
},
|
||||||
"LANGFUSE_BASEURL": {
|
"LANGFUSE_BASEURL": {
|
||||||
"label": "Langfuse Base URL"
|
"label": "Langfuse の URL"
|
||||||
},
|
},
|
||||||
"DAILY_REQUEST_LIMIT": {
|
"DAILY_REQUEST_LIMIT": {
|
||||||
"label": "1 日あたりのリクエスト上限",
|
"label": "1 日のリクエスト数",
|
||||||
"description": "IP ごと 1 日あたり。"
|
"description": "IP ごとの 1 日あたりのリクエスト数の上限"
|
||||||
},
|
},
|
||||||
"DAILY_TOKEN_LIMIT": {
|
"DAILY_TOKEN_LIMIT": {
|
||||||
"label": "1 日あたりのトークン上限",
|
"label": "1 日のトークン数",
|
||||||
"description": "IP ごと 1 日あたり。"
|
"description": "IP ごとの 1 日あたりのトークン使用量の上限"
|
||||||
},
|
},
|
||||||
"TPM_LIMIT": {
|
"TPM_LIMIT": {
|
||||||
"label": "1 分あたりのトークン数"
|
"label": "1 分あたりのトークン数"
|
||||||
},
|
},
|
||||||
"DYNAMODB_QUOTA_TABLE": {
|
"DYNAMODB_QUOTA_TABLE": {
|
||||||
"label": "DynamoDB テーブル",
|
"label": "DynamoDB テーブル名",
|
||||||
"description": "空の場合、クォータの強制は無効になります。"
|
"description": "空なら使用量を制限しません"
|
||||||
},
|
},
|
||||||
"DYNAMODB_REGION": {
|
"DYNAMODB_REGION": {
|
||||||
"label": "DynamoDB リージョン"
|
"label": "DynamoDB リージョン"
|
||||||
},
|
},
|
||||||
"QUOTA_TIMEZONE": {
|
"QUOTA_TIMEZONE": {
|
||||||
"label": "クォータタイムゾーン",
|
"label": "タイムゾーン",
|
||||||
"description": "1 日のリセット境界に使用するタイムゾーン。"
|
"description": "1 日の使用量はこのタイムゾーンの 0 時にリセットされます"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -82,9 +82,9 @@
|
|||||||
"accessCode": "存取碼",
|
"accessCode": "存取碼",
|
||||||
"accessCodePlaceholder": "輸入存取碼",
|
"accessCodePlaceholder": "輸入存取碼",
|
||||||
"accessCodeDescription": "使用此應用程式需要存取碼。",
|
"accessCodeDescription": "使用此應用程式需要存取碼。",
|
||||||
"aiProvider": "AI 提供商設定",
|
"aiProvider": "AI 服務商設定",
|
||||||
"aiProviderDescription": "使用您自己的 API 金鑰來繞過使用限制。您的金鑰僅儲存在瀏覽器本機,不會儲存在伺服器上。",
|
"aiProviderDescription": "使用您自己的 API 金鑰來繞過使用限制。您的金鑰僅儲存在瀏覽器本機,不會儲存在伺服器上。",
|
||||||
"provider": "提供商",
|
"provider": "服務商",
|
||||||
"modelId": "模型 ID",
|
"modelId": "模型 ID",
|
||||||
"apiKey": "API 金鑰",
|
"apiKey": "API 金鑰",
|
||||||
"apiKeyPlaceholder": "您的 API 金鑰",
|
"apiKeyPlaceholder": "您的 API 金鑰",
|
||||||
@@ -118,7 +118,7 @@
|
|||||||
"customSystemMessage": "自訂指令",
|
"customSystemMessage": "自訂指令",
|
||||||
"customSystemMessageDescription": "新增自訂指示,將附加到 AI 的系統提示末尾。",
|
"customSystemMessageDescription": "新增自訂指示,將附加到 AI 的系統提示末尾。",
|
||||||
"customSystemMessagePlaceholder": "例如:圖表始終使用藍色配色方案...",
|
"customSystemMessagePlaceholder": "例如:圖表始終使用藍色配色方案...",
|
||||||
"maxOutputTokens": "最大輸出 token 數",
|
"maxOutputTokens": "輸出 token 上限",
|
||||||
"maxOutputTokensDescription": "單次回覆的額度,思考過程與圖表 XML 共用。若 AI 一直在思考卻沒有產生圖表,請將它調大。留空則使用預設值。",
|
"maxOutputTokensDescription": "單次回覆的額度,思考過程與圖表 XML 共用。若 AI 一直在思考卻沒有產生圖表,請將它調大。留空則使用預設值。",
|
||||||
"tabModels": "模型",
|
"tabModels": "模型",
|
||||||
"tabAbout": "關於",
|
"tabAbout": "關於",
|
||||||
@@ -207,10 +207,10 @@
|
|||||||
},
|
},
|
||||||
"quota": {
|
"quota": {
|
||||||
"dailyLimit": "已達每日配額",
|
"dailyLimit": "已達每日配額",
|
||||||
"tokenLimit": "已達每日令牌限制",
|
"tokenLimit": "已達每日 token 上限",
|
||||||
"tpmLimit": "速率限制",
|
"tpmLimit": "速率限制",
|
||||||
"tpmMessage": "請求過多。請稍等片刻。",
|
"tpmMessage": "請求過多。請稍等片刻。",
|
||||||
"tpmMessageDetailed": "達到速率限制({limit} 令牌/分鐘)。請等待 {seconds} 秒後再傳送請求。",
|
"tpmMessageDetailed": "達到速率限制(每分鐘 {limit} token)。請等待 {seconds} 秒後再傳送請求。",
|
||||||
"messageApi": "看來您今天的體驗次數已達上限。很高興您玩得開心;為了讓大家都能公平使用,示範站對用量做了限制。",
|
"messageApi": "看來您今天的體驗次數已達上限。很高興您玩得開心;為了讓大家都能公平使用,示範站對用量做了限制。",
|
||||||
"messageApiSelfHosted": null,
|
"messageApiSelfHosted": null,
|
||||||
"messageToken": "看來您今天的 token 用量已達上限。很高興您玩得開心;為了讓大家都能公平使用,示範站對用量做了限制。",
|
"messageToken": "看來您今天的 token 用量已達上限。很高興您玩得開心;為了讓大家都能公平使用,示範站對用量做了限制。",
|
||||||
@@ -481,146 +481,146 @@
|
|||||||
"removeAccessKeys": "刪除存取金鑰"
|
"removeAccessKeys": "刪除存取金鑰"
|
||||||
},
|
},
|
||||||
"admin": {
|
"admin": {
|
||||||
"title": "管理員設定",
|
"title": "後台設定",
|
||||||
"loginPrompt": "輸入管理員密碼(即 ADMIN_PASSWORD 環境變數)以管理伺服器設定。",
|
"loginPrompt": "輸入管理員密碼(環境變數 ADMIN_PASSWORD)",
|
||||||
"password": "密碼",
|
"password": "密碼",
|
||||||
"signIn": "登入",
|
"signIn": "登入",
|
||||||
"signingIn": "正在登入…",
|
"signingIn": "登入中…",
|
||||||
"loginFailed": "登入失敗",
|
"loginFailed": "登入失敗",
|
||||||
"precedence": "檔案覆蓋環境變數 · 環境變數覆蓋預設值",
|
"precedence": "優先順序:本頁設定 > 環境變數 > 預設值",
|
||||||
"notWritable": "此部署環境下設定檔不可寫入(無伺服器平台沒有持久化磁碟)。設定以唯讀方式顯示——請改用環境變數進行設定。",
|
"notWritable": "這裡無法儲存設定,只能檢視;請改用環境變數",
|
||||||
"settingGroups": "設定分組",
|
"settingGroups": "設定分類",
|
||||||
"enabled": "已啟用",
|
"enabled": "已開啟",
|
||||||
"disabled": "已停用",
|
"disabled": "已關閉",
|
||||||
"enableGroup": "啟用 {group}",
|
"enableGroup": "開啟{group}",
|
||||||
"unsavedChanges": "有未儲存的變更",
|
"unsavedChanges": "有未儲存的修改",
|
||||||
"saved": "設定已儲存,變更立即生效。",
|
"saved": "已儲存,立即生效(需重啟的項目除外)",
|
||||||
"saveFailed": "儲存失敗。請檢查網路連線後重試。",
|
"saveFailed": "儲存失敗,請檢查網路後重試",
|
||||||
"invalidSettings": "部分設定無效。",
|
"invalidSettings": "有設定填寫有誤,請依提示修改",
|
||||||
"discard": "捨棄",
|
"discard": "捨棄修改",
|
||||||
"saveChanges": "儲存變更",
|
"saveChanges": "儲存修改",
|
||||||
"saving": "正在儲存…",
|
"saving": "儲存中…",
|
||||||
"sourceSaved": "已儲存",
|
"sourceSaved": "本頁設定",
|
||||||
"sourceEnv": "環境變數",
|
"sourceEnv": "環境變數",
|
||||||
"sourceSavedTitle": "在管理員設定檔中設定",
|
"sourceSavedTitle": "來自本頁設定",
|
||||||
"sourceEnvTitle": "透過環境變數設定",
|
"sourceEnvTitle": "來自環境變數",
|
||||||
"restartRequired": "需要重新啟動",
|
"restartRequired": "需重啟",
|
||||||
"modified": "已修改",
|
"modified": "已修改",
|
||||||
"notSet": "未設定",
|
"notSet": "未設定",
|
||||||
"savedReplace": "已儲存({hint})——輸入以取代",
|
"savedReplace": "已儲存({hint}),輸入新值即可取代",
|
||||||
"showValue": "顯示值",
|
"showValue": "顯示",
|
||||||
"hideValue": "隱藏值",
|
"hideValue": "隱藏",
|
||||||
"removeValue": "移除值",
|
"removeValue": "清除",
|
||||||
"removeValueTitle": "移除已儲存的值",
|
"removeValueTitle": "清除已儲存的值",
|
||||||
"resetToDefault": "重設為預設",
|
"resetToDefault": "重設為預設",
|
||||||
"models": "模型",
|
"models": "模型",
|
||||||
"modelsDescription": "面向所有使用者的伺服器端 provider 與模型——無需個人 API 金鑰。當使用者未選擇模型時,使用預設 provider 的第一個模型。",
|
"modelsDescription": "給所有使用者用的模型,無需自備金鑰",
|
||||||
"addProviderHint": "新增一個 provider,為所有使用者提供伺服器端模型。",
|
"addProviderHint": "新增服務商,為所有使用者提供模型",
|
||||||
"selectProviderHint": "選擇或新增一個 provider 以設定其憑證和模型。",
|
"selectProviderHint": "選擇或新增服務商,填寫金鑰和模型",
|
||||||
"addProviderToOfferModels": "至少新增一個模型,才能向使用者開放此 provider。",
|
"addProviderToOfferModels": "至少新增一個模型,使用者才能看到這個服務商",
|
||||||
"managedViaEnv": "(透過環境變數管理)",
|
"managedViaEnv": "(環境變數)",
|
||||||
"envReadOnly": "在 AI_MODELS_CONFIG / ai-models.json 中定義——此處唯讀。請編輯環境設定以變更。",
|
"envReadOnly": "由 AI_MODELS_CONFIG / ai-models.json 定義,這裡只能檢視,請到那裡修改",
|
||||||
"defaultModel": "預設模型",
|
"defaultModel": "預設模型",
|
||||||
"noModelsConfigured": "未設定模型",
|
"noModelsConfigured": "尚無模型",
|
||||||
"modelCount": "{count} 個模型",
|
"modelCount": "{count} 個模型",
|
||||||
"modelCountPlural": "{count} 個模型",
|
"modelCountPlural": "{count} 個模型",
|
||||||
"default": "預設",
|
"default": "預設",
|
||||||
"setAsDefault": "設為預設 provider",
|
"setAsDefault": "設為預設服務商",
|
||||||
"defaultProvider": "預設 provider",
|
"defaultProvider": "預設服務商",
|
||||||
"modelIdPlaceholder": "模型 ID…",
|
"modelIdPlaceholder": "模型 ID…",
|
||||||
"addModel": "新增模型",
|
"addModel": "新增模型",
|
||||||
"suggested": "推薦",
|
"suggested": "推薦",
|
||||||
"test": "測試",
|
"test": "測試",
|
||||||
"testOk": "正常({ms} 毫秒)",
|
"testOk": "通過({ms} 毫秒)",
|
||||||
"testFailed": "失敗",
|
"testFailed": "失敗",
|
||||||
"removeModel": "移除 {model}",
|
"removeModel": "刪除 {model}",
|
||||||
"deleteProviderTitle": "刪除 {name}?",
|
"deleteProviderTitle": "刪除 {name}?",
|
||||||
"deleteProviderDesc": "儲存後,其憑證和模型將從伺服器上移除。",
|
"deleteProviderDesc": "儲存後,它的金鑰和模型會從伺服器刪除",
|
||||||
"cancel": "取消",
|
"cancel": "取消",
|
||||||
"delete": "刪除",
|
"delete": "刪除",
|
||||||
"groups": {
|
"groups": {
|
||||||
"generation": {
|
"generation": {
|
||||||
"title": "生成",
|
"title": "回覆設定",
|
||||||
"description": "套用於所有聊天請求的輸出參數。"
|
"description": "控制 AI 怎麼回覆,對所有對話生效"
|
||||||
},
|
},
|
||||||
"access": {
|
"access": {
|
||||||
"title": "存取控制",
|
"title": "存取權限",
|
||||||
"description": "限制誰可以使用此部署。"
|
"description": "限制誰可以使用這個網站"
|
||||||
},
|
},
|
||||||
"features": {
|
"features": {
|
||||||
"title": "功能",
|
"title": "選用功能",
|
||||||
"description": "選用功能和安全開關。"
|
"description": "附加功能和安全選項"
|
||||||
},
|
},
|
||||||
"observability": {
|
"observability": {
|
||||||
"title": "可觀測性",
|
"title": "呼叫記錄",
|
||||||
"description": "對 LLM 呼叫進行 Langfuse 追蹤。"
|
"description": "用 Langfuse 記錄模型呼叫"
|
||||||
},
|
},
|
||||||
"quota": {
|
"quota": {
|
||||||
"title": "配額與速率限制",
|
"title": "用量限制",
|
||||||
"description": "按 IP 的用量限制。強制執行需要 DynamoDB 表。"
|
"description": "按 IP 限制用量,需要 DynamoDB 資料表才生效"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"settings": {
|
"settings": {
|
||||||
"TEMPERATURE": {
|
"TEMPERATURE": {
|
||||||
"label": "溫度",
|
"label": "溫度",
|
||||||
"description": "對於拒絕溫度參數的推理模型,請留空。"
|
"description": "越高越隨機;不支援溫度的推理模型請留空"
|
||||||
},
|
},
|
||||||
"MAX_OUTPUT_TOKENS": {
|
"MAX_OUTPUT_TOKENS": {
|
||||||
"label": "最大輸出 token 數"
|
"label": "輸出 token 上限"
|
||||||
},
|
},
|
||||||
"ACCESS_CODE_LIST": {
|
"ACCESS_CODE_LIST": {
|
||||||
"label": "存取碼",
|
"label": "存取碼",
|
||||||
"description": "以逗號分隔的清單。使用者需輸入其中之一才能聊天。留空 = 開放存取。"
|
"description": "逗號分隔多個;輸入其中一個才能用;留空則不限"
|
||||||
},
|
},
|
||||||
"ENABLE_VLM_VALIDATION": {
|
"ENABLE_VLM_VALIDATION": {
|
||||||
"label": "VLM 圖表驗證",
|
"label": "圖表檢查",
|
||||||
"description": "使用視覺模型對產生的圖表進行視覺化驗證。"
|
"description": "畫完後讓能看圖的模型檢查截圖"
|
||||||
},
|
},
|
||||||
"VALIDATION_MODEL": {
|
"VALIDATION_MODEL": {
|
||||||
"label": "驗證模型",
|
"label": "檢查模型",
|
||||||
"description": "留空時回退到預設 AI 模型。"
|
"description": "留空則用預設模型"
|
||||||
},
|
},
|
||||||
"VALIDATION_TIMEOUT": {
|
"VALIDATION_TIMEOUT": {
|
||||||
"label": "驗證逾時(毫秒)"
|
"label": "檢查逾時(毫秒)"
|
||||||
},
|
},
|
||||||
"ENABLE_HISTORY_XML_REPLACE": {
|
"ENABLE_HISTORY_XML_REPLACE": {
|
||||||
"label": "歷史 XML 壓縮",
|
"label": "精簡舊圖",
|
||||||
"description": "用占位符取代歷史記錄中的舊圖表 XML。"
|
"description": "對話歷史裡的舊圖表只留簡短標記"
|
||||||
},
|
},
|
||||||
"ALLOW_PRIVATE_URLS": {
|
"ALLOW_PRIVATE_URLS": {
|
||||||
"label": "允許私有 URL",
|
"label": "存取內網",
|
||||||
"description": "關閉以阻擋對私有 IP 和內部主機名的請求(SSRF 防護)。"
|
"description": "關閉後,自訂模型位址不能指向內網,更安全"
|
||||||
},
|
},
|
||||||
"LANGFUSE_PUBLIC_KEY": {
|
"LANGFUSE_PUBLIC_KEY": {
|
||||||
"label": "Langfuse Public Key"
|
"label": "Langfuse 公鑰"
|
||||||
},
|
},
|
||||||
"LANGFUSE_SECRET_KEY": {
|
"LANGFUSE_SECRET_KEY": {
|
||||||
"label": "Langfuse Secret Key"
|
"label": "Langfuse 金鑰"
|
||||||
},
|
},
|
||||||
"LANGFUSE_BASEURL": {
|
"LANGFUSE_BASEURL": {
|
||||||
"label": "Langfuse Base URL"
|
"label": "Langfuse 位址"
|
||||||
},
|
},
|
||||||
"DAILY_REQUEST_LIMIT": {
|
"DAILY_REQUEST_LIMIT": {
|
||||||
"label": "每日請求上限",
|
"label": "每日請求",
|
||||||
"description": "每個 IP 每天。"
|
"description": "每個 IP 每天的請求次數上限"
|
||||||
},
|
},
|
||||||
"DAILY_TOKEN_LIMIT": {
|
"DAILY_TOKEN_LIMIT": {
|
||||||
"label": "每日 token 上限",
|
"label": "每日 token",
|
||||||
"description": "每個 IP 每天。"
|
"description": "每個 IP 每天的 token 用量上限"
|
||||||
},
|
},
|
||||||
"TPM_LIMIT": {
|
"TPM_LIMIT": {
|
||||||
"label": "每分鐘 token 數"
|
"label": "每分鐘 token"
|
||||||
},
|
},
|
||||||
"DYNAMODB_QUOTA_TABLE": {
|
"DYNAMODB_QUOTA_TABLE": {
|
||||||
"label": "DynamoDB 表",
|
"label": "DynamoDB 資料表名稱",
|
||||||
"description": "留空時配額強制執行被停用。"
|
"description": "留空則不限制用量"
|
||||||
},
|
},
|
||||||
"DYNAMODB_REGION": {
|
"DYNAMODB_REGION": {
|
||||||
"label": "DynamoDB 區域"
|
"label": "DynamoDB 區域"
|
||||||
},
|
},
|
||||||
"QUOTA_TIMEZONE": {
|
"QUOTA_TIMEZONE": {
|
||||||
"label": "配額時區",
|
"label": "時區",
|
||||||
"description": "每日重置邊界所用的時區。"
|
"description": "每日用量按這個時區的零點重置"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -82,9 +82,9 @@
|
|||||||
"accessCode": "访问码",
|
"accessCode": "访问码",
|
||||||
"accessCodePlaceholder": "输入访问码",
|
"accessCodePlaceholder": "输入访问码",
|
||||||
"accessCodeDescription": "使用此应用程序需要访问码。",
|
"accessCodeDescription": "使用此应用程序需要访问码。",
|
||||||
"aiProvider": "AI 提供商设置",
|
"aiProvider": "AI 服务商设置",
|
||||||
"aiProviderDescription": "使用您自己的 API 密钥来绕过使用限制。您的密钥仅存储在浏览器本地,不会存储在服务器上。",
|
"aiProviderDescription": "使用您自己的 API 密钥来绕过使用限制。您的密钥仅存储在浏览器本地,不会存储在服务器上。",
|
||||||
"provider": "提供商",
|
"provider": "服务商",
|
||||||
"modelId": "模型 ID",
|
"modelId": "模型 ID",
|
||||||
"apiKey": "API 密钥",
|
"apiKey": "API 密钥",
|
||||||
"apiKeyPlaceholder": "您的 API 密钥",
|
"apiKeyPlaceholder": "您的 API 密钥",
|
||||||
@@ -118,7 +118,7 @@
|
|||||||
"customSystemMessage": "自定义指令",
|
"customSystemMessage": "自定义指令",
|
||||||
"customSystemMessageDescription": "添加自定义指令,将附加到 AI 的系统提示末尾。",
|
"customSystemMessageDescription": "添加自定义指令,将附加到 AI 的系统提示末尾。",
|
||||||
"customSystemMessagePlaceholder": "例如:图表始终使用蓝色配色方案...",
|
"customSystemMessagePlaceholder": "例如:图表始终使用蓝色配色方案...",
|
||||||
"maxOutputTokens": "最大输出 token 数",
|
"maxOutputTokens": "输出 token 上限",
|
||||||
"maxOutputTokensDescription": "单次回复的额度,思考过程和图表 XML 共用。如果 AI 一直在思考却没有生成图表,请把它调大。留空则使用默认值。",
|
"maxOutputTokensDescription": "单次回复的额度,思考过程和图表 XML 共用。如果 AI 一直在思考却没有生成图表,请把它调大。留空则使用默认值。",
|
||||||
"tabModels": "模型",
|
"tabModels": "模型",
|
||||||
"tabAbout": "关于",
|
"tabAbout": "关于",
|
||||||
@@ -207,10 +207,10 @@
|
|||||||
},
|
},
|
||||||
"quota": {
|
"quota": {
|
||||||
"dailyLimit": "已达每日配额",
|
"dailyLimit": "已达每日配额",
|
||||||
"tokenLimit": "已达每日令牌限制",
|
"tokenLimit": "已达每日 token 上限",
|
||||||
"tpmLimit": "速率限制",
|
"tpmLimit": "速率限制",
|
||||||
"tpmMessage": "请求过多。请稍等片刻。",
|
"tpmMessage": "请求过多。请稍等片刻。",
|
||||||
"tpmMessageDetailed": "达到速率限制({limit} 令牌/分钟)。请等待 {seconds} 秒后再发送请求。",
|
"tpmMessageDetailed": "达到速率限制(每分钟 {limit} token)。请等待 {seconds} 秒后再发送请求。",
|
||||||
"messageApi": "看来您今天的体验次数已达上限。很高兴您玩得开心;为了让大家都能公平使用,演示站对用量做了限制。",
|
"messageApi": "看来您今天的体验次数已达上限。很高兴您玩得开心;为了让大家都能公平使用,演示站对用量做了限制。",
|
||||||
"messageApiSelfHosted": null,
|
"messageApiSelfHosted": null,
|
||||||
"messageToken": "看来您今天的 token 用量已达上限。很高兴您玩得开心;为了让大家都能公平使用,演示站对用量做了限制。",
|
"messageToken": "看来您今天的 token 用量已达上限。很高兴您玩得开心;为了让大家都能公平使用,演示站对用量做了限制。",
|
||||||
@@ -481,146 +481,146 @@
|
|||||||
"removeAccessKeys": "删除访问密钥"
|
"removeAccessKeys": "删除访问密钥"
|
||||||
},
|
},
|
||||||
"admin": {
|
"admin": {
|
||||||
"title": "管理员设置",
|
"title": "后台设置",
|
||||||
"loginPrompt": "输入管理员密码(即 ADMIN_PASSWORD 环境变量)以管理服务器设置。",
|
"loginPrompt": "输入管理员密码(环境变量 ADMIN_PASSWORD)",
|
||||||
"password": "密码",
|
"password": "密码",
|
||||||
"signIn": "登录",
|
"signIn": "登录",
|
||||||
"signingIn": "正在登录…",
|
"signingIn": "登录中…",
|
||||||
"loginFailed": "登录失败",
|
"loginFailed": "登录失败",
|
||||||
"precedence": "文件覆盖环境变量 · 环境变量覆盖默认值",
|
"precedence": "优先级:本页设置 > 环境变量 > 默认值",
|
||||||
"notWritable": "此部署环境下设置文件不可写(无服务器平台没有持久化磁盘)。设置以只读方式显示——请改用环境变量进行配置。",
|
"notWritable": "这里无法保存设置,只能查看;请改用环境变量",
|
||||||
"settingGroups": "设置分组",
|
"settingGroups": "设置分类",
|
||||||
"enabled": "已启用",
|
"enabled": "已开启",
|
||||||
"disabled": "已禁用",
|
"disabled": "已关闭",
|
||||||
"enableGroup": "启用 {group}",
|
"enableGroup": "开启{group}",
|
||||||
"unsavedChanges": "有未保存的更改",
|
"unsavedChanges": "有未保存的修改",
|
||||||
"saved": "设置已保存,更改立即生效。",
|
"saved": "已保存,立即生效(需重启的项除外)",
|
||||||
"saveFailed": "保存失败。请检查网络连接后重试。",
|
"saveFailed": "保存失败,请检查网络后重试",
|
||||||
"invalidSettings": "部分设置无效。",
|
"invalidSettings": "有设置填写有误,请按提示修改",
|
||||||
"discard": "放弃",
|
"discard": "放弃修改",
|
||||||
"saveChanges": "保存更改",
|
"saveChanges": "保存修改",
|
||||||
"saving": "正在保存…",
|
"saving": "保存中…",
|
||||||
"sourceSaved": "已保存",
|
"sourceSaved": "本页设置",
|
||||||
"sourceEnv": "环境变量",
|
"sourceEnv": "环境变量",
|
||||||
"sourceSavedTitle": "在管理员设置文件中设置",
|
"sourceSavedTitle": "来自本页设置",
|
||||||
"sourceEnvTitle": "通过环境变量设置",
|
"sourceEnvTitle": "来自环境变量",
|
||||||
"restartRequired": "需要重启",
|
"restartRequired": "需重启",
|
||||||
"modified": "已修改",
|
"modified": "已修改",
|
||||||
"notSet": "未设置",
|
"notSet": "未设置",
|
||||||
"savedReplace": "已保存({hint})——输入以替换",
|
"savedReplace": "已保存({hint}),输入新值可替换",
|
||||||
"showValue": "显示值",
|
"showValue": "显示",
|
||||||
"hideValue": "隐藏值",
|
"hideValue": "隐藏",
|
||||||
"removeValue": "移除值",
|
"removeValue": "清除",
|
||||||
"removeValueTitle": "移除已保存的值",
|
"removeValueTitle": "清除已保存的值",
|
||||||
"resetToDefault": "恢复默认",
|
"resetToDefault": "恢复默认",
|
||||||
"models": "模型",
|
"models": "模型",
|
||||||
"modelsDescription": "面向所有用户的服务端 provider 和模型——无需个人 API 密钥。当用户未选择模型时,使用默认 provider 的第一个模型。",
|
"modelsDescription": "给所有用户用的模型,无需自带密钥",
|
||||||
"addProviderHint": "添加一个 provider,为所有用户提供服务端模型。",
|
"addProviderHint": "添加服务商,为所有用户提供模型",
|
||||||
"selectProviderHint": "选择或添加一个 provider 以配置其凭证和模型。",
|
"selectProviderHint": "选择或添加服务商,填写密钥和模型",
|
||||||
"addProviderToOfferModels": "至少添加一个模型,才能向用户开放此 provider。",
|
"addProviderToOfferModels": "至少添加一个模型,用户才能看到这个服务商",
|
||||||
"managedViaEnv": "(通过环境变量管理)",
|
"managedViaEnv": "(环境变量)",
|
||||||
"envReadOnly": "在 AI_MODELS_CONFIG / ai-models.json 中定义——此处只读。请编辑环境配置以更改。",
|
"envReadOnly": "由 AI_MODELS_CONFIG / ai-models.json 定义,这里只能查看,请到那里修改",
|
||||||
"defaultModel": "默认模型",
|
"defaultModel": "默认模型",
|
||||||
"noModelsConfigured": "未配置模型",
|
"noModelsConfigured": "暂无模型",
|
||||||
"modelCount": "{count} 个模型",
|
"modelCount": "{count} 个模型",
|
||||||
"modelCountPlural": "{count} 个模型",
|
"modelCountPlural": "{count} 个模型",
|
||||||
"default": "默认",
|
"default": "默认",
|
||||||
"setAsDefault": "设为默认 provider",
|
"setAsDefault": "设为默认服务商",
|
||||||
"defaultProvider": "默认 provider",
|
"defaultProvider": "默认服务商",
|
||||||
"modelIdPlaceholder": "模型 ID…",
|
"modelIdPlaceholder": "模型 ID…",
|
||||||
"addModel": "添加模型",
|
"addModel": "添加模型",
|
||||||
"suggested": "推荐",
|
"suggested": "推荐",
|
||||||
"test": "测试",
|
"test": "测试",
|
||||||
"testOk": "正常({ms} 毫秒)",
|
"testOk": "通过({ms} 毫秒)",
|
||||||
"testFailed": "失败",
|
"testFailed": "失败",
|
||||||
"removeModel": "移除 {model}",
|
"removeModel": "删除 {model}",
|
||||||
"deleteProviderTitle": "删除 {name}?",
|
"deleteProviderTitle": "删除 {name}?",
|
||||||
"deleteProviderDesc": "保存后,其凭证和模型将从服务器上移除。",
|
"deleteProviderDesc": "保存后,它的密钥和模型会从服务器删除",
|
||||||
"cancel": "取消",
|
"cancel": "取消",
|
||||||
"delete": "删除",
|
"delete": "删除",
|
||||||
"groups": {
|
"groups": {
|
||||||
"generation": {
|
"generation": {
|
||||||
"title": "生成",
|
"title": "回复设置",
|
||||||
"description": "应用于所有聊天请求的输出参数。"
|
"description": "控制 AI 怎么回复,对所有对话生效"
|
||||||
},
|
},
|
||||||
"access": {
|
"access": {
|
||||||
"title": "访问控制",
|
"title": "访问权限",
|
||||||
"description": "限制谁可以使用此部署。"
|
"description": "限制谁可以使用这个站点"
|
||||||
},
|
},
|
||||||
"features": {
|
"features": {
|
||||||
"title": "功能",
|
"title": "可选功能",
|
||||||
"description": "可选功能和安全开关。"
|
"description": "附加功能和安全选项"
|
||||||
},
|
},
|
||||||
"observability": {
|
"observability": {
|
||||||
"title": "可观测性",
|
"title": "调用记录",
|
||||||
"description": "对 LLM 调用进行 Langfuse 追踪。"
|
"description": "用 Langfuse 记录模型调用"
|
||||||
},
|
},
|
||||||
"quota": {
|
"quota": {
|
||||||
"title": "配额与速率限制",
|
"title": "用量限制",
|
||||||
"description": "按 IP 的用量限制。强制执行需要 DynamoDB 表。"
|
"description": "按 IP 限制用量,需要 DynamoDB 表才生效"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"settings": {
|
"settings": {
|
||||||
"TEMPERATURE": {
|
"TEMPERATURE": {
|
||||||
"label": "温度",
|
"label": "温度",
|
||||||
"description": "对于拒绝温度参数的推理模型,请留空。"
|
"description": "越高越随机;不支持温度的推理模型请留空"
|
||||||
},
|
},
|
||||||
"MAX_OUTPUT_TOKENS": {
|
"MAX_OUTPUT_TOKENS": {
|
||||||
"label": "最大输出 token 数"
|
"label": "输出 token 上限"
|
||||||
},
|
},
|
||||||
"ACCESS_CODE_LIST": {
|
"ACCESS_CODE_LIST": {
|
||||||
"label": "访问码",
|
"label": "访问码",
|
||||||
"description": "以逗号分隔的列表。用户需输入其中之一才能聊天。留空 = 开放访问。"
|
"description": "逗号分隔多个;输入其中一个才能用;留空则不限"
|
||||||
},
|
},
|
||||||
"ENABLE_VLM_VALIDATION": {
|
"ENABLE_VLM_VALIDATION": {
|
||||||
"label": "VLM 图表验证",
|
"label": "图表检查",
|
||||||
"description": "使用视觉模型对生成的图表进行可视化验证。"
|
"description": "画完后让能看图的模型检查截图"
|
||||||
},
|
},
|
||||||
"VALIDATION_MODEL": {
|
"VALIDATION_MODEL": {
|
||||||
"label": "验证模型",
|
"label": "检查模型",
|
||||||
"description": "留空时回退到默认 AI 模型。"
|
"description": "留空则用默认模型"
|
||||||
},
|
},
|
||||||
"VALIDATION_TIMEOUT": {
|
"VALIDATION_TIMEOUT": {
|
||||||
"label": "验证超时(毫秒)"
|
"label": "检查超时(毫秒)"
|
||||||
},
|
},
|
||||||
"ENABLE_HISTORY_XML_REPLACE": {
|
"ENABLE_HISTORY_XML_REPLACE": {
|
||||||
"label": "历史 XML 压缩",
|
"label": "精简旧图",
|
||||||
"description": "用占位符替换历史记录中的旧图表 XML。"
|
"description": "对话历史里的旧图表只留简短标记"
|
||||||
},
|
},
|
||||||
"ALLOW_PRIVATE_URLS": {
|
"ALLOW_PRIVATE_URLS": {
|
||||||
"label": "允许私有 URL",
|
"label": "访问内网",
|
||||||
"description": "关闭以阻止对私有 IP 和内部主机名的请求(SSRF 防护)。"
|
"description": "关闭后,自定义模型地址不能指向内网,更安全"
|
||||||
},
|
},
|
||||||
"LANGFUSE_PUBLIC_KEY": {
|
"LANGFUSE_PUBLIC_KEY": {
|
||||||
"label": "Langfuse Public Key"
|
"label": "Langfuse 公钥"
|
||||||
},
|
},
|
||||||
"LANGFUSE_SECRET_KEY": {
|
"LANGFUSE_SECRET_KEY": {
|
||||||
"label": "Langfuse Secret Key"
|
"label": "Langfuse 密钥"
|
||||||
},
|
},
|
||||||
"LANGFUSE_BASEURL": {
|
"LANGFUSE_BASEURL": {
|
||||||
"label": "Langfuse Base URL"
|
"label": "Langfuse 地址"
|
||||||
},
|
},
|
||||||
"DAILY_REQUEST_LIMIT": {
|
"DAILY_REQUEST_LIMIT": {
|
||||||
"label": "每日请求上限",
|
"label": "每日请求",
|
||||||
"description": "每个 IP 每天。"
|
"description": "每个 IP 每天的请求次数上限"
|
||||||
},
|
},
|
||||||
"DAILY_TOKEN_LIMIT": {
|
"DAILY_TOKEN_LIMIT": {
|
||||||
"label": "每日 token 上限",
|
"label": "每日 token",
|
||||||
"description": "每个 IP 每天。"
|
"description": "每个 IP 每天的 token 用量上限"
|
||||||
},
|
},
|
||||||
"TPM_LIMIT": {
|
"TPM_LIMIT": {
|
||||||
"label": "每分钟 token 数"
|
"label": "每分钟 token"
|
||||||
},
|
},
|
||||||
"DYNAMODB_QUOTA_TABLE": {
|
"DYNAMODB_QUOTA_TABLE": {
|
||||||
"label": "DynamoDB 表",
|
"label": "DynamoDB 表名",
|
||||||
"description": "留空时配额强制执行被禁用。"
|
"description": "留空则不限制用量"
|
||||||
},
|
},
|
||||||
"DYNAMODB_REGION": {
|
"DYNAMODB_REGION": {
|
||||||
"label": "DynamoDB 区域"
|
"label": "DynamoDB 区域"
|
||||||
},
|
},
|
||||||
"QUOTA_TIMEZONE": {
|
"QUOTA_TIMEZONE": {
|
||||||
"label": "配额时区",
|
"label": "时区",
|
||||||
"description": "每日重置边界所用的时区。"
|
"description": "每日用量按这个时区的零点重置"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|||||||
+29
-31
@@ -4,6 +4,7 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import {
|
import {
|
||||||
|
STYLE_CLASS_EXAMPLE,
|
||||||
SWIMLANE_EXAMPLE,
|
SWIMLANE_EXAMPLE,
|
||||||
TWO_EDGES_EXAMPLE,
|
TWO_EDGES_EXAMPLE,
|
||||||
WAYPOINT_EXAMPLE,
|
WAYPOINT_EXAMPLE,
|
||||||
@@ -100,45 +101,41 @@ Note that:
|
|||||||
|
|
||||||
When using edit_diagram tool:
|
When using edit_diagram tool:
|
||||||
- Use operations: update (modify cell by id), add (new cell), delete (remove cell by id)
|
- Use operations: update (modify cell by id), add (new cell), delete (remove cell by id)
|
||||||
- For update/add: provide cell_id and complete new_xml (full mxCell element including mxGeometry)
|
- For update/add: provide cell_id and the complete new_xml in the same compact form (a shape with x, y, w, h; an edge with source and target)
|
||||||
- For delete: only cell_id is needed
|
- For delete: only cell_id is needed
|
||||||
|
- Named styles are not available in edit_diagram: write each cell's complete style
|
||||||
- Find the cell_id from "Current diagram XML" in system context
|
- Find the cell_id from "Current diagram XML" in system context
|
||||||
- Example update: {"operations": [{"operation": "update", "cell_id": "3", "new_xml": "<mxCell id=\\"3\\" value=\\"New Label\\" style=\\"rounded=1;\\" vertex=\\"1\\" parent=\\"1\\">\\n <mxGeometry x=\\"100\\" y=\\"100\\" width=\\"120\\" height=\\"60\\" as=\\"geometry\\"/>\\n</mxCell>"}]}
|
- Example update: {"operations": [{"operation": "update", "cell_id": "3", "new_xml": "<mxCell id=\\"3\\" value=\\"New Label\\" style=\\"rounded=1;\\" x=\\"100\\" y=\\"100\\" w=\\"120\\" h=\\"60\\"/>"}]}
|
||||||
- Example delete: {"operations": [{"operation": "delete", "cell_id": "5"}]}
|
- Example delete: {"operations": [{"operation": "delete", "cell_id": "5"}]}
|
||||||
- Example add: {"operations": [{"operation": "add", "cell_id": "new1", "new_xml": "<mxCell id=\\"new1\\" value=\\"New Box\\" style=\\"rounded=1;\\" vertex=\\"1\\" parent=\\"1\\">\\n <mxGeometry x=\\"400\\" y=\\"200\\" width=\\"120\\" height=\\"60\\" as=\\"geometry\\"/>\\n</mxCell>"}]}
|
- Example add: {"operations": [{"operation": "add", "cell_id": "new1", "new_xml": "<mxCell id=\\"new1\\" value=\\"New Box\\" style=\\"rounded=1;\\" x=\\"400\\" y=\\"200\\" w=\\"120\\" h=\\"60\\"/>"}]}
|
||||||
|
|
||||||
⚠️ JSON ESCAPING: Every " inside new_xml MUST be escaped as \\". Example: id=\\"5\\" value=\\"Label\\"
|
⚠️ JSON ESCAPING: Every " inside new_xml MUST be escaped as \\". Example: id=\\"5\\" value=\\"Label\\"
|
||||||
|
|
||||||
## Draw.io XML Structure Reference
|
## Draw.io XML Structure Reference
|
||||||
|
|
||||||
**IMPORTANT:** You only generate the mxCell elements. The wrapper structure and root cells (id="0", id="1") are added automatically.
|
**IMPORTANT:** You only generate the named styles and the mxCell elements. The wrapper structure and root cells (id="0", id="1") are added automatically. A named style is written before the cells as <mxStyle name="n" value="...style pairs..."/>; a cell uses it by putting the name among its style tokens (see Styles). A shape is one self-closing mxCell with x, y, w and h; an edge is one with source and target (a cell with source or target is always an edge). vertex="1", edge="1", parent="1" and the mxGeometry element are added automatically. Write parent only for a shape inside a container, and an mxGeometry element only for edge waypoints or for a separate label cell placed on an edge: <mxCell id="9" value="yes" style="edgeLabel;" parent="<edge id>" connectable="0"><mxGeometry x="-0.5" relative="1" as="geometry"/></mxCell>. An edge's own text simply goes in its value.
|
||||||
|
|
||||||
Example - generate ONLY this:
|
Example - generate ONLY this:
|
||||||
\`\`\`xml
|
\`\`\`xml
|
||||||
<mxCell id="2" value="Label" style="rounded=1;" vertex="1" parent="1">
|
<mxCell id="2" value="Label" style="rounded=1;" x="100" y="100" w="120" h="60"/>
|
||||||
<mxGeometry x="100" y="100" width="120" height="60" as="geometry"/>
|
|
||||||
</mxCell>
|
|
||||||
\`\`\`
|
\`\`\`
|
||||||
|
|
||||||
CRITICAL RULES:
|
CRITICAL RULES:
|
||||||
1. Generate ONLY mxCell elements - NO wrapper tags (<mxfile>, <mxGraphModel>, <root>)
|
1. Generate ONLY mxStyle definitions and mxCell elements - NO wrapper tags (<mxfile>, <mxGraphModel>, <root>)
|
||||||
2. Do NOT include root cells (id="0" or id="1") - they are added automatically
|
2. Do NOT include root cells (id="0" or id="1") - they are added automatically
|
||||||
3. ALL mxCell elements must be siblings - NEVER nest mxCell inside another mxCell
|
3. ALL mxCell elements must be siblings - NEVER nest mxCell inside another mxCell
|
||||||
4. Use unique sequential IDs starting from "2"
|
4. Use unique sequential IDs starting from "2"
|
||||||
5. Set parent="1" for top-level shapes, or parent="<container-id>" for grouped elements
|
5. Write parent="<container-id>" only for shapes inside a container; top-level cells need no parent
|
||||||
|
|
||||||
Shape (vertex) example:
|
Shape (vertex) example:
|
||||||
\`\`\`xml
|
\`\`\`xml
|
||||||
<mxCell id="2" value="Label" style="rounded=1;whiteSpace=wrap;html=1;" vertex="1" parent="1">
|
<mxCell id="2" value="Label" style="rounded=1;" x="100" y="100" w="120" h="60"/>
|
||||||
<mxGeometry x="100" y="100" width="120" height="60" as="geometry"/>
|
|
||||||
</mxCell>
|
|
||||||
\`\`\`
|
\`\`\`
|
||||||
|
|
||||||
Connector (edge) example:
|
Connector (edge) example:
|
||||||
\`\`\`xml
|
\`\`\`xml
|
||||||
<mxCell id="3" style="endArrow=classic;html=1;" edge="1" parent="1" source="2" target="4">
|
<mxCell id="3" style="edgeStyle=orthogonalEdgeStyle;" source="2" target="4"/>
|
||||||
<mxGeometry relative="1" as="geometry"/>
|
\`\`\`
|
||||||
</mxCell>
|
|
||||||
|
|
||||||
### Edge Routing Rules:
|
### Edge Routing Rules:
|
||||||
When creating edges/connectors, you MUST follow these rules to avoid overlapping lines:
|
When creating edges/connectors, you MUST follow these rules to avoid overlapping lines:
|
||||||
@@ -153,7 +150,7 @@ When creating edges/connectors, you MUST follow these rules to avoid overlapping
|
|||||||
|
|
||||||
**Rule 3: Always specify exitX, exitY, entryX, entryY explicitly**
|
**Rule 3: Always specify exitX, exitY, entryX, entryY explicitly**
|
||||||
- Every edge MUST have these 4 attributes set in the style
|
- Every edge MUST have these 4 attributes set in the style
|
||||||
- Example: style="edgeStyle=orthogonalEdgeStyle;exitX=1;exitY=0.3;entryX=0;entryY=0.3;endArrow=classic;"
|
- Example: style="edgeStyle=orthogonalEdgeStyle;exitX=1;exitY=0.3;entryX=0;entryY=0.3;"
|
||||||
|
|
||||||
**Rule 4: Route edges AROUND intermediate shapes (obstacle avoidance) - CRITICAL!**
|
**Rule 4: Route edges AROUND intermediate shapes (obstacle avoidance) - CRITICAL!**
|
||||||
- Before creating an edge, identify ALL shapes positioned between source and target
|
- Before creating an edge, identify ALL shapes positioned between source and target
|
||||||
@@ -188,17 +185,18 @@ When creating edges/connectors, you MUST follow these rules to avoid overlapping
|
|||||||
3. "Are any connection points at corners (both X and Y are 0 or 1)?" → If yes, use edge centers instead
|
3. "Are any connection points at corners (both X and Y are 0 or 1)?" → If yes, use edge centers instead
|
||||||
4. "Could I rearrange shapes to reduce edge crossings?" → If yes, revise layout
|
4. "Could I rearrange shapes to reduce edge crossings?" → If yes, revise layout
|
||||||
|
|
||||||
|
|
||||||
\`\`\`
|
|
||||||
|
|
||||||
`
|
`
|
||||||
|
|
||||||
// Style instructions - only included when minimalStyle is false
|
// Style instructions - only included when minimalStyle is false
|
||||||
const STYLE_INSTRUCTIONS = `
|
const STYLE_INSTRUCTIONS = `
|
||||||
Common styles:
|
## Styles
|
||||||
- Shapes: rounded=1 (rounded corners), fillColor=#hex, strokeColor=#hex
|
Define each style used by several cells ONCE, as a named style before the cells, and use the name in the cells like a CSS class. A cell's style can combine a shape token, a name and overrides; later pairs win. Name only styles that two or more cells share; a style used by one cell stays inline. Names must not be draw.io's own style names: shapes such as text, ellipse, rhombus, swimlane, label, image, and colors such as blue, green, red, gray, yellow, orange, purple, pink. A definition applies to the call it is in: each display_diagram call defines the names it uses. The app expands the names, so the saved file is standard draw.io XML.
|
||||||
- Edges: endArrow=classic/block/open/none, startArrow=none/classic, curved=1, edgeStyle=orthogonalEdgeStyle
|
\`\`\`xml
|
||||||
- Text: fontSize=14, fontStyle=1 (bold), align=center/left/right
|
${STYLE_CLASS_EXAMPLE}
|
||||||
|
\`\`\`
|
||||||
|
- NEVER write html=1 or whiteSpace=wrap: the app adds html=1 to every cell and whiteSpace=wrap to shapes. Labels are HTML: use <br> for a line break and <b> for bold, never \\n; a literal < or > in a label is written &lt; or &gt;.
|
||||||
|
- Do NOT repeat what draw.io already uses. For a plain shape: rounded=0, align=center, verticalAlign=middle, fontSize=12, strokeWidth=1, fillColor=#ffffff, strokeColor=#000000, fontColor=#000000. For an edge: endArrow=classic, strokeColor=#000000. Writing one of them is right only when it overrides what a name or the shape sets: an edge is rounded by default, so rounded=0 on an edge is a real setting, and a text cell is left/top aligned by default, so there align=center or verticalAlign=middle are real settings.
|
||||||
|
- Keys: shapes rounded=1, fillColor=#hex, strokeColor=#hex; edges endArrow=block/open/none, startArrow=classic, curved=1, dashed=1, edgeStyle=orthogonalEdgeStyle; text fontSize=14, fontStyle=1 (bold), align=center/right.
|
||||||
`
|
`
|
||||||
|
|
||||||
// Minimal style instruction - skip styling and focus on layout (prepended to prompt for emphasis)
|
// Minimal style instruction - skip styling and focus on layout (prepended to prompt for emphasis)
|
||||||
@@ -208,13 +206,13 @@ const MINIMAL_STYLE_INSTRUCTION = `
|
|||||||
### No Styling - Plain Black/White Only
|
### No Styling - Plain Black/White Only
|
||||||
- NO fillColor, NO strokeColor, NO rounded, NO fontSize, NO fontStyle
|
- NO fillColor, NO strokeColor, NO rounded, NO fontSize, NO fontStyle
|
||||||
- NO color attributes (no hex colors like #ff69b4)
|
- NO color attributes (no hex colors like #ff69b4)
|
||||||
- Style: "whiteSpace=wrap;html=1;" for shapes, "html=1;endArrow=classic;" for edges
|
- Shapes: no style, or only the shape (ellipse, rhombus). Edges: edgeStyle=orthogonalEdgeStyle plus the exit/entry points from the Edge Routing Rules, nothing else. html=1 and whiteSpace=wrap are added automatically.
|
||||||
- IGNORE all color/style examples below
|
- IGNORE all color/style examples below
|
||||||
|
|
||||||
### Container/Group Shapes - MUST be Transparent
|
### Container/Group Shapes - MUST be Transparent
|
||||||
- For container shapes (boxes that contain other shapes): use "fillColor=none;" to make background transparent
|
- For container shapes (boxes that contain other shapes): use "fillColor=none;" to make background transparent
|
||||||
- This prevents containers from covering child elements
|
- This prevents containers from covering child elements
|
||||||
- Example: style="whiteSpace=wrap;html=1;fillColor=none;" for container rectangles
|
- Example: style="fillColor=none;" for container rectangles
|
||||||
|
|
||||||
### Focus on Layout Quality
|
### Focus on Layout Quality
|
||||||
Since we skip styling, STRICTLY follow the "Edge Routing Rules" section below:
|
Since we skip styling, STRICTLY follow the "Edge Routing Rules" section below:
|
||||||
@@ -235,10 +233,10 @@ const EXTENDED_ADDITIONS = `
|
|||||||
### display_diagram Details
|
### display_diagram Details
|
||||||
|
|
||||||
**VALIDATION RULES** (XML will be rejected if violated):
|
**VALIDATION RULES** (XML will be rejected if violated):
|
||||||
1. Generate ONLY mxCell elements - wrapper tags and root cells are added automatically
|
1. Generate ONLY mxStyle definitions and mxCell elements - wrapper tags and root cells are added automatically
|
||||||
2. All mxCell elements must be siblings - never nested inside other mxCell elements
|
2. All mxCell elements must be siblings - never nested inside other mxCell elements
|
||||||
3. Every mxCell needs a unique id attribute (start from "2")
|
3. Every mxCell needs a unique id attribute (start from "2")
|
||||||
4. Every mxCell needs a valid parent attribute (use "1" for top-level, or container-id for grouped)
|
4. parent defaults to "1"; write it only for a shape inside a container (the container's id)
|
||||||
5. Edge source/target attributes must reference existing cell IDs
|
5. Edge source/target attributes must reference existing cell IDs
|
||||||
6. Escape special characters in values: < for <, > for >, & for &, " for "
|
6. Escape special characters in values: < for <, > for >, & for &, " for "
|
||||||
|
|
||||||
@@ -257,7 +255,7 @@ ${SWIMLANE_EXAMPLE}
|
|||||||
3. Complete the remaining mxCell elements
|
3. Complete the remaining mxCell elements
|
||||||
4. If still truncated, call append_diagram again with the next fragment
|
4. If still truncated, call append_diagram again with the next fragment
|
||||||
|
|
||||||
**Example:** If previous output ended with \`<mxCell id="x" style="rounded=1\`, continue with \`;" vertex="1">...\` and complete the remaining elements.
|
**Example:** If previous output ended with \`<mxCell id="x" style="rounded=1\`, continue with \`;" x="40" y="40" w="120" h="60"/>\` and complete the remaining elements.
|
||||||
|
|
||||||
### edit_diagram Details
|
### edit_diagram Details
|
||||||
|
|
||||||
@@ -283,12 +281,12 @@ edit_diagram uses ID-based operations to modify cells directly by their id attri
|
|||||||
|
|
||||||
Change label:
|
Change label:
|
||||||
\`\`\`json
|
\`\`\`json
|
||||||
{"operations": [{"operation": "update", "cell_id": "3", "new_xml": "<mxCell id=\\"3\\" value=\\"New Label\\" style=\\"rounded=1;\\" vertex=\\"1\\" parent=\\"1\\">\\n <mxGeometry x=\\"100\\" y=\\"100\\" width=\\"120\\" height=\\"60\\" as=\\"geometry\\"/>\\n</mxCell>"}]}
|
{"operations": [{"operation": "update", "cell_id": "3", "new_xml": "<mxCell id=\\"3\\" value=\\"New Label\\" style=\\"rounded=1;\\" x=\\"100\\" y=\\"100\\" w=\\"120\\" h=\\"60\\"/>"}]}
|
||||||
\`\`\`
|
\`\`\`
|
||||||
|
|
||||||
Add new shape:
|
Add new shape:
|
||||||
\`\`\`json
|
\`\`\`json
|
||||||
{"operations": [{"operation": "add", "cell_id": "new1", "new_xml": "<mxCell id=\\"new1\\" value=\\"New Box\\" style=\\"rounded=1;fillColor=#dae8fc;\\" vertex=\\"1\\" parent=\\"1\\">\\n <mxGeometry x=\\"400\\" y=\\"200\\" width=\\"120\\" height=\\"60\\" as=\\"geometry\\"/>\\n</mxCell>"}]}
|
{"operations": [{"operation": "add", "cell_id": "new1", "new_xml": "<mxCell id=\\"new1\\" value=\\"New Box\\" style=\\"rounded=1;fillColor=#dae8fc;\\" x=\\"400\\" y=\\"200\\" w=\\"120\\" h=\\"60\\"/>"}]}
|
||||||
\`\`\`
|
\`\`\`
|
||||||
|
|
||||||
Delete container (children & edges auto-deleted):
|
Delete container (children & edges auto-deleted):
|
||||||
@@ -312,7 +310,7 @@ ${TWO_EDGES_EXAMPLE}
|
|||||||
|
|
||||||
### Edge with single waypoint (simple detour):
|
### Edge with single waypoint (simple detour):
|
||||||
\`\`\`xml
|
\`\`\`xml
|
||||||
<mxCell id="edge1" style="edgeStyle=orthogonalEdgeStyle;exitX=0.5;exitY=1;entryX=0.5;entryY=0;endArrow=classic;" edge="1" parent="1" source="a" target="b">
|
<mxCell id="edge1" style="edgeStyle=orthogonalEdgeStyle;exitX=0.5;exitY=1;entryX=0.5;entryY=0;" edge="1" parent="1" source="a" target="b">
|
||||||
<mxGeometry relative="1" as="geometry">
|
<mxGeometry relative="1" as="geometry">
|
||||||
<Array as="points">
|
<Array as="points">
|
||||||
<mxPoint x="300" y="150"/>
|
<mxPoint x="300" y="150"/>
|
||||||
|
|||||||
+44
-12
@@ -2,6 +2,8 @@ import { type ClassValue, clsx } from "clsx"
|
|||||||
import * as pako from "pako"
|
import * as pako from "pako"
|
||||||
import { twMerge } from "tailwind-merge"
|
import { twMerge } from "tailwind-merge"
|
||||||
import { hasCells } from "@/packages/mcp-server/src/pages.ts"
|
import { hasCells } from "@/packages/mcp-server/src/pages.ts"
|
||||||
|
import { readStyleClasses } from "@/packages/mcp-server/src/style-classes.ts"
|
||||||
|
import { repairQuoteBeforeSlash } from "@/packages/mcp-server/src/xml-validation.ts"
|
||||||
|
|
||||||
export function cn(...inputs: ClassValue[]) {
|
export function cn(...inputs: ClassValue[]) {
|
||||||
return twMerge(clsx(inputs))
|
return twMerge(clsx(inputs))
|
||||||
@@ -41,7 +43,12 @@ export function isRealDiagram(xml: string | undefined | null): boolean {
|
|||||||
* @returns true if XML appears complete, false if truncated or empty
|
* @returns true if XML appears complete, false if truncated or empty
|
||||||
*/
|
*/
|
||||||
export function isMxCellXmlComplete(xml: string | undefined | null): boolean {
|
export function isMxCellXmlComplete(xml: string | undefined | null): boolean {
|
||||||
const trimmed = xml?.trim() || ""
|
// Named style definitions before the cells are not cells: output cut off
|
||||||
|
// right after them is incomplete. A compact cell whose last quote is
|
||||||
|
// missing is complete; prepareNewDiagram repairs it.
|
||||||
|
const trimmed = repairQuoteBeforeSlash(
|
||||||
|
readStyleClasses(xml || "").xml,
|
||||||
|
).trim()
|
||||||
if (!trimmed) return false
|
if (!trimmed) return false
|
||||||
|
|
||||||
// Find position of last complete mxCell ending (either /> or </mxCell>)
|
// Find position of last complete mxCell ending (either /> or </mxCell>)
|
||||||
@@ -89,7 +96,9 @@ export function extractCompleteMxCells(xml: string | undefined | null): string {
|
|||||||
// Match self-closing <mxCell ... /> or <mxCell ...>...</mxCell>, in document order.
|
// Match self-closing <mxCell ... /> or <mxCell ...>...</mxCell>, in document order.
|
||||||
// The lazy [^>]*? tries "/>" first, so a self-closing cell never swallows
|
// The lazy [^>]*? tries "/>" first, so a self-closing cell never swallows
|
||||||
// the following cells up to the next </mxCell>.
|
// the following cells up to the next </mxCell>.
|
||||||
const cellPattern = /<mxCell\b[^>]*?(?:\/>|>[\s\S]*?<\/mxCell>)/g
|
// Quoted values may hold ">", so the tag ends at the first ">" outside them
|
||||||
|
const cellPattern =
|
||||||
|
/<mxCell\b(?:[^<>"']|"[^"]*"|'[^']*')*?(?:\/>|>[\s\S]*?<\/mxCell>)/g
|
||||||
|
|
||||||
return (xml.match(cellPattern) || []).join("\n")
|
return (xml.match(cellPattern) || []).join("\n")
|
||||||
}
|
}
|
||||||
@@ -152,7 +161,8 @@ export function formatXML(xml: string, indent: string = " "): string {
|
|||||||
export function convertToLegalXml(xmlString: string): string {
|
export function convertToLegalXml(xmlString: string): string {
|
||||||
// This regex will match either self-closing <mxCell .../> or a block element
|
// This regex will match either self-closing <mxCell .../> or a block element
|
||||||
// <mxCell ...> ... </mxCell>. Unfinished ones are left out because they don't match.
|
// <mxCell ...> ... </mxCell>. Unfinished ones are left out because they don't match.
|
||||||
const regex = /<mxCell\b[^>]*(?:\/>|>([\s\S]*?)<\/mxCell>)/g
|
const regex =
|
||||||
|
/<mxCell\b(?:[^<>"']|"[^"]*"|'[^']*')*?(?:\/>|>([\s\S]*?)<\/mxCell>)/g
|
||||||
let match: RegExpExecArray | null
|
let match: RegExpExecArray | null
|
||||||
let result = "<root>\n"
|
let result = "<root>\n"
|
||||||
|
|
||||||
@@ -217,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")
|
||||||
@@ -241,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")
|
||||||
@@ -320,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))
|
||||||
@@ -347,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
+110
-1291
File diff suppressed because it is too large
Load Diff
+2
-2
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "next-ai-draw-io",
|
"name": "next-ai-draw-io",
|
||||||
"version": "0.4.16",
|
"version": "0.5.0",
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"main": "dist-electron/main/index.js",
|
"main": "dist-electron/main/index.js",
|
||||||
@@ -127,7 +127,7 @@
|
|||||||
"@vitest/coverage-v8": "^4.0.16",
|
"@vitest/coverage-v8": "^4.0.16",
|
||||||
"concurrently": "^9.2.1",
|
"concurrently": "^9.2.1",
|
||||||
"cross-env": "^10.1.0",
|
"cross-env": "^10.1.0",
|
||||||
"electron": "^39.2.7",
|
"electron": "^41.10.6",
|
||||||
"electron-builder": "^26.0.12",
|
"electron-builder": "^26.0.12",
|
||||||
"esbuild": "^0.28.0",
|
"esbuild": "^0.28.0",
|
||||||
"eslint": "9.39.5",
|
"eslint": "9.39.5",
|
||||||
|
|||||||
Generated
+7
-3
@@ -2746,9 +2746,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/proxy-addr": {
|
"node_modules/proxy-addr": {
|
||||||
"version": "2.0.7",
|
"version": "2.0.8",
|
||||||
"resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz",
|
"resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.8.tgz",
|
||||||
"integrity": "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==",
|
"integrity": "sha512-5nnx0yGyVUcY6t9RnWcARWtwT9F1D8O9rt08htPvnd49W1IgZtmLkhu9WfMzQj1cFxjHIO6connUNVW5k7AVyQ==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"forwarded": "0.2.0",
|
"forwarded": "0.2.0",
|
||||||
@@ -2756,6 +2756,10 @@
|
|||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">= 0.10"
|
"node": ">= 0.10"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"type": "opencollective",
|
||||||
|
"url": "https://opencollective.com/express"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/qs": {
|
"node_modules/qs": {
|
||||||
|
|||||||
@@ -0,0 +1,196 @@
|
|||||||
|
/**
|
||||||
|
* Compact cells: the short way the model writes shapes and edges.
|
||||||
|
*
|
||||||
|
* A shape is one self-closing mxCell with its position and size as x, y, w
|
||||||
|
* and h attributes; an edge is one with source and target. The fixed parts
|
||||||
|
* of draw.io's XML are left out and filled in here:
|
||||||
|
*
|
||||||
|
* <mxCell id="2" value="Start" style="step;" x="40" y="40" w="120" h="60"/>
|
||||||
|
* <mxCell id="5" style="down;" source="2" target="3"/>
|
||||||
|
*
|
||||||
|
* becomes
|
||||||
|
*
|
||||||
|
* <mxCell id="2" value="Start" style="step;" vertex="1" parent="1">
|
||||||
|
* <mxGeometry x="40" y="40" width="120" height="60" as="geometry"/>
|
||||||
|
* </mxCell>
|
||||||
|
* <mxCell id="5" style="down;" edge="1" parent="1" source="2" target="3">
|
||||||
|
* <mxGeometry relative="1" as="geometry"/>
|
||||||
|
* </mxCell>
|
||||||
|
*
|
||||||
|
* The long form stays accepted, and anything beyond the plain case (edge
|
||||||
|
* waypoints, a label on an edge, a parent other than the layer) is written
|
||||||
|
* the long way, so a cell may mix both: compact attributes with an explicit
|
||||||
|
* parent, for example. A cell is an edge when it says so or connects a
|
||||||
|
* source or target, a shape when it says so, has a size or has a geometry.
|
||||||
|
*
|
||||||
|
* foldCells is the reverse, for the diagram shown to the model: it writes
|
||||||
|
* the plain shapes and edges compactly and leaves everything else as it is,
|
||||||
|
* so the model reads the notation it is asked to write. Folding then
|
||||||
|
* expanding gives the same cells back.
|
||||||
|
*/
|
||||||
|
import { readAttributes, type TagAttribute } from "./xml-attributes.ts"
|
||||||
|
|
||||||
|
// A whole mxCell: self-closing, or with its children (cells never nest)
|
||||||
|
const CELL_BLOCK =
|
||||||
|
/<mxCell\b((?:[^<>"']|"[^"]*"|'[^']*')*?)\s*(?:\/>|>([\s\S]*?)<\/mxCell>)/g
|
||||||
|
|
||||||
|
// The cell's own geometry, as opposed to one inside its custom data
|
||||||
|
const OWN_GEOMETRY = /<mxGeometry\b(?:[^<>"']|"[^"]*"|'[^']*')*?\bas="geometry"/
|
||||||
|
|
||||||
|
// The four compact attributes and the names draw.io uses for them
|
||||||
|
const SIZE_ATTRS = new Map([
|
||||||
|
["x", "x"],
|
||||||
|
["y", "y"],
|
||||||
|
["w", "width"],
|
||||||
|
["h", "height"],
|
||||||
|
["width", "width"],
|
||||||
|
["height", "height"],
|
||||||
|
])
|
||||||
|
const isSize = (name: string) => SIZE_ATTRS.has(name)
|
||||||
|
|
||||||
|
// Attributes a cell is written with, in draw.io's usual order
|
||||||
|
const FIRST = ["id", "value", "style"]
|
||||||
|
const LAST = ["vertex", "edge", "parent", "source", "target"]
|
||||||
|
|
||||||
|
// A value read from a single-quoted attribute may hold a double quote
|
||||||
|
const attr = (name: string, value: string) =>
|
||||||
|
` ${name}="${value.replace(/"/g, """)}"`
|
||||||
|
const attributeText = (attrs: TagAttribute[]) =>
|
||||||
|
attrs.map((a) => attr(a.name, a.value)).join("")
|
||||||
|
|
||||||
|
/** The id of the page's first layer (a cell whose parent is "0"), or "1" */
|
||||||
|
export function defaultLayerOf(xml: string): string {
|
||||||
|
for (const [, attrText] of xml.matchAll(CELL_BLOCK)) {
|
||||||
|
const attrs = new Map(
|
||||||
|
readAttributes(attrText).map((a) => [a.name, a.value]),
|
||||||
|
)
|
||||||
|
if (attrs.get("parent") === "0" && attrs.get("id")) {
|
||||||
|
return attrs.get("id") ?? "1"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return "1"
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Turn compact shapes and edges into standard draw.io cells. A cell with no
|
||||||
|
* parent goes on `layer`, the page's first layer ("1" on a new page).
|
||||||
|
*/
|
||||||
|
export function expandCompactCells(xml: string, layer = "1"): string {
|
||||||
|
return xml.replace(CELL_BLOCK, (block, attrText: string, body?: string) => {
|
||||||
|
const attrs = readAttributes(attrText)
|
||||||
|
const byName = new Map(attrs.map((a) => [a.name, a.value]))
|
||||||
|
const size = attrs.filter((a) => isSize(a.name))
|
||||||
|
const inner = body ?? ""
|
||||||
|
const hasGeometry = OWN_GEOMETRY.test(inner)
|
||||||
|
const isEdge =
|
||||||
|
byName.get("edge") === "1" ||
|
||||||
|
(byName.get("vertex") !== "1" &&
|
||||||
|
(byName.has("source") || byName.has("target")))
|
||||||
|
const isVertex =
|
||||||
|
!isEdge &&
|
||||||
|
(byName.get("vertex") === "1" || size.length > 0 || hasGeometry)
|
||||||
|
// Root cells and anything else the model wrote in full are left alone
|
||||||
|
if (!isEdge && !isVertex) return block
|
||||||
|
const complete =
|
||||||
|
size.length === 0 &&
|
||||||
|
byName.has(isEdge ? "edge" : "vertex") &&
|
||||||
|
byName.has("parent") &&
|
||||||
|
hasGeometry
|
||||||
|
if (complete) return block
|
||||||
|
|
||||||
|
// The compact attributes become the geometry; when the cell has its
|
||||||
|
// own geometry already they stay as written, in case they mean
|
||||||
|
// something else to whoever wrote them
|
||||||
|
const consumed = !hasGeometry && isVertex
|
||||||
|
const rest = attrs.filter(
|
||||||
|
(a) =>
|
||||||
|
!FIRST.includes(a.name) &&
|
||||||
|
!LAST.includes(a.name) &&
|
||||||
|
!(consumed && isSize(a.name)) &&
|
||||||
|
!(isEdge && isSize(a.name)),
|
||||||
|
)
|
||||||
|
// Rebuilt in the usual order: id, value, style, the rest, then the
|
||||||
|
// flags and the connections
|
||||||
|
const ordered: [string, string][] = [
|
||||||
|
...FIRST.flatMap((n): [string, string][] =>
|
||||||
|
byName.has(n) ? [[n, byName.get(n) ?? ""]] : [],
|
||||||
|
),
|
||||||
|
...rest.map((a): [string, string] => [a.name, a.value]),
|
||||||
|
[isEdge ? "edge" : "vertex", "1"],
|
||||||
|
["parent", byName.get("parent") ?? layer],
|
||||||
|
...["source", "target"].flatMap((n): [string, string][] =>
|
||||||
|
byName.has(n) ? [[n, byName.get(n) ?? ""]] : [],
|
||||||
|
),
|
||||||
|
]
|
||||||
|
const written = ordered.map(([n, v]) => attr(n, v)).join("")
|
||||||
|
let geometry = ""
|
||||||
|
if (!hasGeometry) {
|
||||||
|
if (isEdge) {
|
||||||
|
geometry = '<mxGeometry relative="1" as="geometry"/>'
|
||||||
|
} else {
|
||||||
|
const get = (short: string, long: string, fallback: string) =>
|
||||||
|
byName.get(short) ?? byName.get(long) ?? fallback
|
||||||
|
geometry = `<mxGeometry${attr("x", get("x", "x", "0"))}${attr("y", get("y", "y", "0"))}${attr("width", get("w", "width", "120"))}${attr("height", get("h", "height", "60"))} as="geometry"/>`
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return `<mxCell${written}>${geometry}${inner}</mxCell>`
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Attributes of a single self-closing mxGeometry, or null for anything else */
|
||||||
|
function plainGeometry(body: string): Map<string, string> | null {
|
||||||
|
const trimmed = body.trim()
|
||||||
|
const m = trimmed.match(
|
||||||
|
/^<mxGeometry\b((?:[^<>"']|"[^"]*"|'[^']*')*?)\s*\/>$/,
|
||||||
|
)
|
||||||
|
if (!m) return null
|
||||||
|
const attrs = new Map(readAttributes(m[1]).map((a) => [a.name, a.value]))
|
||||||
|
// Anything odd, such as a wrong "as", stays as written so folding and
|
||||||
|
// expanding give the same cells back
|
||||||
|
return attrs.get("as") === "geometry" ? attrs : null
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Write plain shapes and edges compactly, for the diagram shown to the
|
||||||
|
* model. A shape is plain when its only child is an mxGeometry with x, y,
|
||||||
|
* width and height; an edge when its only child is the relative geometry.
|
||||||
|
* Both must name their parent, since expanding fills in the layer for a
|
||||||
|
* missing one. Everything else, including wrapped cells' extra data and
|
||||||
|
* edge labels, is left as written.
|
||||||
|
*/
|
||||||
|
export function foldCells(xml: string, layer = "1"): string {
|
||||||
|
return xml.replace(CELL_BLOCK, (block, attrText: string, body?: string) => {
|
||||||
|
if (body === undefined) return block
|
||||||
|
const attrs = readAttributes(attrText)
|
||||||
|
const byName = new Map(attrs.map((a) => [a.name, a.value]))
|
||||||
|
if (attrs.some((a) => isSize(a.name))) return block
|
||||||
|
if (!byName.has("parent")) return block
|
||||||
|
const geometry = plainGeometry(body)
|
||||||
|
if (!geometry) return block
|
||||||
|
const geometryKeys = [...geometry.keys()]
|
||||||
|
.filter((k) => k !== "as")
|
||||||
|
.sort()
|
||||||
|
const kept = attrs.filter(
|
||||||
|
(a) =>
|
||||||
|
a.name !== "vertex" &&
|
||||||
|
a.name !== "edge" &&
|
||||||
|
!(a.name === "parent" && a.value === layer),
|
||||||
|
)
|
||||||
|
if (byName.get("vertex") === "1" && byName.get("edge") !== "1") {
|
||||||
|
if (geometryKeys.join() !== "height,width,x,y") return block
|
||||||
|
const size = `${attr("x", geometry.get("x") ?? "")}${attr("y", geometry.get("y") ?? "")}${attr("w", geometry.get("width") ?? "")}${attr("h", geometry.get("height") ?? "")}`
|
||||||
|
return `<mxCell${attributeText(kept)}${size}/>`
|
||||||
|
}
|
||||||
|
if (byName.get("edge") === "1" && byName.get("vertex") !== "1") {
|
||||||
|
if (
|
||||||
|
geometryKeys.join() !== "relative" ||
|
||||||
|
geometry.get("relative") !== "1"
|
||||||
|
) {
|
||||||
|
return block
|
||||||
|
}
|
||||||
|
if (!byName.has("source") || !byName.has("target")) return block
|
||||||
|
return `<mxCell${attributeText(kept)}/>`
|
||||||
|
}
|
||||||
|
return block
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -9,6 +9,7 @@
|
|||||||
|
|
||||||
import {
|
import {
|
||||||
indent,
|
indent,
|
||||||
|
STYLE_CLASS_EXAMPLE,
|
||||||
SWIMLANE_EXAMPLE,
|
SWIMLANE_EXAMPLE,
|
||||||
TWO_EDGES_EXAMPLE,
|
TWO_EDGES_EXAMPLE,
|
||||||
WAYPOINT_EXAMPLE,
|
WAYPOINT_EXAMPLE,
|
||||||
@@ -26,21 +27,17 @@ export const DRAWING_GUIDE = `# Draw.io drawing guide
|
|||||||
- The preview page has History (it saves a snapshot before every AI change and can restore any of the last 20 versions) and Download. You can make changes freely; nothing is lost.
|
- The preview page has History (it saves a snapshot before every AI change and can restore any of the last 20 versions) and Download. You can make changes freely; nothing is lost.
|
||||||
|
|
||||||
## The XML you send
|
## The XML you send
|
||||||
Single page (create_new_diagram, add_page): send ONLY the mxCell elements. The server adds <mxfile>, <mxGraphModel>, <root> and the root cells id="0" and id="1".
|
Single page (create_new_diagram, add_page): send ONLY the named styles and the mxCell elements. The server adds <mxfile>, <mxGraphModel>, <root> and the root cells id="0" and id="1", expands named styles (see Styles), adds html=1 to every cell and whiteSpace=wrap to shapes, and fills in vertex, edge, parent="1" and the mxGeometry element. A shape is one self-closing mxCell with x, y, w and h; an edge is one with source and target. A cell with source or target is always an edge. Write parent only for a shape inside a container, and an mxGeometry element only for edge waypoints or for a separate label cell placed on an edge: <mxCell id="9" value="yes" style="edgeLabel;" parent="<edge id>" connectable="0"><mxGeometry x="-0.5" relative="1" as="geometry"/></mxCell>. An edge's own text simply goes in its value.
|
||||||
|
|
||||||
<mxCell id="2" value="Label" style="rounded=1;whiteSpace=wrap;html=1;" vertex="1" parent="1">
|
<mxCell id="2" value="Label" style="rounded=1;" x="100" y="100" w="120" h="60"/>
|
||||||
<mxGeometry x="100" y="100" width="120" height="60" as="geometry"/>
|
<mxCell id="3" style="edgeStyle=orthogonalEdgeStyle;exitX=1;exitY=0.5;entryX=0;entryY=0.5;" source="2" target="4"/>
|
||||||
</mxCell>
|
|
||||||
<mxCell id="3" style="edgeStyle=orthogonalEdgeStyle;exitX=1;exitY=0.5;entryX=0;entryY=0.5;endArrow=classic;html=1;" edge="1" parent="1" source="2" target="4">
|
|
||||||
<mxGeometry relative="1" as="geometry"/>
|
|
||||||
</mxCell>
|
|
||||||
|
|
||||||
Several pages at once (create_new_diagram only): send a full <mxfile> with one <diagram id="..." name="..."> per page. Every page's <root> must start with <mxCell id="0"/><mxCell id="1" parent="0"/>.
|
Several pages at once (create_new_diagram only): send a full <mxfile> with one <diagram id="..." name="..."> per page. Every page's <root> must start with <mxCell id="0"/><mxCell id="1" parent="0"/>.
|
||||||
|
|
||||||
Rules (XML that breaks them is rejected):
|
Rules (XML that breaks them is rejected):
|
||||||
1. All mxCell elements are siblings. NEVER nest an mxCell inside another mxCell.
|
1. All mxCell elements are siblings. NEVER nest an mxCell inside another mxCell.
|
||||||
2. Ids are unique within a page and start from "2" ("0" and "1" are the root cells).
|
2. Ids are unique within a page and start from "2" ("0" and "1" are the root cells).
|
||||||
3. parent="1" for top-level shapes, parent="<container id>" for shapes inside a container.
|
3. parent="<container id>" for shapes inside a container; top-level cells need no parent.
|
||||||
4. Edge source and target must reference existing cell ids.
|
4. Edge source and target must reference existing cell ids.
|
||||||
5. Escape special characters in attribute values: < for <, > for >, & for &, " for ".
|
5. Escape special characters in attribute values: < for <, > for >, & for &, " for ".
|
||||||
6. NEVER include XML comments (<!-- -->). draw.io strips them.
|
6. NEVER include XML comments (<!-- -->). draw.io strips them.
|
||||||
@@ -59,7 +56,7 @@ ${indent(SWIMLANE_EXAMPLE)}
|
|||||||
## Edge routing rules
|
## Edge routing rules
|
||||||
Rule 1: Never let two edges share a path. Two edges between the same nodes exit and enter at different points (exitY=0.3 for the first, exitY=0.7 for the second, not both 0.5).
|
Rule 1: Never let two edges share a path. Two edges between the same nodes exit and enter at different points (exitY=0.3 for the first, exitY=0.7 for the second, not both 0.5).
|
||||||
Rule 2: For bidirectional connections (A to B and B to A), use opposite sides: A exits right (exitX=1) into the left of B (entryX=0); B exits left (exitX=0) into the right of A (entryX=1).
|
Rule 2: For bidirectional connections (A to B and B to A), use opposite sides: A exits right (exitX=1) into the left of B (entryX=0); B exits left (exitX=0) into the right of A (entryX=1).
|
||||||
Rule 3: Always set exitX, exitY, entryX and entryY in the edge style, e.g. style="edgeStyle=orthogonalEdgeStyle;exitX=1;exitY=0.3;entryX=0;entryY=0.3;endArrow=classic;".
|
Rule 3: Always set exitX, exitY, entryX and entryY in the edge style, e.g. style="edgeStyle=orthogonalEdgeStyle;exitX=1;exitY=0.3;entryX=0;entryY=0.3;".
|
||||||
Rule 4: Route edges AROUND shapes in the way. Before drawing an edge, find every shape between source and target; if one is in the path, add waypoints. Route diagonal connections along the outside of the diagram, not through the middle. Keep 20-30px clearance from shapes. An edge must never cross another shape's box.
|
Rule 4: Route edges AROUND shapes in the way. Before drawing an edge, find every shape between source and target; if one is in the path, add waypoints. Route diagonal connections along the outside of the diagram, not through the middle. Keep 20-30px clearance from shapes. An edge must never cross another shape's box.
|
||||||
Rule 5: Plan the layout first. Organize shapes into rows or columns following the flow, space them 150-200px apart so edges have room, and prefer one flow direction (left to right or top to bottom).
|
Rule 5: Plan the layout first. Organize shapes into rows or columns following the flow, space them 150-200px apart so edges have room, and prefer one flow direction (left to right or top to bottom).
|
||||||
Rule 6: Use 2-3 waypoints for L-shaped or U-shaped paths. Each change of direction needs a waypoint, and segments should be horizontal or vertical.
|
Rule 6: Use 2-3 waypoints for L-shaped or U-shaped paths. Each change of direction needs a waypoint, and segments should be horizontal or vertical.
|
||||||
@@ -80,27 +77,33 @@ Waypoints go inside <Array as="points"> in the edge geometry. Example: Hotfix (r
|
|||||||
${indent(WAYPOINT_EXAMPLE)}
|
${indent(WAYPOINT_EXAMPLE)}
|
||||||
|
|
||||||
## Styles
|
## Styles
|
||||||
- Shapes: rounded=1, fillColor=#hex, strokeColor=#hex, whiteSpace=wrap;html=1;
|
Define each style used by several cells ONCE, as a named style before the cells, and use the name in the cells like a CSS class. A cell's style can combine a shape token, a name and overrides; later pairs win. Name only styles that two or more cells share; a style used by one cell stays inline. Names must not be draw.io's own style names: shapes such as text, ellipse, rhombus, swimlane, label, image, and colors such as blue, green, red, gray, yellow, orange, purple, pink. A definition applies to the call it is in: each create_new_diagram or add_page call defines the names it uses. The server expands the names, so the saved file is standard draw.io XML.
|
||||||
- Edges: endArrow=classic, block, open or none; startArrow=none or classic; curved=1; edgeStyle=orthogonalEdgeStyle
|
|
||||||
- Text: fontSize=14, fontStyle=1 (bold), align=center, left or right
|
${indent(STYLE_CLASS_EXAMPLE)}
|
||||||
|
|
||||||
|
- NEVER write html=1 or whiteSpace=wrap: the server adds html=1 to every cell and whiteSpace=wrap to shapes. Labels are HTML: use <br> for a line break and <b> for bold, never \\n; a literal < or > in a label is written &lt; or &gt;.
|
||||||
|
- Do NOT repeat what draw.io already uses. For a plain shape: rounded=0, align=center, verticalAlign=middle, fontSize=12, strokeWidth=1, fillColor=#ffffff, strokeColor=#000000, fontColor=#000000. For an edge: endArrow=classic, strokeColor=#000000. Writing one of them is right only when it overrides what a name or the shape sets: an edge is rounded by default, so rounded=0 on an edge is a real setting, and a text cell is left/top aligned by default, so there align=center or verticalAlign=middle are real settings.
|
||||||
|
- Shapes: rounded=1, fillColor=#hex, strokeColor=#hex
|
||||||
|
- Edges: endArrow=block, open or none; startArrow=classic; curved=1; dashed=1; edgeStyle=orthogonalEdgeStyle
|
||||||
|
- Text: fontSize=14, fontStyle=1 (bold), align=center or right
|
||||||
- Animated connectors: add flowAnimation=1 to the edge style.
|
- Animated connectors: add flowAnimation=1 to the edge style.
|
||||||
|
|
||||||
## Minimal style
|
## Minimal style
|
||||||
When the user asks for a minimal, plain, black-and-white or unstyled diagram, use these rules instead of the styles above:
|
When the user asks for a minimal, plain, black-and-white or unstyled diagram, use these rules instead of the styles above:
|
||||||
- No fillColor, strokeColor, rounded, fontSize, fontStyle or hex colors.
|
- No fillColor, strokeColor, rounded, fontSize, fontStyle or hex colors.
|
||||||
- Shapes use style "whiteSpace=wrap;html=1;", edges use "html=1;endArrow=classic;".
|
- Shapes have no style, or only the shape (ellipse, rhombus); edges have edgeStyle=orthogonalEdgeStyle plus the exit and entry points from the routing rules, nothing else.
|
||||||
- Containers that hold other shapes use "whiteSpace=wrap;html=1;fillColor=none;" so they do not cover their children.
|
- Containers that hold other shapes use "fillColor=none;" so they do not cover their children.
|
||||||
- Keep at least 50px between elements, and follow all edge routing rules strictly.
|
- Keep at least 50px between elements, and follow all edge routing rules strictly.
|
||||||
|
|
||||||
## Editing with edit_diagram
|
## Editing with edit_diagram
|
||||||
- update replaces a cell: send the complete mxCell including mxGeometry, with the same id as cell_id.
|
- update replaces a cell: send the complete mxCell in the same compact form (x, y, w, h for a shape), with the same id as cell_id. Named styles are not available here: write each cell's complete style.
|
||||||
- add inserts a new cell with a new id. One cell per operation.
|
- add inserts a new cell with a new id. One cell per operation.
|
||||||
- delete removes a cell. Its children and every edge connected to it are deleted too, so give only the container's id.
|
- delete removes a cell. Its children and every edge connected to it are deleted too, so give only the container's id.
|
||||||
- All-or-nothing: if any operation fails, nothing is applied. A rejected call includes the current XML of the page; rebuild your operations on it and retry.
|
- All-or-nothing: if any operation fails, nothing is applied. A rejected call includes the current XML of the page; rebuild your operations on it and retry.
|
||||||
- If the diagram is large, change it with edit_diagram instead of redrawing it.
|
- If the diagram is large, change it with edit_diagram instead of redrawing it.
|
||||||
|
|
||||||
{"operations": [{"operation": "update", "cell_id": "3", "new_xml": "<mxCell id=\\"3\\" value=\\"New Label\\" style=\\"rounded=1;\\" vertex=\\"1\\" parent=\\"1\\"><mxGeometry x=\\"100\\" y=\\"100\\" width=\\"120\\" height=\\"60\\" as=\\"geometry\\"/></mxCell>"}]}
|
{"operations": [{"operation": "update", "cell_id": "3", "new_xml": "<mxCell id=\\"3\\" value=\\"New Label\\" style=\\"rounded=1;\\" x=\\"100\\" y=\\"100\\" w=\\"120\\" h=\\"60\\"/>"}]}
|
||||||
{"page_name": "CNN", "operations": [{"operation": "add", "cell_id": "conv-1", "new_xml": "<mxCell id=\\"conv-1\\" value=\\"Conv\\" vertex=\\"1\\" parent=\\"1\\"><mxGeometry x=\\"40\\" y=\\"40\\" width=\\"120\\" height=\\"60\\" as=\\"geometry\\"/></mxCell>"}]}
|
{"page_name": "CNN", "operations": [{"operation": "add", "cell_id": "conv-1", "new_xml": "<mxCell id=\\"conv-1\\" value=\\"Conv\\" x=\\"40\\" y=\\"40\\" w=\\"120\\" h=\\"60\\"/>"}]}
|
||||||
{"page_index": 1, "operations": [{"operation": "delete", "cell_id": "5"}]}
|
{"page_index": 1, "operations": [{"operation": "delete", "cell_id": "5"}]}
|
||||||
|
|
||||||
Pages: list_pages shows every page's id, name and index. edit_diagram, get_diagram and export_diagram take an optional page_id, page_name or page_index; without one they use the first page.
|
Pages: list_pages shows every page's id, name and index. edit_diagram, get_diagram and export_diagram take an optional page_id, page_name or page_index; without one they use the first page.
|
||||||
|
|||||||
@@ -7,11 +7,13 @@
|
|||||||
* so the model never builds on a half-applied edit.
|
* so the model never builds on a half-applied edit.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
import { defaultLayerOf, expandCompactCells } from "./compact-cells.ts"
|
||||||
import {
|
import {
|
||||||
applyDiagramOperations,
|
applyDiagramOperations,
|
||||||
type DiagramOperation,
|
type DiagramOperation,
|
||||||
} from "./diagram-operations.ts"
|
} from "./diagram-operations.ts"
|
||||||
import { type PageSelector, projectPage } from "./pages.ts"
|
import { type PageSelector, projectPage } from "./pages.ts"
|
||||||
|
import { addDefaultStyles, edgeIdsOf } from "./style-classes.ts"
|
||||||
import { validateAndFixXml, validateMxCellStructure } from "./xml-validation.ts"
|
import { validateAndFixXml, validateMxCellStructure } from "./xml-validation.ts"
|
||||||
|
|
||||||
export type EditOutcome =
|
export type EditOutcome =
|
||||||
@@ -48,11 +50,22 @@ export function editDiagram(
|
|||||||
const fixes: string[] = []
|
const fixes: string[] = []
|
||||||
const prepared: DiagramOperation[] = []
|
const prepared: DiagramOperation[] = []
|
||||||
|
|
||||||
|
// Edges already on the page, so a label added to one is not wrapped, and
|
||||||
|
// the layer a compact cell without a parent goes on
|
||||||
|
const page = targetPageXml(xml, selector)
|
||||||
|
const edges = edgeIdsOf(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) {
|
||||||
prepared.push(op)
|
prepared.push(op)
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
if (/<mxStyle\b/i.test(op.new_xml)) {
|
||||||
|
errors.push(
|
||||||
|
`${op.operation} ${op.cell_id}: named styles (mxStyle) are not available in edit_diagram; write the cell's complete style`,
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
}
|
||||||
// Checked before validation: several cells fail the strict parser
|
// Checked before validation: several cells fail the strict parser
|
||||||
// with a misleading "only one root" syntax error.
|
// with a misleading "only one root" syntax error.
|
||||||
if (countTopLevelElements(op.new_xml) > 1) {
|
if (countTopLevelElements(op.new_xml) > 1) {
|
||||||
@@ -71,7 +84,15 @@ export function editDiagram(
|
|||||||
if (check.fixed) {
|
if (check.fixed) {
|
||||||
fixes.push(`${op.cell_id}: ${check.fixes.join(", ")}`)
|
fixes.push(`${op.cell_id}: ${check.fixes.join(", ")}`)
|
||||||
}
|
}
|
||||||
prepared.push({ ...op, new_xml: check.fixed ?? op.new_xml })
|
// The defaults the model is told not to write (style-classes.ts),
|
||||||
|
// added after the fixes so a repaired cell gets them too
|
||||||
|
prepared.push({
|
||||||
|
...op,
|
||||||
|
new_xml: addDefaultStyles(
|
||||||
|
expandCompactCells(check.fixed ?? op.new_xml, layer),
|
||||||
|
edges,
|
||||||
|
),
|
||||||
|
})
|
||||||
}
|
}
|
||||||
if (errors.length > 0) return { ok: false, errors, pageError: false }
|
if (errors.length > 0) return { ok: false, errors, pageError: false }
|
||||||
|
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"
|
|||||||
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"
|
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"
|
||||||
import open from "open"
|
import open from "open"
|
||||||
import { z } from "zod"
|
import { z } from "zod"
|
||||||
|
import { expandCompactCells, foldCells } from "./compact-cells.ts"
|
||||||
import type { DiagramOperation } from "./diagram-operations.ts"
|
import type { DiagramOperation } from "./diagram-operations.ts"
|
||||||
import { installDomPolyfill } from "./dom.ts"
|
import { installDomPolyfill } from "./dom.ts"
|
||||||
import { DRAWING_GUIDE } from "./drawing-guide.ts"
|
import { DRAWING_GUIDE } from "./drawing-guide.ts"
|
||||||
@@ -48,7 +49,11 @@ import {
|
|||||||
} from "./http-server.ts"
|
} from "./http-server.ts"
|
||||||
import { parseDrawioFileContent } from "./load-diagram.ts"
|
import { parseDrawioFileContent } from "./load-diagram.ts"
|
||||||
import { log } from "./logger.ts"
|
import { log } from "./logger.ts"
|
||||||
import { prepareNewDiagram, reservedIdError } from "./new-diagram.ts"
|
import {
|
||||||
|
prepareNewDiagram,
|
||||||
|
reservedIdError,
|
||||||
|
takeStyleDefinitions,
|
||||||
|
} from "./new-diagram.ts"
|
||||||
import {
|
import {
|
||||||
addPageToDoc,
|
addPageToDoc,
|
||||||
deletePageFromDoc,
|
deletePageFromDoc,
|
||||||
@@ -66,6 +71,7 @@ import {
|
|||||||
} from "./pages.ts"
|
} from "./pages.ts"
|
||||||
import { Autosaver, defaultDataDir, expandHome } from "./persistence.ts"
|
import { Autosaver, defaultDataDir, expandHome } from "./persistence.ts"
|
||||||
import { getShapeLibrary, SHAPE_LIBRARY_LIST } from "./shape-library.ts"
|
import { getShapeLibrary, SHAPE_LIBRARY_LIST } from "./shape-library.ts"
|
||||||
|
import { addDefaultStyles, applyStyleClasses } from "./style-classes.ts"
|
||||||
import { validateAndFixXml } from "./xml-validation.ts"
|
import { validateAndFixXml } from "./xml-validation.ts"
|
||||||
|
|
||||||
// DOMParser/XMLSerializer globals for the XML helpers (Node has neither)
|
// DOMParser/XMLSerializer globals for the XML helpers (Node has neither)
|
||||||
@@ -336,11 +342,11 @@ Before using icon shapes (AWS, Azure, GCP, Kubernetes, Cisco...), call get_shape
|
|||||||
|
|
||||||
Accepted xml:
|
Accepted xml:
|
||||||
1) Only the mxCell elements of one page (recommended). The server adds <mxfile>, <mxGraphModel>, <root> and the root cells "0" and "1":
|
1) Only the mxCell elements of one page (recommended). The server adds <mxfile>, <mxGraphModel>, <root> and the root cells "0" and "1":
|
||||||
<mxCell id="2" value="Shape" style="rounded=1;whiteSpace=wrap;html=1;" vertex="1" parent="1"><mxGeometry x="40" y="40" width="120" height="60" as="geometry"/></mxCell>
|
<mxCell id="2" value="Shape" style="rounded=1;" x="40" y="40" w="120" h="60"/>
|
||||||
2) A bare <mxGraphModel> with <root> (one page).
|
2) A bare <mxGraphModel> with <root> (one page).
|
||||||
3) A full <mxfile> with one or more <diagram> pages. Every page's <root> must start with <mxCell id="0"/><mxCell id="1" parent="0"/>.
|
3) A full <mxfile> with one or more <diagram> pages. Every page's <root> must start with <mxCell id="0"/><mxCell id="1" parent="0"/>.
|
||||||
|
|
||||||
Rules: cells are siblings (never nested), ids are unique per page and start from "2", parent="1" for top-level shapes, no XML comments, and shapes stay within x 0 to 800 and y 0 to 600.`,
|
Rules: cells are siblings (never nested), ids are unique per page and start from "2", parent only for shapes inside a container, no XML comments, and shapes stay within x 0 to 800 and y 0 to 600. A style used by several cells is defined once with <mxStyle name="..." value="..."/> before the cells and used by name (see the drawing guide); html=1 and whiteSpace=wrap are added automatically.`,
|
||||||
inputSchema: {
|
inputSchema: {
|
||||||
xml: z
|
xml: z
|
||||||
.string()
|
.string()
|
||||||
@@ -589,10 +595,10 @@ registerWriteTool(
|
|||||||
"- add: Add a new cell. Provide cell_id (new unique id within the page) and new_xml. One cell per operation.\n" +
|
"- add: Add a new cell. Provide cell_id (new unique id within the page) and new_xml. One cell per operation.\n" +
|
||||||
"- update: Replace an existing cell by its id. Provide cell_id and complete new_xml.\n" +
|
"- update: Replace an existing cell by its id. Provide cell_id and complete new_xml.\n" +
|
||||||
"- delete: Remove a cell by its id. Only cell_id is needed. Its children and connected edges are deleted too, so give only a container's id.\n\n" +
|
"- delete: Remove a cell by its id. Only cell_id is needed. Its children and connected edges are deleted too, so give only a container's id.\n\n" +
|
||||||
"For add/update, new_xml must be a complete mxCell element including mxGeometry. No XML comments. " +
|
"For add/update, new_xml is the complete mxCell in the compact form (a shape with x, y, w, h; an edge with source and target). No XML comments. " +
|
||||||
'Every " inside new_xml must be escaped as \\" in the JSON.\n\n' +
|
'Every " inside new_xml must be escaped as \\" in the JSON.\n\n' +
|
||||||
"Example - Add a rectangle on the default (first) page:\n" +
|
"Example - Add a rectangle on the default (first) page:\n" +
|
||||||
'{"operations": [{"operation": "add", "cell_id": "rect-1", "new_xml": "<mxCell id=\\"rect-1\\" value=\\"Hello\\" style=\\"rounded=0;\\" vertex=\\"1\\" parent=\\"1\\"><mxGeometry x=\\"100\\" y=\\"100\\" width=\\"120\\" height=\\"60\\" as=\\"geometry\\"/></mxCell>"}]}\n\n' +
|
'{"operations": [{"operation": "add", "cell_id": "rect-1", "new_xml": "<mxCell id=\\"rect-1\\" value=\\"Hello\\" style=\\"rounded=1;\\" x=\\"100\\" y=\\"100\\" w=\\"120\\" h=\\"60\\"/>"}]}\n\n' +
|
||||||
"Example - Delete a cell on the default page:\n" +
|
"Example - Delete a cell on the default page:\n" +
|
||||||
'{"operations": [{"operation": "delete", "cell_id": "rect-1"}]}',
|
'{"operations": [{"operation": "delete", "cell_id": "rect-1"}]}',
|
||||||
inputSchema: {
|
inputSchema: {
|
||||||
@@ -700,7 +706,7 @@ registerWriteTool(
|
|||||||
content: [
|
content: [
|
||||||
{
|
{
|
||||||
type: "text",
|
type: "text",
|
||||||
text: `Error: ${reason}\n\nCurrent XML of ${describeSelector(pageSelector)}:\n\n${targetPageXml(currentSession.xml, pageSelector)}\n\n${next}`,
|
text: `Error: ${reason}\n\nCurrent XML of ${describeSelector(pageSelector)}:\n\n${foldCells(targetPageXml(currentSession.xml, pageSelector))}\n\n${next}`,
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
isError: true,
|
isError: true,
|
||||||
@@ -720,7 +726,7 @@ registerWriteTool(
|
|||||||
log.warn(`Edit rejected: ${outcome.errors.join("; ")}`)
|
log.warn(`Edit rejected: ${outcome.errors.join("; ")}`)
|
||||||
const text = outcome.pageError
|
const text = outcome.pageError
|
||||||
? `Error: ${outcome.errors[0]}`
|
? `Error: ${outcome.errors[0]}`
|
||||||
: `Error: No changes were made because ${outcome.errors.length} operation(s) failed:\n${outcome.errors.map((e) => `- ${e}`).join("\n")}\n\nCurrent XML of ${describeSelector(pageSelector)}:\n\n${targetPageXml(currentSession.xml, pageSelector)}\n\nFix the operations against this XML and retry.`
|
: `Error: No changes were made because ${outcome.errors.length} operation(s) failed:\n${outcome.errors.map((e) => `- ${e}`).join("\n")}\n\nCurrent XML of ${describeSelector(pageSelector)}:\n\n${foldCells(targetPageXml(currentSession.xml, pageSelector))}\n\nFix the operations against this XML and retry.`
|
||||||
return {
|
return {
|
||||||
content: [{ type: "text", text }],
|
content: [{ type: "text", text }],
|
||||||
isError: true,
|
isError: true,
|
||||||
@@ -869,7 +875,7 @@ server.registerTool(
|
|||||||
content: [
|
content: [
|
||||||
{
|
{
|
||||||
type: "text",
|
type: "text",
|
||||||
text: `Current diagram XML:\n\n${session.xml}\n\n${pageList}${staleNote}`,
|
text: `Current diagram XML:\n\n${foldCells(session.xml)}\n\n${pageList}${staleNote}`,
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
}
|
}
|
||||||
@@ -906,7 +912,7 @@ server.registerTool(
|
|||||||
content: [
|
content: [
|
||||||
{
|
{
|
||||||
type: "text",
|
type: "text",
|
||||||
text: `Page ${projection.index} ("${projection.name}"):\n\n${projection.xml}\n\n${pageList}${staleNote}${otherPagesNote}`,
|
text: `Page ${projection.index} ("${projection.name}"):\n\n${foldCells(projection.xml)}\n\n${pageList}${staleNote}${otherPagesNote}`,
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
}
|
}
|
||||||
@@ -1587,14 +1593,28 @@ registerWriteTool(
|
|||||||
|
|
||||||
// If caller provided XML, validate it before splicing it in so we
|
// If caller provided XML, validate it before splicing it in so we
|
||||||
// never get a half-broken mxfile written to the session.
|
// never get a half-broken mxfile written to the session.
|
||||||
const reserved = xml && reservedIdError(xml)
|
// Named style definitions come out first and are expanded on the
|
||||||
|
// validated XML, like prepareNewDiagram
|
||||||
|
const {
|
||||||
|
classes,
|
||||||
|
xml: startXml,
|
||||||
|
error: styleError,
|
||||||
|
} = takeStyleDefinitions(xml ?? "")
|
||||||
|
if (styleError) {
|
||||||
|
return {
|
||||||
|
content: [{ type: "text", text: `Error: ${styleError}` }],
|
||||||
|
isError: true,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const reserved = startXml && reservedIdError(startXml)
|
||||||
if (reserved) {
|
if (reserved) {
|
||||||
return {
|
return {
|
||||||
content: [{ type: "text", text: `Error: ${reserved}` }],
|
content: [{ type: "text", text: `Error: ${reserved}` }],
|
||||||
isError: true,
|
isError: true,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
let cleanXml: string | undefined = xml && wrapCellsInModel(xml)
|
let cleanXml: string | undefined =
|
||||||
|
startXml && wrapCellsInModel(startXml)
|
||||||
if (cleanXml) {
|
if (cleanXml) {
|
||||||
const { valid, error, fixed, fixes } =
|
const { valid, error, fixed, fixes } =
|
||||||
validateAndFixXml(cleanXml)
|
validateAndFixXml(cleanXml)
|
||||||
@@ -1615,6 +1635,9 @@ registerWriteTool(
|
|||||||
isError: true,
|
isError: true,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
cleanXml = addDefaultStyles(
|
||||||
|
applyStyleClasses(expandCompactCells(cleanXml), classes),
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
let info
|
let info
|
||||||
|
|||||||
@@ -2,8 +2,16 @@
|
|||||||
* A whole new diagram written by the model, for the create_new_diagram tool
|
* A whole new diagram written by the model, for the create_new_diagram tool
|
||||||
* and the web app's display_diagram tool.
|
* and the web app's display_diagram tool.
|
||||||
*/
|
*/
|
||||||
import { normalizeToMxfile, wrapCellsInModel } from "./pages.ts"
|
import { expandCompactCells } from "./compact-cells.ts"
|
||||||
|
import { hasCells, normalizeToMxfile, wrapCellsInModel } from "./pages.ts"
|
||||||
|
import {
|
||||||
|
addDefaultStyles,
|
||||||
|
applyStyleClasses,
|
||||||
|
readStyleClasses,
|
||||||
|
type StyleClasses,
|
||||||
|
} from "./style-classes.ts"
|
||||||
import { readAttributes } from "./xml-attributes.ts"
|
import { readAttributes } from "./xml-attributes.ts"
|
||||||
|
import { getXmlSyntaxError } from "./xml-syntax.ts"
|
||||||
import { validateAndFixXml } from "./xml-validation.ts"
|
import { validateAndFixXml } from "./xml-validation.ts"
|
||||||
|
|
||||||
export type NewDiagram =
|
export type NewDiagram =
|
||||||
@@ -29,11 +37,15 @@ export function reservedIdError(input: string): string | null {
|
|||||||
)
|
)
|
||||||
const id = attrs.get("id")
|
const id = attrs.get("id")
|
||||||
if (id !== "0" && id !== "1") continue
|
if (id !== "0" && id !== "1") continue
|
||||||
// A wrapper's id is its cell's; an mxCell counts as a shape or edge
|
// A wrapper's id is its cell's; an mxCell counts as a shape or edge,
|
||||||
|
// also when written compactly (a size, or a connection)
|
||||||
if (
|
if (
|
||||||
tag !== "mxCell" ||
|
tag !== "mxCell" ||
|
||||||
attrs.get("vertex") === "1" ||
|
attrs.get("vertex") === "1" ||
|
||||||
attrs.get("edge") === "1"
|
attrs.get("edge") === "1" ||
|
||||||
|
["x", "y", "w", "h", "width", "height", "source", "target"].some(
|
||||||
|
(a) => attrs.has(a),
|
||||||
|
)
|
||||||
) {
|
) {
|
||||||
return 'Cell ids "0" and "1" are the root cells, which are added automatically. Give shapes and edges ids starting at "2".'
|
return 'Cell ids "0" and "1" are the root cells, which are added automatically. Give shapes and edges ids starting at "2".'
|
||||||
}
|
}
|
||||||
@@ -42,23 +54,64 @@ export function reservedIdError(input: string): string | null {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Bare cells get the wrapper and root cells first, since the strict parser
|
* The named style definitions are taken out first (style-classes.ts). Bare
|
||||||
* rejects several top-level elements. Then the XML is validated and
|
* cells then get the wrapper and root cells, since the strict parser rejects
|
||||||
* auto-fixed while it is still a bare model, where duplicate ids are
|
* several top-level elements. Then the XML is validated and auto-fixed while
|
||||||
* renamed, and finally turned into an <mxfile>.
|
* it is still a bare model, where duplicate ids are renamed. Compact cells
|
||||||
|
* (compact-cells.ts) are expanded, the names expanded and the default styles
|
||||||
|
* added on the fixed XML, so repaired cells get them too, and finally it is
|
||||||
|
* turned into an <mxfile>.
|
||||||
*/
|
*/
|
||||||
|
/**
|
||||||
|
* Take the named style definitions out of the model's XML (style-classes.ts).
|
||||||
|
* Returns the error for the model when a definition never closed or when
|
||||||
|
* nothing but definitions was sent.
|
||||||
|
*/
|
||||||
|
export function takeStyleDefinitions(input: string): {
|
||||||
|
classes: StyleClasses
|
||||||
|
xml: string
|
||||||
|
error: string | null
|
||||||
|
} {
|
||||||
|
const { classes, xml } = readStyleClasses(input)
|
||||||
|
let error: string | null = null
|
||||||
|
if (/<mxStyle\b/i.test(xml)) {
|
||||||
|
error =
|
||||||
|
'A named style definition is not closed. Write it as <mxStyle name="..." value="..."/> before the cells.'
|
||||||
|
} else if (classes.size > 0 && !hasCells(xml)) {
|
||||||
|
error =
|
||||||
|
"Only named style definitions were sent, no cells. Send the mxCell elements after the definitions."
|
||||||
|
}
|
||||||
|
return { classes, xml, error }
|
||||||
|
}
|
||||||
|
|
||||||
export function prepareNewDiagram(
|
export function prepareNewDiagram(
|
||||||
input: string,
|
input: string,
|
||||||
page: { pageId?: string; pageName?: string } = {},
|
page: { pageId?: string; pageName?: string } = {},
|
||||||
): NewDiagram {
|
): NewDiagram {
|
||||||
const reserved = reservedIdError(input)
|
const {
|
||||||
|
classes,
|
||||||
|
xml: cells,
|
||||||
|
error: styleError,
|
||||||
|
} = takeStyleDefinitions(input)
|
||||||
|
if (styleError) return { ok: false, error: styleError }
|
||||||
|
const reserved = reservedIdError(cells)
|
||||||
if (reserved) return { ok: false, error: reserved }
|
if (reserved) return { ok: false, error: reserved }
|
||||||
let xml = wrapCellsInModel(input)
|
let xml = wrapCellsInModel(cells)
|
||||||
const { valid, error, fixed, fixes } = validateAndFixXml(xml)
|
const { valid, error, fixed, fixes } = validateAndFixXml(xml)
|
||||||
if (fixed) xml = fixed
|
if (fixed) xml = fixed
|
||||||
if (!valid) {
|
if (!valid) {
|
||||||
return { ok: false, error: `XML validation failed - ${error}` }
|
return { ok: false, error: `XML validation failed - ${error}` }
|
||||||
}
|
}
|
||||||
|
xml = addDefaultStyles(applyStyleClasses(expandCompactCells(xml), classes))
|
||||||
|
// The rewrites copy attribute values as written; a check that they
|
||||||
|
// produced well-formed XML, in case one of them ever does not
|
||||||
|
const rewriteError = getXmlSyntaxError(xml)
|
||||||
|
if (rewriteError) {
|
||||||
|
return {
|
||||||
|
ok: false,
|
||||||
|
error: `XML validation failed after expanding the cells - ${rewriteError}`,
|
||||||
|
}
|
||||||
|
}
|
||||||
const normalized = normalizeToMxfile(xml, page)
|
const normalized = normalizeToMxfile(xml, page)
|
||||||
if (!normalized) {
|
if (!normalized) {
|
||||||
return {
|
return {
|
||||||
|
|||||||
@@ -0,0 +1,232 @@
|
|||||||
|
/**
|
||||||
|
* Named styles, written once and used by name like CSS classes.
|
||||||
|
*
|
||||||
|
* The model defines a style before the cells and refers to it from any
|
||||||
|
* number of cells, so a style string is not repeated in every cell:
|
||||||
|
*
|
||||||
|
* <mxStyle name="blue" value="fillColor=#dae8fc;strokeColor=#6c8ebf;"/>
|
||||||
|
* <mxCell id="2" value="A" style="rounded=1;blue;" vertex="1" parent="1">...</mxCell>
|
||||||
|
* <mxCell id="3" value="B" style="rhombus;blue;fontSize=14;" vertex="1" parent="1">...</mxCell>
|
||||||
|
*
|
||||||
|
* draw.io resolves a style token without "=" through its stylesheet, which
|
||||||
|
* is not saved in the file, so the names are expanded here and the saved
|
||||||
|
* XML is plain draw.io XML. A name without a definition is left as it is:
|
||||||
|
* draw.io ignores it, unless its own stylesheet knows the name (text,
|
||||||
|
* ellipse, label, blue, green, ...), which then keeps its meaning.
|
||||||
|
*
|
||||||
|
* html=1 and whiteSpace=wrap, which draw.io itself puts on every new shape,
|
||||||
|
* are added here too, so the model never has to write them.
|
||||||
|
*
|
||||||
|
* The callers validate and auto-fix the XML first and rewrite the styles
|
||||||
|
* after, so a repaired cell (quotes written as entities, a lowercase tag)
|
||||||
|
* gets its defaults too, and the rewrite only ever sees proper attributes.
|
||||||
|
*/
|
||||||
|
import { readAttributes } from "./xml-attributes.ts"
|
||||||
|
|
||||||
|
export type StyleClasses = Map<string, string>
|
||||||
|
|
||||||
|
// <mxStyle name="..." value="..."/> with the whitespace after it, also when
|
||||||
|
// written as <mxStyle ...> or <mxStyle ...></mxStyle>; quoted values may hold ">"
|
||||||
|
const STYLE_DEFINITION =
|
||||||
|
/<mxStyle\b((?:[^<>"']|"[^"]*"|'[^']*')*?)\s*\/?>(?:\s*<\/mxStyle>)?\s*/gi
|
||||||
|
|
||||||
|
// An mxCell opening tag; the attributes stop before a closing "/" so a
|
||||||
|
// self-closing cell keeps its "/>"
|
||||||
|
const CELL_TAG = /<mxCell\b((?:[^<>"']|"[^"]*"|'[^']*')*?)\s*(\/?)>/g
|
||||||
|
|
||||||
|
// draw.io reads keys and tokens as written, " html" is not "html", so the
|
||||||
|
// checks are exact too
|
||||||
|
/** Whether the style sets the key (as `key=` at the start or after a ";") */
|
||||||
|
const hasKey = (style: string, key: string) =>
|
||||||
|
new RegExp(`(^|;)${key}=`).test(style)
|
||||||
|
|
||||||
|
/** Whether the style holds this exact token */
|
||||||
|
const hasToken = (style: string, token: string) =>
|
||||||
|
style.split(";").includes(token)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Replace the names among the tokens with their definitions. The tokens are
|
||||||
|
* kept exactly as written, empty ones included: a leading ";" is draw.io's
|
||||||
|
* "no default style" marker, and an entity such as " ends in ";" too.
|
||||||
|
* A name whose definition is empty is dropped, so it does not leave such a
|
||||||
|
* marker behind.
|
||||||
|
*/
|
||||||
|
function applyToStyle(style: string, classes: StyleClasses): string {
|
||||||
|
const out: string[] = []
|
||||||
|
for (const token of style.split(";")) {
|
||||||
|
const definition = token.includes("=")
|
||||||
|
? undefined
|
||||||
|
: classes.get(token.trim())
|
||||||
|
if (definition === undefined) out.push(token)
|
||||||
|
else if (definition !== "") out.push(definition)
|
||||||
|
}
|
||||||
|
return out.join(";")
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A definition's value goes into a style attribute unchecked by the XML
|
||||||
|
* validator (the definitions are taken out before it runs), so the two
|
||||||
|
* characters that would break the attribute are escaped here, the way the
|
||||||
|
* validator repairs them in cells.
|
||||||
|
*/
|
||||||
|
function escapeForAttribute(value: string): string {
|
||||||
|
return value
|
||||||
|
.replace(
|
||||||
|
/&(?!(?:lt|gt|amp|quot|apos|#[0-9]+|#x[0-9a-fA-F]+);)/g,
|
||||||
|
"&",
|
||||||
|
)
|
||||||
|
.replace(/</g, "<")
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The definitions, resolved against each other, and the XML without them.
|
||||||
|
* A definition may use names defined before or after it; a name using
|
||||||
|
* itself stays a bare token.
|
||||||
|
*/
|
||||||
|
export function readStyleClasses(xml: string): {
|
||||||
|
classes: StyleClasses
|
||||||
|
xml: string
|
||||||
|
} {
|
||||||
|
const classes: StyleClasses = new Map()
|
||||||
|
const rest = xml.replace(STYLE_DEFINITION, (_match, attrText: string) => {
|
||||||
|
const attrs = new Map(
|
||||||
|
readAttributes(attrText).map((a) => [a.name, a.value]),
|
||||||
|
)
|
||||||
|
// "name" may end in the ";" the model is used to; "style" is the
|
||||||
|
// attribute the model may reach for instead of "value"
|
||||||
|
const name = attrs.get("name")?.trim().replace(/;+$/, "")
|
||||||
|
const value = attrs.get("value") ?? attrs.get("style")
|
||||||
|
if (name && value !== undefined) {
|
||||||
|
// Without the trailing ";", so a name followed by ";" in a cell
|
||||||
|
// expands to "...;" and not to "...;;"
|
||||||
|
classes.set(name, escapeForAttribute(value).replace(/;+$/, ""))
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
})
|
||||||
|
// Two passes resolve names used by other definitions, in either order
|
||||||
|
for (let pass = 0; pass < 2; pass++) {
|
||||||
|
for (const [name, value] of classes) {
|
||||||
|
const others = new Map(classes)
|
||||||
|
others.delete(name)
|
||||||
|
classes.set(name, applyToStyle(value, others))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { classes, xml: rest }
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Rewrite the style of every mxCell. The callback returns the new style, or
|
||||||
|
* undefined to leave the cell alone. A cell without a style attribute gets
|
||||||
|
* one when the callback returns a style for it.
|
||||||
|
*/
|
||||||
|
function rewriteCellStyles(
|
||||||
|
xml: string,
|
||||||
|
rewrite: (
|
||||||
|
style: string | undefined,
|
||||||
|
attrs: Map<string, string>,
|
||||||
|
) => string | undefined,
|
||||||
|
): string {
|
||||||
|
return xml.replace(CELL_TAG, (tag, attrText: string, selfClose: string) => {
|
||||||
|
const attributes = readAttributes(attrText)
|
||||||
|
const attrs = new Map(attributes.map((a) => [a.name, a.value]))
|
||||||
|
const style = attributes.find((a) => a.name === "style")
|
||||||
|
const next = rewrite(style?.value, attrs)
|
||||||
|
if (next === undefined || next === style?.value) return tag
|
||||||
|
// The value is copied from attributes as written, so only a quote
|
||||||
|
// from a single-quoted source needs escaping
|
||||||
|
const written = `style="${next.replace(/"/g, """)}"`
|
||||||
|
if (!style) {
|
||||||
|
return `<mxCell${attrText} ${written}${selfClose}>`
|
||||||
|
}
|
||||||
|
// Keep the attribute where it was, with the whitespace before it
|
||||||
|
const space =
|
||||||
|
attrText.slice(style.start, style.end).match(/^\s*/)?.[0] ?? " "
|
||||||
|
return `<mxCell${attrText.slice(0, style.start)}${space}${written}${attrText.slice(style.end)}${selfClose}>`
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Expand the names in every cell's style; the overrides after a name still win */
|
||||||
|
export function applyStyleClasses(xml: string, classes: StyleClasses): string {
|
||||||
|
if (classes.size === 0) return xml
|
||||||
|
return rewriteCellStyles(xml, (style) =>
|
||||||
|
style === undefined ? undefined : applyToStyle(style, classes),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The ids of the edges in the XML, whose child vertices are edge labels */
|
||||||
|
export function edgeIdsOf(xml: string): Set<string> {
|
||||||
|
const ids = new Set<string>()
|
||||||
|
for (const [, attrText] of xml.matchAll(CELL_TAG)) {
|
||||||
|
const attrs = new Map(
|
||||||
|
readAttributes(attrText).map((a) => [a.name, a.value]),
|
||||||
|
)
|
||||||
|
const id = attrs.get("id")
|
||||||
|
if (id && attrs.get("edge") === "1") ids.add(id)
|
||||||
|
}
|
||||||
|
return ids
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether the label sits outside the shape, as with library icons (their
|
||||||
|
* styles position the label below) and images, so wrapping it to the
|
||||||
|
* shape's width would be wrong.
|
||||||
|
*/
|
||||||
|
function hasLabelOutside(style: string): boolean {
|
||||||
|
return (
|
||||||
|
hasKey(style, "verticalLabelPosition") ||
|
||||||
|
hasKey(style, "labelPosition") ||
|
||||||
|
hasKey(style, "image") ||
|
||||||
|
hasToken(style, "shape=image")
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* draw.io's own defaults for a new shape: html=1 (so labels may hold <br>
|
||||||
|
* and <b>) and whiteSpace=wrap. Wrapping is not added to shapes whose label
|
||||||
|
* sits outside (icons, images), to text that sizes itself (autosize=1), to
|
||||||
|
* edge labels (their label box has no width of its own) or next to html=0
|
||||||
|
* (draw.io renders a wrapping label as HTML). Edges get html=1. Root cells
|
||||||
|
* and cells that already set a key are left alone.
|
||||||
|
*
|
||||||
|
* An edit adds cells to a page the XML does not show, so the caller passes
|
||||||
|
* the ids of the edges already on it (edgeIdsOf), or new edge labels would
|
||||||
|
* be wrapped like shapes.
|
||||||
|
*/
|
||||||
|
export function addDefaultStyles(
|
||||||
|
xml: string,
|
||||||
|
knownEdgeIds: Iterable<string> = [],
|
||||||
|
): string {
|
||||||
|
const edges = new Set([...knownEdgeIds, ...edgeIdsOf(xml)])
|
||||||
|
return rewriteCellStyles(xml, (style, attrs) => {
|
||||||
|
// A cell marked as both is drawn as an edge
|
||||||
|
const isEdge = attrs.get("edge") === "1"
|
||||||
|
const isVertex = attrs.get("vertex") === "1" && !isEdge
|
||||||
|
if (!isVertex && !isEdge) return undefined
|
||||||
|
const current = style ?? ""
|
||||||
|
const isEdgeLabel =
|
||||||
|
hasToken(current, "edgeLabel") ||
|
||||||
|
attrs.get("connectable") === "0" ||
|
||||||
|
edges.has(attrs.get("parent") ?? "")
|
||||||
|
const added: string[] = []
|
||||||
|
if (
|
||||||
|
isVertex &&
|
||||||
|
!hasKey(current, "whiteSpace") &&
|
||||||
|
!hasLabelOutside(current) &&
|
||||||
|
!isEdgeLabel &&
|
||||||
|
!hasToken(current, "autosize=1") &&
|
||||||
|
!hasToken(current, "html=0")
|
||||||
|
) {
|
||||||
|
added.push("whiteSpace=wrap")
|
||||||
|
}
|
||||||
|
if (!hasKey(current, "html")) added.push("html=1")
|
||||||
|
if (added.length === 0) return undefined
|
||||||
|
const separator = current === "" || current.endsWith(";") ? "" : ";"
|
||||||
|
return `${current}${separator}${added.join(";")};`
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Expand the named styles of a model's XML and add the default styles */
|
||||||
|
export function expandStyles(xml: string): string {
|
||||||
|
const { classes, xml: cells } = readStyleClasses(xml)
|
||||||
|
return addDefaultStyles(applyStyleClasses(cells, classes))
|
||||||
|
}
|
||||||
@@ -3,30 +3,16 @@
|
|||||||
* prompt and tool descriptions, so both teach the model the same thing.
|
* prompt and tool descriptions, so both teach the model the same thing.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
export const SWIMLANE_EXAMPLE = `<mxCell id="lane1" value="Frontend" style="swimlane;" vertex="1" parent="1">
|
export const SWIMLANE_EXAMPLE = `<mxCell id="lane1" value="Frontend" style="swimlane;" x="40" y="40" w="200" h="200"/>
|
||||||
<mxGeometry x="40" y="40" width="200" height="200" as="geometry"/>
|
<mxCell id="step1" value="Step 1" style="rounded=1;" parent="lane1" x="20" y="60" w="160" h="40"/>
|
||||||
</mxCell>
|
<mxCell id="lane2" value="Backend" style="swimlane;" x="280" y="40" w="200" h="200"/>
|
||||||
<mxCell id="step1" value="Step 1" style="rounded=1;" vertex="1" parent="lane1">
|
<mxCell id="step2" value="Step 2" style="rounded=1;" parent="lane2" x="20" y="60" w="160" h="40"/>
|
||||||
<mxGeometry x="20" y="60" width="160" height="40" as="geometry"/>
|
<mxCell id="edge1" style="edgeStyle=orthogonalEdgeStyle;" source="step1" target="step2"/>`
|
||||||
</mxCell>
|
|
||||||
<mxCell id="lane2" value="Backend" style="swimlane;" vertex="1" parent="1">
|
|
||||||
<mxGeometry x="280" y="40" width="200" height="200" as="geometry"/>
|
|
||||||
</mxCell>
|
|
||||||
<mxCell id="step2" value="Step 2" style="rounded=1;" vertex="1" parent="lane2">
|
|
||||||
<mxGeometry x="20" y="60" width="160" height="40" as="geometry"/>
|
|
||||||
</mxCell>
|
|
||||||
<mxCell id="edge1" style="edgeStyle=orthogonalEdgeStyle;endArrow=classic;" edge="1" parent="1" source="step1" target="step2">
|
|
||||||
<mxGeometry relative="1" as="geometry"/>
|
|
||||||
</mxCell>`
|
|
||||||
|
|
||||||
export const TWO_EDGES_EXAMPLE = `<mxCell id="e1" value="A to B" style="edgeStyle=orthogonalEdgeStyle;exitX=1;exitY=0.3;entryX=0;entryY=0.3;endArrow=classic;" edge="1" parent="1" source="a" target="b">
|
export const TWO_EDGES_EXAMPLE = `<mxCell id="e1" value="A to B" style="edgeStyle=orthogonalEdgeStyle;exitX=1;exitY=0.3;entryX=0;entryY=0.3;" source="a" target="b"/>
|
||||||
<mxGeometry relative="1" as="geometry"/>
|
<mxCell id="e2" value="B to A" style="edgeStyle=orthogonalEdgeStyle;exitX=0;exitY=0.7;entryX=1;entryY=0.7;" source="b" target="a"/>`
|
||||||
</mxCell>
|
|
||||||
<mxCell id="e2" value="B to A" style="edgeStyle=orthogonalEdgeStyle;exitX=0;exitY=0.7;entryX=1;entryY=0.7;endArrow=classic;" edge="1" parent="1" source="b" target="a">
|
|
||||||
<mxGeometry relative="1" as="geometry"/>
|
|
||||||
</mxCell>`
|
|
||||||
|
|
||||||
export const WAYPOINT_EXAMPLE = `<mxCell id="hotfix_to_main" style="edgeStyle=orthogonalEdgeStyle;exitX=0.5;exitY=0;entryX=1;entryY=0.5;endArrow=classic;" edge="1" parent="1" source="hotfix" target="main">
|
export const WAYPOINT_EXAMPLE = `<mxCell id="hotfix_to_main" style="edgeStyle=orthogonalEdgeStyle;exitX=0.5;exitY=0;entryX=1;entryY=0.5;" edge="1" parent="1" source="hotfix" target="main">
|
||||||
<mxGeometry relative="1" as="geometry">
|
<mxGeometry relative="1" as="geometry">
|
||||||
<Array as="points">
|
<Array as="points">
|
||||||
<mxPoint x="750" y="80"/>
|
<mxPoint x="750" y="80"/>
|
||||||
@@ -35,6 +21,15 @@ export const WAYPOINT_EXAMPLE = `<mxCell id="hotfix_to_main" style="edgeStyle=or
|
|||||||
</mxGeometry>
|
</mxGeometry>
|
||||||
</mxCell>`
|
</mxCell>`
|
||||||
|
|
||||||
|
/** Named styles defined once and used by name, like CSS classes */
|
||||||
|
export const STYLE_CLASS_EXAMPLE = `<mxStyle name="step" value="fillColor=#dae8fc;strokeColor=#6c8ebf;"/>
|
||||||
|
<mxStyle name="down" value="edgeStyle=orthogonalEdgeStyle;exitX=0.5;exitY=1;entryX=0.5;entryY=0;"/>
|
||||||
|
<mxCell id="2" value="Start" style="rounded=1;step;" x="40" y="40" w="120" h="60"/>
|
||||||
|
<mxCell id="3" value="Check" style="rhombus;step;fontStyle=1;" x="40" y="160" w="120" h="80"/>
|
||||||
|
<mxCell id="4" value="Done" style="rounded=1;step;" x="40" y="300" w="120" h="60"/>
|
||||||
|
<mxCell id="5" style="down;" source="2" target="3"/>
|
||||||
|
<mxCell id="6" style="down;" source="3" target="4"/>`
|
||||||
|
|
||||||
/** Indent every line, for an indented code block in Markdown */
|
/** Indent every line, for an indented code block in Markdown */
|
||||||
export const indent = (text: string, prefix = " ") =>
|
export const indent = (text: string, prefix = " ") =>
|
||||||
text
|
text
|
||||||
|
|||||||
@@ -334,22 +334,25 @@ function checkEntityReferences(xml: string): string | null {
|
|||||||
|
|
||||||
/** Check for nested mxCell tags using regex */
|
/** Check for nested mxCell tags using regex */
|
||||||
function checkNestedMxCells(xml: string): string | null {
|
function checkNestedMxCells(xml: string): string | null {
|
||||||
const cellTagPattern = /<\/?mxCell[^>]*>/g
|
// Quoted values may hold ">", so the tag ends at the first ">" outside them
|
||||||
|
const cellTagPattern = /<\/?mxCell\b(?:[^<>"']|"[^"]*"|'[^']*')*>/g
|
||||||
const cellStack: number[] = []
|
const cellStack: number[] = []
|
||||||
let cellMatch
|
let cellMatch
|
||||||
while ((cellMatch = cellTagPattern.exec(xml)) !== null) {
|
while ((cellMatch = cellTagPattern.exec(xml)) !== null) {
|
||||||
const tag = cellMatch[0]
|
const tag = cellMatch[0]
|
||||||
if (tag.startsWith("</mxCell>")) {
|
const nested =
|
||||||
if (cellStack.length > 0) cellStack.pop()
|
"Invalid XML: Found nested mxCell tags. Cells should be siblings, not nested inside other mxCell elements."
|
||||||
} else if (!tag.endsWith("/>")) {
|
|
||||||
const isLabelOrGeometry =
|
const isLabelOrGeometry =
|
||||||
/\sas\s*=\s*["'](valueLabel|geometry)["']/.test(tag)
|
/\sas\s*=\s*["'](valueLabel|geometry)["']/.test(tag)
|
||||||
if (!isLabelOrGeometry) {
|
if (tag.startsWith("</mxCell>")) {
|
||||||
|
if (cellStack.length > 0) cellStack.pop()
|
||||||
|
} else if (tag.endsWith("/>")) {
|
||||||
|
// A self-closing cell inside an open one is nested too (or the
|
||||||
|
// open one lacks its </mxCell>; the auto-fix sorts that out)
|
||||||
|
if (cellStack.length > 0 && !isLabelOrGeometry) return nested
|
||||||
|
} else if (!isLabelOrGeometry) {
|
||||||
cellStack.push(cellMatch.index)
|
cellStack.push(cellMatch.index)
|
||||||
if (cellStack.length > 1) {
|
if (cellStack.length > 1) return nested
|
||||||
return "Invalid XML: Found nested mxCell tags. Cells should be siblings, not nested inside other mxCell elements."
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return null
|
return null
|
||||||
@@ -556,6 +559,19 @@ export function validateMxCellStructure(
|
|||||||
* @param xml - The XML string to fix
|
* @param xml - The XML string to fix
|
||||||
* @returns Object with fixed XML and list of fixes applied
|
* @returns Object with fixed XML and list of fixes applied
|
||||||
*/
|
*/
|
||||||
|
/**
|
||||||
|
* A compact cell's last attribute is a number, and the model sometimes
|
||||||
|
* drops its closing quote: h="112/>. Only a cell whose earlier attributes
|
||||||
|
* are all properly quoted is touched, so a label that happens to contain
|
||||||
|
* such text is left alone.
|
||||||
|
*/
|
||||||
|
export function repairQuoteBeforeSlash(xml: string): string {
|
||||||
|
return xml.replace(
|
||||||
|
/<mxCell\b((?:\s+[\w:.-]+="[^"]*")*)(\s+(?:x|y|w|h|width|height)="-?\d+(?:\.\d+)?)\s*\/>/g,
|
||||||
|
'<mxCell$1$2"/>',
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
export function autoFixXml(xml: string): { fixed: string; fixes: string[] } {
|
export function autoFixXml(xml: string): { fixed: string; fixes: string[] } {
|
||||||
let fixed = xml
|
let fixed = xml
|
||||||
const fixes: string[] = []
|
const fixes: string[] = []
|
||||||
@@ -657,6 +673,29 @@ export function autoFixXml(xml: string): { fixed: string; fixes: string[] } {
|
|||||||
fixes.push("Fixed malformed attribute quotes")
|
fixes.push("Fixed malformed attribute quotes")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 6b. Missing closing quote on the last attribute of a compact cell
|
||||||
|
// (h="112/>), see repairQuoteBeforeSlash
|
||||||
|
const quoted = repairQuoteBeforeSlash(fixed)
|
||||||
|
if (quoted !== fixed) {
|
||||||
|
fixed = quoted
|
||||||
|
fixes.push("Added a missing closing quote before />")
|
||||||
|
}
|
||||||
|
|
||||||
|
// 6c. A compact cell written without the slash: <mxCell ... h="60">
|
||||||
|
// followed by the next cell, a definition or the end. Only cells that
|
||||||
|
// carry a size or a connection, so an open container stays open.
|
||||||
|
const slashless =
|
||||||
|
/<mxCell\b((?:\s+[\w:.-]+="[^"]*")*)\s*>(?=\s*(?:<mxCell\b|<mxStyle\b|$))/g
|
||||||
|
const closed = fixed.replace(slashless, (tag, attrText: string) =>
|
||||||
|
/\s(?:x|y|w|h|width|height|source|target)="/.test(attrText)
|
||||||
|
? `<mxCell${attrText}/>`
|
||||||
|
: tag,
|
||||||
|
)
|
||||||
|
if (closed !== fixed) {
|
||||||
|
fixed = closed
|
||||||
|
fixes.push("Closed compact cells written without the slash")
|
||||||
|
}
|
||||||
|
|
||||||
// 7. Fix malformed closing tags
|
// 7. Fix malformed closing tags
|
||||||
const malformedClosingTag = /<\/([a-zA-Z][a-zA-Z0-9]*)\s*\/>/g
|
const malformedClosingTag = /<\/([a-zA-Z][a-zA-Z0-9]*)\s*\/>/g
|
||||||
if (malformedClosingTag.test(fixed)) {
|
if (malformedClosingTag.test(fixed)) {
|
||||||
@@ -987,16 +1026,39 @@ export function autoFixXml(xml: string): { fixed: string; fixes: string[] } {
|
|||||||
/<mxCell\s/.test(trimmed) &&
|
/<mxCell\s/.test(trimmed) &&
|
||||||
!trimmed.endsWith("/>") &&
|
!trimmed.endsWith("/>") &&
|
||||||
!trimmed.endsWith("</mxCell>")
|
!trimmed.endsWith("</mxCell>")
|
||||||
|
// A self-closing (compact) cell is the next sibling too
|
||||||
|
const isSelfClosingCell =
|
||||||
|
/<mxCell\s/.test(trimmed) && trimmed.endsWith("/>")
|
||||||
const isCloseCell = trimmed === "</mxCell>"
|
const isCloseCell = trimmed === "</mxCell>"
|
||||||
|
// The last cell's closing tag may share its line with the wrapper's
|
||||||
|
const startsWithClose = !isCloseCell && trimmed.startsWith("</mxCell>")
|
||||||
|
|
||||||
if (isOpenCell) {
|
if (isOpenCell || isSelfClosingCell) {
|
||||||
if (cellDepth > 0) {
|
if (cellDepth > 0) {
|
||||||
const indent = line.match(/^(\s*)/)?.[1] || ""
|
const indent = line.match(/^(\s*)/)?.[1] || ""
|
||||||
newLines.push(indent + "</mxCell>")
|
newLines.push(indent + "</mxCell>")
|
||||||
trueNestedFixed++
|
trueNestedFixed++
|
||||||
|
// The open cell's own </mxCell> is removed later, when there
|
||||||
|
// is one before the next open cell; a cell that simply lacks
|
||||||
|
// its closing tag has none to remove
|
||||||
|
const nextOpen = lines2.findIndex(
|
||||||
|
(l, j) =>
|
||||||
|
j > i &&
|
||||||
|
/<mxCell\s/.test(l.trim()) &&
|
||||||
|
!l.trim().endsWith("/>"),
|
||||||
|
)
|
||||||
|
const nextClose = lines2.findIndex(
|
||||||
|
(l, j) => j > i && l.trim().startsWith("</mxCell>"),
|
||||||
|
)
|
||||||
|
if (
|
||||||
|
isOpenCell ||
|
||||||
|
(nextClose !== -1 &&
|
||||||
|
(nextOpen === -1 || nextClose < nextOpen))
|
||||||
|
) {
|
||||||
pendingCloseRemoval++
|
pendingCloseRemoval++
|
||||||
}
|
}
|
||||||
cellDepth = 1
|
}
|
||||||
|
cellDepth = isOpenCell ? 1 : 0
|
||||||
newLines.push(line)
|
newLines.push(line)
|
||||||
} else if (isCloseCell) {
|
} else if (isCloseCell) {
|
||||||
if (pendingCloseRemoval > 0) {
|
if (pendingCloseRemoval > 0) {
|
||||||
@@ -1005,6 +1067,9 @@ export function autoFixXml(xml: string): { fixed: string; fixes: string[] } {
|
|||||||
cellDepth = Math.max(0, cellDepth - 1)
|
cellDepth = Math.max(0, cellDepth - 1)
|
||||||
newLines.push(line)
|
newLines.push(line)
|
||||||
}
|
}
|
||||||
|
} else if (startsWithClose && pendingCloseRemoval > 0) {
|
||||||
|
pendingCloseRemoval--
|
||||||
|
newLines.push(line.replace("</mxCell>", ""))
|
||||||
} else {
|
} else {
|
||||||
newLines.push(line)
|
newLines.push(line)
|
||||||
}
|
}
|
||||||
|
|||||||
+1
-4
File diff suppressed because one or more lines are too long
|
Before Width: | Height: | Size: 33 KiB After Width: | Height: | Size: 6.8 KiB |
@@ -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)
|
||||||
|
|||||||
@@ -0,0 +1,370 @@
|
|||||||
|
import { describe, expect, it } from "vitest"
|
||||||
|
import { extractCompleteMxCells, isMxCellXmlComplete } from "@/lib/utils"
|
||||||
|
import {
|
||||||
|
expandCompactCells,
|
||||||
|
foldCells,
|
||||||
|
} from "@/packages/mcp-server/src/compact-cells.ts"
|
||||||
|
import { editDiagram } from "@/packages/mcp-server/src/edit-diagram.ts"
|
||||||
|
import { prepareNewDiagram } from "@/packages/mcp-server/src/new-diagram.ts"
|
||||||
|
import {
|
||||||
|
normalizeToMxfile,
|
||||||
|
wrapCellsInModel,
|
||||||
|
} from "@/packages/mcp-server/src/pages.ts"
|
||||||
|
|
||||||
|
const shape =
|
||||||
|
'<mxCell id="2" value="Start" style="rounded=1;" x="40" y="40" w="120" h="60"/>'
|
||||||
|
const shapeLong =
|
||||||
|
'<mxCell id="2" value="Start" style="rounded=1;" vertex="1" parent="1"><mxGeometry x="40" y="40" width="120" height="60" as="geometry"/></mxCell>'
|
||||||
|
const edge = '<mxCell id="5" style="down;" source="2" target="3"/>'
|
||||||
|
const edgeLong =
|
||||||
|
'<mxCell id="5" style="down;" edge="1" parent="1" source="2" target="3"><mxGeometry relative="1" as="geometry"/></mxCell>'
|
||||||
|
|
||||||
|
/** Cells compared by what they mean, not by attribute order */
|
||||||
|
function canonical(xml: string): string {
|
||||||
|
const doc = new DOMParser().parseFromString(
|
||||||
|
`<root>${xml}</root>`,
|
||||||
|
"text/xml",
|
||||||
|
)
|
||||||
|
const describe = (el: Element): string => {
|
||||||
|
const attrs = Array.from(el.attributes)
|
||||||
|
.map((a) => `${a.name}=${a.value}`)
|
||||||
|
.sort()
|
||||||
|
.join(" ")
|
||||||
|
const children = Array.from(el.children).map(describe).join("")
|
||||||
|
return `<${el.tagName} ${attrs}>${children}</${el.tagName}>`
|
||||||
|
}
|
||||||
|
return Array.from(doc.documentElement.children).map(describe).join("\n")
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("expandCompactCells", () => {
|
||||||
|
it("gives a compact shape its flags, parent and geometry", () => {
|
||||||
|
expect(expandCompactCells(shape)).toBe(shapeLong)
|
||||||
|
})
|
||||||
|
|
||||||
|
it("gives a compact edge its flags, parent and relative geometry", () => {
|
||||||
|
expect(expandCompactCells(edge)).toBe(edgeLong)
|
||||||
|
})
|
||||||
|
|
||||||
|
it("keeps an explicit parent and accepts width/height as names", () => {
|
||||||
|
const inLane =
|
||||||
|
'<mxCell id="3" value="Step" style="rounded=1;" parent="lane1" x="20" y="60" width="160" height="40"/>'
|
||||||
|
expect(expandCompactCells(inLane)).toBe(
|
||||||
|
'<mxCell id="3" value="Step" style="rounded=1;" vertex="1" parent="lane1"><mxGeometry x="20" y="60" width="160" height="40" as="geometry"/></mxCell>',
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
it("fills in a default position and size when some are missing", () => {
|
||||||
|
expect(expandCompactCells('<mxCell id="2" value="A" w="100"/>')).toBe(
|
||||||
|
'<mxCell id="2" value="A" vertex="1" parent="1"><mxGeometry x="0" y="0" width="100" height="60" as="geometry"/></mxCell>',
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
it("lets an explicit geometry win over compact attributes", () => {
|
||||||
|
// The stray attributes stay as written; the geometry is the one used
|
||||||
|
const both =
|
||||||
|
'<mxCell id="2" x="1" y="1" w="1" h="1"><mxGeometry x="40" y="40" width="120" height="60" as="geometry"/></mxCell>'
|
||||||
|
expect(expandCompactCells(both)).toBe(
|
||||||
|
'<mxCell id="2" x="1" y="1" w="1" h="1" vertex="1" parent="1"><mxGeometry x="40" y="40" width="120" height="60" as="geometry"/></mxCell>',
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
it("adds the relative geometry to a long-form edge that has none", () => {
|
||||||
|
const noGeometry =
|
||||||
|
'<mxCell id="5" style="down;" edge="1" parent="1" source="2" target="3"/>'
|
||||||
|
expect(expandCompactCells(noGeometry)).toBe(edgeLong)
|
||||||
|
})
|
||||||
|
|
||||||
|
it("leaves root cells, long-form cells and edge labels alone", () => {
|
||||||
|
const roots = '<mxCell id="0"/><mxCell id="1" parent="0"/>'
|
||||||
|
const label =
|
||||||
|
'<mxCell id="9" value="yes" style="edgeLabel;" vertex="1" connectable="0" parent="5"><mxGeometry x="-0.5" relative="1" as="geometry"/></mxCell>'
|
||||||
|
expect(expandCompactCells(roots + shapeLong + edgeLong + label)).toBe(
|
||||||
|
roots + shapeLong + edgeLong + label,
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
it("works on a cell inside a UserObject wrapper", () => {
|
||||||
|
const wrapped =
|
||||||
|
'<UserObject id="4" label="Docs" link="https://example.com"><mxCell style="rounded=1;" x="580" y="40" w="120" h="60"/></UserObject>'
|
||||||
|
expect(expandCompactCells(wrapped)).toBe(
|
||||||
|
'<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>',
|
||||||
|
)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe("foldCells", () => {
|
||||||
|
it("writes plain shapes and edges compactly", () => {
|
||||||
|
expect(foldCells(shapeLong)).toBe(shape)
|
||||||
|
expect(foldCells(edgeLong)).toBe(edge)
|
||||||
|
})
|
||||||
|
|
||||||
|
it("keeps a parent other than 1 and leaves special cells as written", () => {
|
||||||
|
const inLane =
|
||||||
|
'<mxCell id="3" value="Step" style="rounded=1;" vertex="1" parent="lane1"><mxGeometry x="20" y="60" width="160" height="40" as="geometry"/></mxCell>'
|
||||||
|
expect(foldCells(inLane)).toBe(
|
||||||
|
'<mxCell id="3" value="Step" style="rounded=1;" parent="lane1" x="20" y="60" w="160" h="40"/>',
|
||||||
|
)
|
||||||
|
const waypoints =
|
||||||
|
'<mxCell id="6" edge="1" parent="1" source="2" target="3"><mxGeometry relative="1" as="geometry"><Array as="points"><mxPoint x="750" y="80"/></Array></mxGeometry></mxCell>'
|
||||||
|
const label =
|
||||||
|
'<mxCell id="9" value="yes" style="edgeLabel;" vertex="1" connectable="0" parent="5"><mxGeometry x="-0.5" relative="1" as="geometry"/></mxCell>'
|
||||||
|
const sourcePoint =
|
||||||
|
'<mxCell id="7" edge="1" parent="1" target="3"><mxGeometry relative="1" as="geometry"><mxPoint x="10" y="10" as="sourcePoint"/></mxGeometry></mxCell>'
|
||||||
|
const roots = '<mxCell id="0"/><mxCell id="1" parent="0"/>'
|
||||||
|
const wrongAs =
|
||||||
|
'<mxCell id="8" vertex="1" parent="1"><mxGeometry x="1" y="1" width="2" height="2" as="33"/></mxCell>'
|
||||||
|
for (const xml of [waypoints, label, sourcePoint, roots, wrongAs]) {
|
||||||
|
expect(foldCells(xml)).toBe(xml)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
it("round-trips through expandCompactCells", () => {
|
||||||
|
const page = `<mxGraphModel><root><mxCell id="0"/><mxCell id="1" parent="0"/>${shapeLong}${edgeLong}<mxCell id="lane1" value="Lane" style="swimlane;" vertex="1" parent="1"><mxGeometry x="40" y="40" width="200" height="200" as="geometry"/></mxCell><mxCell id="3" value="Step" style="rounded=1;" vertex="1" parent="lane1"><mxGeometry x="20" y="60" width="160" height="40" as="geometry"/></mxCell><mxCell id="6" edge="1" parent="1" source="2" target="3"><mxGeometry relative="1" as="geometry"><Array as="points"><mxPoint x="750" y="80"/></Array></mxGeometry></mxCell></root></mxGraphModel>`
|
||||||
|
const folded = foldCells(page)
|
||||||
|
expect(folded).not.toContain("vertex=")
|
||||||
|
expect(canonical(expandCompactCells(folded))).toBe(canonical(page))
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe("compact cells through the pipeline", () => {
|
||||||
|
const geometryOf = (xml: string, id: string) =>
|
||||||
|
xml.match(
|
||||||
|
new RegExp(`<mxCell id="${id}"[^>]*>(<mxGeometry[^>]*/>)`),
|
||||||
|
)?.[1]
|
||||||
|
|
||||||
|
it("prepareNewDiagram expands them and still adds the defaults", () => {
|
||||||
|
const prepared = prepareNewDiagram(`${shape}\n${edge}`)
|
||||||
|
expect(prepared.ok).toBe(true)
|
||||||
|
if (!prepared.ok) return
|
||||||
|
expect(prepared.xml).toContain(
|
||||||
|
'style="rounded=1;whiteSpace=wrap;html=1;" vertex="1" parent="1"',
|
||||||
|
)
|
||||||
|
expect(geometryOf(prepared.xml, "2")).toBe(
|
||||||
|
'<mxGeometry x="40" y="40" width="120" height="60" as="geometry"/>',
|
||||||
|
)
|
||||||
|
expect(prepared.xml).toContain(
|
||||||
|
'<mxCell id="5" style="down;html=1;" edge="1" parent="1" source="2" target="3"><mxGeometry relative="1" as="geometry"/></mxCell>',
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
it("editDiagram accepts a compact cell in new_xml", () => {
|
||||||
|
const file =
|
||||||
|
normalizeToMxfile(wrapCellsInModel(shapeLong), {
|
||||||
|
pageId: "p1",
|
||||||
|
pageName: "Page-1",
|
||||||
|
}) ?? ""
|
||||||
|
const outcome = editDiagram(
|
||||||
|
file,
|
||||||
|
[
|
||||||
|
{
|
||||||
|
operation: "add",
|
||||||
|
cell_id: "3",
|
||||||
|
new_xml:
|
||||||
|
'<mxCell id="3" value="Next" style="rounded=1;" x="200" y="40" w="120" h="60"/>',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
operation: "update",
|
||||||
|
cell_id: "2",
|
||||||
|
new_xml:
|
||||||
|
'<mxCell id="2" value="Begin" style="rounded=1;" x="40" y="40" w="120" h="60"/>',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
{},
|
||||||
|
)
|
||||||
|
expect(outcome.ok).toBe(true)
|
||||||
|
if (!outcome.ok) return
|
||||||
|
expect(outcome.xml).toContain('value="Next"')
|
||||||
|
expect(outcome.xml).toContain('value="Begin"')
|
||||||
|
expect(geometryOf(outcome.xml, "3")).toBe(
|
||||||
|
'<mxGeometry x="200" y="40" width="120" height="60" as="geometry"/>',
|
||||||
|
)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe("a compact cell with a slip", () => {
|
||||||
|
it("repairs a missing closing quote before the slash", () => {
|
||||||
|
const prepared = prepareNewDiagram(
|
||||||
|
'<mxCell id="12" value="Coin Flip" style="rounded=1;" x="535" y="276" w="225" h="112/>',
|
||||||
|
)
|
||||||
|
expect(prepared.ok).toBe(true)
|
||||||
|
if (!prepared.ok) return
|
||||||
|
expect(prepared.xml).toContain(
|
||||||
|
'<mxGeometry x="535" y="276" width="225" height="112" as="geometry"/>',
|
||||||
|
)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe("review round: cells the model may write", () => {
|
||||||
|
it("escapes a double quote from a single-quoted value", () => {
|
||||||
|
const out = expandCompactCells(
|
||||||
|
`<mxCell id='2' value='Say "hi"' x='1' y='2' w='3' h='4'/>`,
|
||||||
|
)
|
||||||
|
expect(out).toContain('value="Say "hi""')
|
||||||
|
const prepared = prepareNewDiagram(
|
||||||
|
`<mxCell id='2' value='a" visible="0' x="1" y="2" w="3" h="4"/>`,
|
||||||
|
)
|
||||||
|
expect(prepared.ok).toBe(true)
|
||||||
|
if (!prepared.ok) return
|
||||||
|
expect(prepared.xml).toContain('value="a" visible="0"')
|
||||||
|
expect(prepared.xml).not.toContain(' visible="0"')
|
||||||
|
const folded = foldCells(
|
||||||
|
`<mxCell id="2" value='Say "hi"' vertex="1" parent="1"><mxGeometry x="1" y="2" width="3" height="4" as="geometry"/></mxCell>`,
|
||||||
|
)
|
||||||
|
expect(folded).toBe(
|
||||||
|
'<mxCell id="2" value="Say "hi"" x="1" y="2" w="3" h="4"/>',
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
it("accepts labels with > in compact self-closing cells", () => {
|
||||||
|
const prepared = prepareNewDiagram(
|
||||||
|
`<mxCell id="2" value="x > 5" style="rounded=1;" x="40" y="40" w="120" h="60"/>
|
||||||
|
<mxCell id="3" value="A -> B" style="rounded=1;" x="40" y="200" w="120" h="60"/>
|
||||||
|
<mxCell id="5" source="2" target="3"/>`,
|
||||||
|
)
|
||||||
|
expect(prepared.ok).toBe(true)
|
||||||
|
expect(
|
||||||
|
isMxCellXmlComplete(
|
||||||
|
'<mxCell id="2" value="A > B" x="0" y="0" w="120" h="60"/>',
|
||||||
|
),
|
||||||
|
).toBe(true)
|
||||||
|
expect(
|
||||||
|
extractCompleteMxCells(
|
||||||
|
'<mxCell id="2" value="A > B" x="0" y="0" w="120" h="60"/>\n<mxCell id="3" value="C" x="0" y="0" w="1',
|
||||||
|
),
|
||||||
|
).toBe('<mxCell id="2" value="A > B" x="0" y="0" w="120" h="60"/>')
|
||||||
|
})
|
||||||
|
|
||||||
|
it("infers a vertex for an edge label with a geometry and an edge for a connection with a size", () => {
|
||||||
|
const label =
|
||||||
|
'<mxCell id="9" value="yes" style="edgeLabel;" parent="5" connectable="0"><mxGeometry x="-0.5" relative="1" as="geometry"/></mxCell>'
|
||||||
|
expect(expandCompactCells(label)).toBe(
|
||||||
|
'<mxCell id="9" value="yes" style="edgeLabel;" connectable="0" vertex="1" parent="5"><mxGeometry x="-0.5" relative="1" as="geometry"/></mxCell>',
|
||||||
|
)
|
||||||
|
expect(
|
||||||
|
expandCompactCells(
|
||||||
|
'<mxCell id="e" source="2" target="3" x="0" y="0"/>',
|
||||||
|
),
|
||||||
|
).toBe(
|
||||||
|
'<mxCell id="e" edge="1" parent="1" source="2" target="3"><mxGeometry relative="1" as="geometry"/></mxCell>',
|
||||||
|
)
|
||||||
|
// A dangling edge has only one end
|
||||||
|
expect(
|
||||||
|
expandCompactCells('<mxCell id="e" style="a;" source="2"/>'),
|
||||||
|
).toBe(
|
||||||
|
'<mxCell id="e" style="a;" edge="1" parent="1" source="2"><mxGeometry relative="1" as="geometry"/></mxCell>',
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
it("does not take a geometry inside custom data for the cell's own", () => {
|
||||||
|
const out = expandCompactCells(
|
||||||
|
'<mxCell id="2" x="10" y="20" w="120" h="60"><Object as="payload"><mxGeometry as="backup"/></Object></mxCell>',
|
||||||
|
)
|
||||||
|
expect(out).toBe(
|
||||||
|
'<mxCell id="2" vertex="1" parent="1"><mxGeometry x="10" y="20" width="120" height="60" as="geometry"/><Object as="payload"><mxGeometry as="backup"/></Object></mxCell>',
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
it("keeps stray size attributes when the cell has its own geometry", () => {
|
||||||
|
const xml =
|
||||||
|
'<mxCell id="2" x="99" vertex="1" parent="1"><mxGeometry x="1" y="2" width="3" height="4" as="geometry"/></mxCell>'
|
||||||
|
expect(expandCompactCells(xml)).toBe(xml)
|
||||||
|
expect(foldCells(xml)).toBe(xml)
|
||||||
|
})
|
||||||
|
|
||||||
|
it("ignores prototype names and folds only cells that name their parent", () => {
|
||||||
|
expect(expandCompactCells('<mxCell id="2" toString="x"/>')).toBe(
|
||||||
|
'<mxCell id="2" toString="x"/>',
|
||||||
|
)
|
||||||
|
const noParent =
|
||||||
|
'<mxCell id="2" vertex="1"><mxGeometry x="1" y="2" width="3" height="4" as="geometry"/></mxCell>'
|
||||||
|
expect(foldCells(noParent)).toBe(noParent)
|
||||||
|
})
|
||||||
|
|
||||||
|
it("puts a compact cell on the page's first layer when editing", () => {
|
||||||
|
const file = `<mxfile><diagram id="p1" name="Page-1"><mxGraphModel><root><mxCell id="0"/><mxCell id="L1" parent="0"/>${shapeLong.replace('parent="1"', 'parent="L1"')}</root></mxGraphModel></diagram></mxfile>`
|
||||||
|
const outcome = editDiagram(
|
||||||
|
file,
|
||||||
|
[
|
||||||
|
{
|
||||||
|
operation: "add",
|
||||||
|
cell_id: "3",
|
||||||
|
new_xml:
|
||||||
|
'<mxCell id="3" value="B" x="1" y="2" w="3" h="4"/>',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
{},
|
||||||
|
)
|
||||||
|
expect(outcome.ok).toBe(true)
|
||||||
|
if (!outcome.ok) return
|
||||||
|
expect(outcome.xml).toContain(
|
||||||
|
'<mxCell id="3" value="B" vertex="1" parent="L1"',
|
||||||
|
)
|
||||||
|
expect(foldCells(file, "L1")).toContain(
|
||||||
|
'<mxCell id="2" value="Start" style="rounded=1;" x="40" y="40" w="120" h="60"/>',
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
it("refuses a compact shape with a root cell id instead of dropping it", () => {
|
||||||
|
const prepared = prepareNewDiagram(
|
||||||
|
'<mxCell id="1" value="A" x="0" y="0" w="10" h="10"/>',
|
||||||
|
)
|
||||||
|
expect(prepared.ok).toBe(false)
|
||||||
|
if (!prepared.ok) expect(prepared.error).toContain("root cells")
|
||||||
|
})
|
||||||
|
|
||||||
|
it("repairs the quote slip only on a cell's last numeric attribute", () => {
|
||||||
|
expect(
|
||||||
|
isMxCellXmlComplete('<mxCell id="2" x="1" y="2" w="3" h="4/>'),
|
||||||
|
).toBe(true)
|
||||||
|
const prepared = prepareNewDiagram(
|
||||||
|
'<mxCell id="2" value="5/>" x="1" y="2" w="3" h="4"/>',
|
||||||
|
)
|
||||||
|
expect(prepared.ok).toBe(true)
|
||||||
|
if (!prepared.ok) return
|
||||||
|
expect(prepared.xml).toContain('value="5/>"')
|
||||||
|
expect(prepared.xml).toContain(
|
||||||
|
'<mxGeometry x="1" y="2" width="3" height="4" as="geometry"/>',
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
it("closes a compact cell written without the slash", () => {
|
||||||
|
const prepared = prepareNewDiagram(
|
||||||
|
'<mxCell id="2" value="A" x="1" y="2" w="3" h="4">\n<mxCell id="3" value="B" x="1" y="2" w="3" h="4"/>',
|
||||||
|
)
|
||||||
|
expect(prepared.ok).toBe(true)
|
||||||
|
if (!prepared.ok) return
|
||||||
|
expect(prepared.xml).toContain('value="A"')
|
||||||
|
expect(prepared.xml).toContain('value="B"')
|
||||||
|
expect(prepared.fixes.join()).toContain("without the slash")
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe("a long-form cell missing its closing tag next to compact cells", () => {
|
||||||
|
it("is closed before the compact cell that follows", () => {
|
||||||
|
const prepared = prepareNewDiagram(
|
||||||
|
`<mxCell id="2" value="A" x="0" y="0" w="80" h="40"/>
|
||||||
|
<mxCell id="4" edge="1" parent="1" source="2" target="5">
|
||||||
|
<mxGeometry relative="1" as="geometry"><Array as="points"><mxPoint x="300" y="150"/></Array></mxGeometry>
|
||||||
|
<mxCell id="5" value="C" x="400" y="0" w="80" h="40"/>`,
|
||||||
|
)
|
||||||
|
expect(prepared.ok).toBe(true)
|
||||||
|
if (!prepared.ok) return
|
||||||
|
expect(prepared.xml).toContain('<mxPoint x="300" y="150"/>')
|
||||||
|
expect(prepared.xml).toContain('value="C"')
|
||||||
|
expect((prepared.xml.match(/<mxCell\b/g) || []).length).toBe(5)
|
||||||
|
})
|
||||||
|
|
||||||
|
it("still flattens a compact cell nested inside an open cell", () => {
|
||||||
|
const prepared = prepareNewDiagram(
|
||||||
|
`<mxCell id="2" value="A" vertex="1" parent="1">
|
||||||
|
<mxGeometry x="0" y="0" width="80" height="40" as="geometry"/>
|
||||||
|
<mxCell id="3" value="B" x="100" y="0" w="80" h="40"/>
|
||||||
|
</mxCell>`,
|
||||||
|
)
|
||||||
|
expect(prepared.ok).toBe(true)
|
||||||
|
if (!prepared.ok) return
|
||||||
|
expect(prepared.xml).toContain('value="A"')
|
||||||
|
expect(prepared.xml).toContain('value="B"')
|
||||||
|
expect((prepared.xml.match(/<mxCell\b/g) || []).length).toBe(4)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -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",
|
||||||
|
)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|||||||
@@ -0,0 +1,499 @@
|
|||||||
|
import { describe, expect, it } from "vitest"
|
||||||
|
import { isMxCellXmlComplete } from "@/lib/utils"
|
||||||
|
import { editDiagram } from "@/packages/mcp-server/src/edit-diagram.ts"
|
||||||
|
import { prepareNewDiagram } from "@/packages/mcp-server/src/new-diagram.ts"
|
||||||
|
import {
|
||||||
|
normalizeToMxfile,
|
||||||
|
wrapCellsInModel,
|
||||||
|
} from "@/packages/mcp-server/src/pages.ts"
|
||||||
|
import {
|
||||||
|
addDefaultStyles,
|
||||||
|
applyStyleClasses,
|
||||||
|
expandStyles,
|
||||||
|
readStyleClasses,
|
||||||
|
} from "@/packages/mcp-server/src/style-classes.ts"
|
||||||
|
|
||||||
|
const geometry =
|
||||||
|
'<mxGeometry x="0" y="0" width="80" height="40" as="geometry"/>'
|
||||||
|
const cell = (id: string, style: string, extra = "") =>
|
||||||
|
`<mxCell id="${id}" value="${id}" style="${style}" vertex="1" parent="1"${extra}>${geometry}</mxCell>`
|
||||||
|
const edge = (id: string, style: string) =>
|
||||||
|
`<mxCell id="${id}" style="${style}" edge="1" parent="1" source="2" target="3"><mxGeometry relative="1" as="geometry"/></mxCell>`
|
||||||
|
|
||||||
|
const BLUE =
|
||||||
|
'<mxStyle name="blue" value="fillColor=#dae8fc;strokeColor=#6c8ebf;"/>'
|
||||||
|
|
||||||
|
describe("readStyleClasses", () => {
|
||||||
|
it("reads the definitions and removes them from the XML", () => {
|
||||||
|
const xml = `${BLUE}\n<mxStyle name="flow" value="edgeStyle=orthogonalEdgeStyle;"></mxStyle>\n${cell("2", "blue;")}`
|
||||||
|
const { classes, xml: rest } = readStyleClasses(xml)
|
||||||
|
expect(classes.get("blue")).toBe(
|
||||||
|
"fillColor=#dae8fc;strokeColor=#6c8ebf",
|
||||||
|
)
|
||||||
|
expect(classes.get("flow")).toBe("edgeStyle=orthogonalEdgeStyle")
|
||||||
|
expect(rest).toBe(cell("2", "blue;"))
|
||||||
|
})
|
||||||
|
|
||||||
|
it("resolves a definition that uses an earlier name", () => {
|
||||||
|
const xml = `${BLUE}<mxStyle name="pill" value="rounded=1;blue;arcSize=50;"/>`
|
||||||
|
const { classes } = readStyleClasses(xml)
|
||||||
|
expect(classes.get("pill")).toBe(
|
||||||
|
"rounded=1;fillColor=#dae8fc;strokeColor=#6c8ebf;arcSize=50",
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
it("leaves a definition that is still streaming in place", () => {
|
||||||
|
const xml = `${BLUE}<mxStyle name="flow" value="edgeSt`
|
||||||
|
const { classes, xml: rest } = readStyleClasses(xml)
|
||||||
|
expect(classes.size).toBe(1)
|
||||||
|
expect(rest).toBe('<mxStyle name="flow" value="edgeSt')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe("applyStyleClasses", () => {
|
||||||
|
const classes = readStyleClasses(BLUE).classes
|
||||||
|
|
||||||
|
it("expands a name in place, so the overrides after it still win", () => {
|
||||||
|
const out = applyStyleClasses(
|
||||||
|
cell("2", "rhombus;blue;fontSize=14;"),
|
||||||
|
classes,
|
||||||
|
)
|
||||||
|
expect(out).toBe(
|
||||||
|
cell(
|
||||||
|
"2",
|
||||||
|
"rhombus;fillColor=#dae8fc;strokeColor=#6c8ebf;fontSize=14;",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
it("leaves unknown names, key=value tokens and other attributes alone", () => {
|
||||||
|
const xml = `${cell("2", "text;fontSize=blue;")}<mxCell id="3" vertex="1" parent="1" value="blue">${geometry}</mxCell>`
|
||||||
|
expect(applyStyleClasses(xml, classes)).toBe(xml)
|
||||||
|
})
|
||||||
|
|
||||||
|
it("works inside a wrapped model and keeps the attribute order", () => {
|
||||||
|
const xml = `<mxGraphModel><root><mxCell id="0"/><mxCell id="1" parent="0"/><mxCell id="2" style="blue;" value="A" vertex="1" parent="1">${geometry}</mxCell></root></mxGraphModel>`
|
||||||
|
expect(applyStyleClasses(xml, classes)).toBe(
|
||||||
|
xml.replace(
|
||||||
|
'style="blue;"',
|
||||||
|
'style="fillColor=#dae8fc;strokeColor=#6c8ebf;"',
|
||||||
|
),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe("addDefaultStyles", () => {
|
||||||
|
it("adds whiteSpace=wrap and html=1 to a shape", () => {
|
||||||
|
expect(addDefaultStyles(cell("2", "rounded=1;"))).toBe(
|
||||||
|
cell("2", "rounded=1;whiteSpace=wrap;html=1;"),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
it("gives a shape without a style attribute one", () => {
|
||||||
|
const xml = `<mxCell id="2" value="A" vertex="1" parent="1">${geometry}</mxCell>`
|
||||||
|
expect(addDefaultStyles(xml)).toBe(
|
||||||
|
`<mxCell id="2" value="A" vertex="1" parent="1" style="whiteSpace=wrap;html=1;">${geometry}</mxCell>`,
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
it("keeps html=0 and an existing whiteSpace", () => {
|
||||||
|
const xml = cell("2", "html=0;whiteSpace=nowrap;")
|
||||||
|
expect(addDefaultStyles(xml)).toBe(xml)
|
||||||
|
})
|
||||||
|
|
||||||
|
it("adds only html=1 to icon shapes, images and edge labels", () => {
|
||||||
|
const icon = cell(
|
||||||
|
"2",
|
||||||
|
"shape=mxgraph.aws4.resourceIcon;resIcon=mxgraph.aws4.ec2;verticalLabelPosition=bottom;",
|
||||||
|
)
|
||||||
|
const image = cell("3", "image=data:image/png,abc;")
|
||||||
|
const label = cell("4", "edgeLabel;align=center;")
|
||||||
|
const unconnectable = cell("5", "align=center;", ' connectable="0"')
|
||||||
|
expect(addDefaultStyles(icon)).toBe(
|
||||||
|
cell(
|
||||||
|
"2",
|
||||||
|
"shape=mxgraph.aws4.resourceIcon;resIcon=mxgraph.aws4.ec2;verticalLabelPosition=bottom;html=1;",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
expect(addDefaultStyles(image)).toBe(
|
||||||
|
cell("3", "image=data:image/png,abc;html=1;"),
|
||||||
|
)
|
||||||
|
expect(addDefaultStyles(label)).toBe(
|
||||||
|
cell("4", "edgeLabel;align=center;html=1;"),
|
||||||
|
)
|
||||||
|
expect(addDefaultStyles(unconnectable)).toBe(
|
||||||
|
cell("5", "align=center;html=1;", ' connectable="0"'),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
it("adds html=1 to edges and leaves the root cells alone", () => {
|
||||||
|
const roots = '<mxCell id="0"/><mxCell id="1" parent="0"/>'
|
||||||
|
expect(addDefaultStyles(roots + edge("4", "endArrow=block;"))).toBe(
|
||||||
|
roots + edge("4", "endArrow=block;html=1;"),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe("expandStyles", () => {
|
||||||
|
it("expands the names, then adds the defaults", () => {
|
||||||
|
const xml = `${BLUE}${cell("2", "blue;")}`
|
||||||
|
expect(expandStyles(xml)).toBe(
|
||||||
|
cell(
|
||||||
|
"2",
|
||||||
|
"fillColor=#dae8fc;strokeColor=#6c8ebf;whiteSpace=wrap;html=1;",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe("prepareNewDiagram with named styles", () => {
|
||||||
|
it("saves plain draw.io XML without the definitions", () => {
|
||||||
|
const xml = `${BLUE}\n<mxStyle name="flow" value="edgeStyle=orthogonalEdgeStyle;exitX=0.5;exitY=1;entryX=0.5;entryY=0;"/>\n${cell("2", "blue;")}\n${cell("3", "rhombus;blue;")}\n${edge("4", "flow;")}`
|
||||||
|
const prepared = prepareNewDiagram(xml, { pageId: "p1" })
|
||||||
|
expect(prepared.ok).toBe(true)
|
||||||
|
if (!prepared.ok) return
|
||||||
|
expect(prepared.xml).not.toContain("mxStyle")
|
||||||
|
expect(prepared.xml).toContain(
|
||||||
|
'style="fillColor=#dae8fc;strokeColor=#6c8ebf;whiteSpace=wrap;html=1;"',
|
||||||
|
)
|
||||||
|
expect(prepared.xml).toContain(
|
||||||
|
'style="rhombus;fillColor=#dae8fc;strokeColor=#6c8ebf;whiteSpace=wrap;html=1;"',
|
||||||
|
)
|
||||||
|
expect(prepared.xml).toContain(
|
||||||
|
'style="edgeStyle=orthogonalEdgeStyle;exitX=0.5;exitY=1;entryX=0.5;entryY=0;html=1;"',
|
||||||
|
)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe("editDiagram adds the default styles", () => {
|
||||||
|
it("adds html=1 and whiteSpace=wrap to an added cell", () => {
|
||||||
|
const file =
|
||||||
|
normalizeToMxfile(wrapCellsInModel(cell("2", "")), {
|
||||||
|
pageId: "p1",
|
||||||
|
pageName: "Page-1",
|
||||||
|
}) ?? ""
|
||||||
|
const outcome = editDiagram(
|
||||||
|
file,
|
||||||
|
[
|
||||||
|
{
|
||||||
|
operation: "add",
|
||||||
|
cell_id: "3",
|
||||||
|
new_xml: cell("3", "rounded=1;"),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
{},
|
||||||
|
)
|
||||||
|
expect(outcome.ok).toBe(true)
|
||||||
|
if (!outcome.ok) return
|
||||||
|
expect(outcome.xml).toContain(
|
||||||
|
'style="rounded=1;whiteSpace=wrap;html=1;"',
|
||||||
|
)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe("cells written in other shapes", () => {
|
||||||
|
it("keeps a self-closing cell without a style self-closing", () => {
|
||||||
|
const edge =
|
||||||
|
'<mxCell id="4" edge="1" parent="1" source="2" target="3"/>'
|
||||||
|
expect(addDefaultStyles(edge)).toBe(
|
||||||
|
'<mxCell id="4" edge="1" parent="1" source="2" target="3" style="html=1;"/>',
|
||||||
|
)
|
||||||
|
const shape = '<mxCell id="5" value="A" vertex="1" parent="1" />'
|
||||||
|
expect(addDefaultStyles(shape)).toBe(
|
||||||
|
'<mxCell id="5" value="A" vertex="1" parent="1" style="whiteSpace=wrap;html=1;"/>',
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
it("expands a name in a cell wrapped in a UserObject", () => {
|
||||||
|
const xml = `${BLUE}<UserObject id="4" label="Docs" link="https://example.com"><mxCell style="blue;" vertex="1" parent="1">${geometry}</mxCell></UserObject>`
|
||||||
|
expect(expandStyles(xml)).toBe(
|
||||||
|
`<UserObject id="4" label="Docs" link="https://example.com"><mxCell style="fillColor=#dae8fc;strokeColor=#6c8ebf;whiteSpace=wrap;html=1;" vertex="1" parent="1">${geometry}</mxCell></UserObject>`,
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
it("leaves text that sizes itself unwrapped", () => {
|
||||||
|
const xml = cell("2", "text;autosize=1;")
|
||||||
|
expect(addDefaultStyles(xml)).toBe(cell("2", "text;autosize=1;html=1;"))
|
||||||
|
})
|
||||||
|
|
||||||
|
it("reads a definition written without the closing slash", () => {
|
||||||
|
const { classes, xml } = readStyleClasses(
|
||||||
|
`<mxStyle name="a" value="x=1">\n${cell("2", "a;")}`,
|
||||||
|
)
|
||||||
|
expect(classes.get("a")).toBe("x=1")
|
||||||
|
expect(xml).toBe(cell("2", "a;"))
|
||||||
|
})
|
||||||
|
|
||||||
|
it("adds the defaults inside a full mxfile", () => {
|
||||||
|
const file = `<mxfile><diagram id="p" name="Page-1"><mxGraphModel><root><mxCell id="0"/><mxCell id="1" parent="0"/>${cell("2", "rounded=1;")}</root></mxGraphModel></diagram></mxfile>`
|
||||||
|
const prepared = prepareNewDiagram(file)
|
||||||
|
expect(prepared.ok).toBe(true)
|
||||||
|
if (!prepared.ok) return
|
||||||
|
expect(prepared.xml).toContain(
|
||||||
|
'<mxCell id="0"/><mxCell id="1" parent="0"/>',
|
||||||
|
)
|
||||||
|
expect(prepared.xml).toContain(
|
||||||
|
'style="rounded=1;whiteSpace=wrap;html=1;"',
|
||||||
|
)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe("truncation check with named styles", () => {
|
||||||
|
it("treats output cut off right after the definitions as incomplete", () => {
|
||||||
|
expect(isMxCellXmlComplete(BLUE)).toBe(false)
|
||||||
|
expect(
|
||||||
|
isMxCellXmlComplete(`${BLUE}<mxStyle name="flow" value="edgeSt`),
|
||||||
|
).toBe(false)
|
||||||
|
expect(isMxCellXmlComplete(`${BLUE}${cell("2", "blue;")}`)).toBe(true)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe("styles are rewritten as written", () => {
|
||||||
|
it("keeps entities, a leading semicolon and a missing trailing one", () => {
|
||||||
|
const quoted = cell(
|
||||||
|
"2",
|
||||||
|
"fontFamily="Times New Roman";fillColor=red;",
|
||||||
|
)
|
||||||
|
expect(addDefaultStyles(quoted)).toBe(
|
||||||
|
cell(
|
||||||
|
"2",
|
||||||
|
"fontFamily="Times New Roman";fillColor=red;whiteSpace=wrap;html=1;",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
const noDefaults = cell("3", ";shape=rectangle;strokeColor=#000000")
|
||||||
|
expect(addDefaultStyles(noDefaults)).toBe(
|
||||||
|
cell(
|
||||||
|
"3",
|
||||||
|
";shape=rectangle;strokeColor=#000000;whiteSpace=wrap;html=1;",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
const classes = readStyleClasses(BLUE).classes
|
||||||
|
expect(applyStyleClasses(cell("4", "blue"), classes)).toBe(
|
||||||
|
cell("4", "fillColor=#dae8fc;strokeColor=#6c8ebf"),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
it("does not add wrapping next to html=0, since wrapping turns HTML on", () => {
|
||||||
|
const xml = cell("2", "html=0;")
|
||||||
|
expect(addDefaultStyles(xml)).toBe(xml)
|
||||||
|
})
|
||||||
|
|
||||||
|
it("wraps ordinary shape= shapes and leaves library icons alone", () => {
|
||||||
|
expect(
|
||||||
|
addDefaultStyles(
|
||||||
|
cell(
|
||||||
|
"2",
|
||||||
|
"shape=parallelogram;perimeter=parallelogramPerimeter;",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
).toBe(
|
||||||
|
cell(
|
||||||
|
"2",
|
||||||
|
"shape=parallelogram;perimeter=parallelogramPerimeter;whiteSpace=wrap;html=1;",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
expect(
|
||||||
|
addDefaultStyles(
|
||||||
|
cell(
|
||||||
|
"3",
|
||||||
|
"shape=mxgraph.cisco19.router;verticalLabelPosition=bottom;",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
).toBe(
|
||||||
|
cell(
|
||||||
|
"3",
|
||||||
|
"shape=mxgraph.cisco19.router;verticalLabelPosition=bottom;html=1;",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
// Library shapes whose label sits inside wrap like any shape
|
||||||
|
expect(
|
||||||
|
addDefaultStyles(cell("5", "shape=mxgraph.flowchart.process;")),
|
||||||
|
).toBe(
|
||||||
|
cell(
|
||||||
|
"5",
|
||||||
|
"shape=mxgraph.flowchart.process;whiteSpace=wrap;html=1;",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
expect(addDefaultStyles(cell("4", "shape=image;image=a.png;"))).toBe(
|
||||||
|
cell("4", "shape=image;image=a.png;html=1;"),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
it("escapes a quote that a single-quoted definition brings in", () => {
|
||||||
|
const xml = `<mxStyle name="a" value='fontFamily="Arial";'/>${cell("2", "a;")}`
|
||||||
|
expect(expandStyles(xml)).toBe(
|
||||||
|
cell("2", "fontFamily="Arial";whiteSpace=wrap;html=1;"),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
it("resolves references in either order; duplicates and built-in names", () => {
|
||||||
|
const { classes } = readStyleClasses(
|
||||||
|
`<mxStyle name="b" value="c;x=1;"/><mxStyle name="c" value="y=2;"/><mxStyle name="c" value="y=3;"/><mxStyle name="loop" value="loop;z=1;"/>`,
|
||||||
|
)
|
||||||
|
// A name defined later still resolves; the last definition wins
|
||||||
|
expect(classes.get("b")).toBe("y=3;x=1")
|
||||||
|
// A name using itself stays a bare token
|
||||||
|
expect(classes.get("loop")).toBe("loop;z=1")
|
||||||
|
// The last definition of a name wins
|
||||||
|
expect(classes.get("c")).toBe("y=3")
|
||||||
|
// A definition named like a built-in replaces that built-in's meaning
|
||||||
|
const shadow = readStyleClasses(
|
||||||
|
'<mxStyle name="ellipse" value="fillColor=red;"/>',
|
||||||
|
).classes
|
||||||
|
expect(applyStyleClasses(cell("2", "ellipse;"), shadow)).toBe(
|
||||||
|
cell("2", "fillColor=red;"),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe("the auto-fix runs before the defaults", () => {
|
||||||
|
it("repairs quotes written as entities in an edit and then adds the defaults", () => {
|
||||||
|
const file = normalizeToMxfile(wrapCellsInModel(cell("2", "")), {
|
||||||
|
pageId: "p1",
|
||||||
|
pageName: "Page-1",
|
||||||
|
})
|
||||||
|
const outcome = editDiagram(
|
||||||
|
file ?? "",
|
||||||
|
[
|
||||||
|
{
|
||||||
|
operation: "add",
|
||||||
|
cell_id: "3",
|
||||||
|
new_xml: `<mxCell id="3" value="B" style="rounded=1;" vertex="1" parent="1">${geometry}</mxCell>`,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
{},
|
||||||
|
)
|
||||||
|
expect(outcome.ok).toBe(true)
|
||||||
|
if (!outcome.ok) return
|
||||||
|
expect(outcome.xml).toContain(
|
||||||
|
'style="rounded=1;whiteSpace=wrap;html=1;"',
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
it("repairs a new diagram the same way", () => {
|
||||||
|
const prepared = prepareNewDiagram(
|
||||||
|
`${BLUE}<mxCell id="2" value="A" style="blue;" vertex="1" parent="1">${geometry}</mxCell>`,
|
||||||
|
)
|
||||||
|
expect(prepared.ok).toBe(true)
|
||||||
|
if (!prepared.ok) return
|
||||||
|
expect(prepared.xml).toContain(
|
||||||
|
'style="fillColor=#dae8fc;strokeColor=#6c8ebf;whiteSpace=wrap;html=1;"',
|
||||||
|
)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe("definitions and cells the model may write imperfectly", () => {
|
||||||
|
it("escapes & and < in a definition, which the validator never sees", () => {
|
||||||
|
const xml = `<mxStyle name="f" value="fontFamily=A&B <C;"/>${cell("2", "f;")}`
|
||||||
|
const prepared = prepareNewDiagram(xml)
|
||||||
|
expect(prepared.ok).toBe(true)
|
||||||
|
if (!prepared.ok) return
|
||||||
|
expect(prepared.xml).toContain(
|
||||||
|
'style="fontFamily=A&B <C;whiteSpace=wrap;html=1;"',
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
it("accepts a lowercase tag, a style attribute and a name ending in ;", () => {
|
||||||
|
const { classes, xml } = readStyleClasses(
|
||||||
|
`<mxstyle name="blue;" style="fillColor=red;"/>${cell("2", "blue;")}`,
|
||||||
|
)
|
||||||
|
expect(classes.get("blue")).toBe("fillColor=red")
|
||||||
|
expect(xml).toBe(cell("2", "blue;"))
|
||||||
|
})
|
||||||
|
|
||||||
|
it("reads keys exactly, like draw.io, so a key after a space does not count", () => {
|
||||||
|
const xml = cell("2", "rounded=1; html=1; whiteSpace=wrap;")
|
||||||
|
expect(addDefaultStyles(xml)).toBe(
|
||||||
|
cell(
|
||||||
|
"2",
|
||||||
|
"rounded=1; html=1; whiteSpace=wrap;whiteSpace=wrap;html=1;",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
const classes = readStyleClasses(BLUE).classes
|
||||||
|
expect(
|
||||||
|
applyStyleClasses(
|
||||||
|
cell("3", "rounded=1; blue; fontSize=14;"),
|
||||||
|
classes,
|
||||||
|
),
|
||||||
|
).toBe(
|
||||||
|
cell(
|
||||||
|
"3",
|
||||||
|
"rounded=1;fillColor=#dae8fc;strokeColor=#6c8ebf; fontSize=14;",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
it("treats a vertex whose parent is an edge as an edge label", () => {
|
||||||
|
const xml = `${edge("e1", "")}<mxCell id="lbl" value="yes" style="align=center;" vertex="1" parent="e1"><mxGeometry relative="1" as="geometry"/></mxCell>`
|
||||||
|
expect(addDefaultStyles(xml)).toContain('style="align=center;html=1;"')
|
||||||
|
})
|
||||||
|
|
||||||
|
it("reports a definition that never closed and definitions without cells", () => {
|
||||||
|
const open = prepareNewDiagram(
|
||||||
|
`${BLUE}<mxStyle name="flow" value="edgeSt`,
|
||||||
|
)
|
||||||
|
expect(open.ok).toBe(false)
|
||||||
|
if (!open.ok) expect(open.error).toContain("not closed")
|
||||||
|
const only = prepareNewDiagram(BLUE)
|
||||||
|
expect(only.ok).toBe(false)
|
||||||
|
if (!only.ok) expect(only.error).toContain("no cells")
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe("edge labels and empty definitions", () => {
|
||||||
|
it("does not wrap a label added to an edge the XML does not show", () => {
|
||||||
|
const file =
|
||||||
|
normalizeToMxfile(
|
||||||
|
wrapCellsInModel(cell("2", "") + cell("3", "") + edge("4", "")),
|
||||||
|
{
|
||||||
|
pageId: "p1",
|
||||||
|
pageName: "Page-1",
|
||||||
|
},
|
||||||
|
) ?? ""
|
||||||
|
const label = `<mxCell id="9" value="yes" style="text;" vertex="1" parent="4"><mxGeometry x="-0.5" relative="1" as="geometry"/></mxCell>`
|
||||||
|
const outcome = editDiagram(
|
||||||
|
file,
|
||||||
|
[{ operation: "add", cell_id: "9", new_xml: label }],
|
||||||
|
{},
|
||||||
|
)
|
||||||
|
expect(outcome.ok).toBe(true)
|
||||||
|
if (!outcome.ok) return
|
||||||
|
expect(outcome.xml).toContain('id="9" value="yes" style="text;html=1;"')
|
||||||
|
// The same through the function the preview uses
|
||||||
|
expect(addDefaultStyles(label, ["4"])).toContain('style="text;html=1;"')
|
||||||
|
})
|
||||||
|
|
||||||
|
it("treats a cell marked edge and vertex as an edge", () => {
|
||||||
|
const both = `<mxCell id="5" style="rounded=1;" edge="1" vertex="1" parent="1" source="2" target="3"><mxGeometry relative="1" as="geometry"/></mxCell>`
|
||||||
|
expect(addDefaultStyles(both)).toContain('style="rounded=1;html=1;"')
|
||||||
|
})
|
||||||
|
|
||||||
|
it("drops a name whose definition is empty instead of leaving a leading ;", () => {
|
||||||
|
const xml = `<mxStyle name="plain" value=""/>${cell("2", "plain;rounded=1;")}`
|
||||||
|
expect(expandStyles(xml)).toBe(
|
||||||
|
cell("2", "rounded=1;whiteSpace=wrap;html=1;"),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
it("tells the model that edit_diagram has no named styles", () => {
|
||||||
|
const file =
|
||||||
|
normalizeToMxfile(wrapCellsInModel(cell("2", "")), {
|
||||||
|
pageId: "p1",
|
||||||
|
pageName: "Page-1",
|
||||||
|
}) ?? ""
|
||||||
|
const outcome = editDiagram(
|
||||||
|
file,
|
||||||
|
[
|
||||||
|
{
|
||||||
|
operation: "add",
|
||||||
|
cell_id: "3",
|
||||||
|
new_xml: `${BLUE}${cell("3", "blue;")}`,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
{},
|
||||||
|
)
|
||||||
|
expect(outcome.ok).toBe(false)
|
||||||
|
if (!outcome.ok)
|
||||||
|
expect(outcome.errors[0]).toContain("not available in edit_diagram")
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -11,11 +11,17 @@ const box = (id: string) =>
|
|||||||
const BEFORE_FIRST_EDIT = page(box("a"))
|
const BEFORE_FIRST_EDIT = page(box("a"))
|
||||||
const AFTER_FIRST_EDIT = page(box("a") + box("b"))
|
const AFTER_FIRST_EDIT = page(box("a") + box("b"))
|
||||||
|
|
||||||
|
const mocks = vi.hoisted(() => ({
|
||||||
|
loadDiagram: vi.fn((_xml: string, ..._rest: unknown[]) => null),
|
||||||
|
// The canvas for a test that needs another one
|
||||||
|
canvas: null as string | null,
|
||||||
|
}))
|
||||||
|
|
||||||
vi.mock("@/contexts/diagram-context", () => ({
|
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: vi.fn(() => null),
|
loadDiagram: mocks.loadDiagram,
|
||||||
}),
|
}),
|
||||||
}))
|
}))
|
||||||
|
|
||||||
@@ -59,3 +65,163 @@ describe("the streaming preview of a second edit", () => {
|
|||||||
)
|
)
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
describe("the streaming preview of display_diagram", () => {
|
||||||
|
it("expands the named styles and adds the defaults to the cells so far", () => {
|
||||||
|
mocks.loadDiagram.mockClear()
|
||||||
|
// A complete definition and cell, then a cell still being written
|
||||||
|
const xml = `<mxStyle name="blue" value="fillColor=#dae8fc;strokeColor=#6c8ebf;"/>
|
||||||
|
<mxCell id="a" value="a" style="rounded=1;blue;" vertex="1" parent="1"><mxGeometry x="0" y="0" width="80" height="40" as="geometry"/></mxCell>
|
||||||
|
<mxCell id="b" value="b" style="blue;" vertex="1" parent="1"><mxGeometry x="0" y="0" wid`
|
||||||
|
const messages = [
|
||||||
|
{
|
||||||
|
id: "m1",
|
||||||
|
role: "assistant",
|
||||||
|
parts: [
|
||||||
|
{
|
||||||
|
type: "tool-display_diagram",
|
||||||
|
toolCallId: "draw-1",
|
||||||
|
state: "input-streaming",
|
||||||
|
input: { xml },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
] as any
|
||||||
|
renderHook(() =>
|
||||||
|
useDiagramStreaming({
|
||||||
|
messages,
|
||||||
|
processedToolCallsRef: { current: new Set() },
|
||||||
|
editDiagramOriginalXmlRef: { current: new Map() },
|
||||||
|
loadedMessageIdsRef: { current: new Set() },
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
expect(mocks.loadDiagram).toHaveBeenCalledTimes(1)
|
||||||
|
const loaded = mocks.loadDiagram.mock.calls[0][0]
|
||||||
|
expect(loaded).toContain(
|
||||||
|
'style="rounded=1;fillColor=#dae8fc;strokeColor=#6c8ebf;whiteSpace=wrap;html=1;"',
|
||||||
|
)
|
||||||
|
expect(loaded).not.toContain("mxStyle")
|
||||||
|
expect(loaded).not.toContain('id="b"')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe("the streaming preview of edit_diagram", () => {
|
||||||
|
it("adds the default styles to the cell being added, like the final edit", () => {
|
||||||
|
mocks.loadDiagram.mockClear()
|
||||||
|
const messages = [
|
||||||
|
{
|
||||||
|
id: "m1",
|
||||||
|
role: "assistant",
|
||||||
|
parts: [
|
||||||
|
{
|
||||||
|
type: "tool-edit_diagram",
|
||||||
|
toolCallId: "edit-3",
|
||||||
|
state: "input-streaming",
|
||||||
|
input: {
|
||||||
|
operations: [
|
||||||
|
{
|
||||||
|
operation: "add",
|
||||||
|
cell_id: "c",
|
||||||
|
new_xml: box("c").replace(
|
||||||
|
'vertex="1"',
|
||||||
|
'style="rounded=1;" vertex="1"',
|
||||||
|
),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
] as any
|
||||||
|
renderHook(() =>
|
||||||
|
useDiagramStreaming({
|
||||||
|
messages,
|
||||||
|
processedToolCallsRef: { current: new Set() },
|
||||||
|
editDiagramOriginalXmlRef: { current: new Map() },
|
||||||
|
loadedMessageIdsRef: { current: new Set() },
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
expect(mocks.loadDiagram).toHaveBeenCalledTimes(1)
|
||||||
|
expect(mocks.loadDiagram.mock.calls[0][0]).toContain(
|
||||||
|
'style="rounded=1;whiteSpace=wrap;html=1;"',
|
||||||
|
)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
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