mirror of
https://github.com/DayuanJiang/next-ai-draw-io.git
synced 2026-10-11 03:59:58 +08:00
* fix(chat): the AI reads and draws the page the user is viewing The model always got the first page and display_diagram replaced the whole file with one page, so drawing on page 3 of a multi-page file deleted the other pages (#241), and edits went to page 1 while the user looked at another page. Now a turn records the page the user is viewing when the message is sent. That page's model goes to the model (also as previousXml, on regenerate and in edit_diagram error messages), selected shapes are sent on any page, display_diagram and append_diagram replace only that page (a drawing that brings several pages still replaces the file), edit_diagram targets it, and the streaming preview draws on it. Compact cells and default styles take the layer and the edges of that page, in the preview and in editDiagram. The editor bridge replaces the current page in place when the other pages are unchanged, so Ctrl+Z undoes an AI change on a multi-page file in one step; when a full load is needed, the user's page is shown again afterwards. Version summaries and the compare dialog count the page the change was made on. * test: read the failed edit's output without unsafe optional chaining
131 lines
4.7 KiB
TypeScript
131 lines
4.7 KiB
TypeScript
/**
|
|
* Core of the edit_diagram tool, kept free of session state so it can be
|
|
* unit tested.
|
|
*
|
|
* All-or-nothing, like the web app (hooks/use-diagram-tool-handlers.ts):
|
|
* if any operation fails, nothing is written and every failure is reported,
|
|
* so the model never builds on a half-applied edit.
|
|
*/
|
|
|
|
import { defaultLayerOf, expandCompactCells } from "./compact-cells.ts"
|
|
import {
|
|
applyDiagramOperations,
|
|
type DiagramOperation,
|
|
} from "./diagram-operations.ts"
|
|
import { type PageSelector, projectPage } from "./pages.ts"
|
|
import { addDefaultStyles, edgeIdsOf } from "./style-classes.ts"
|
|
import { validateAndFixXml, validateMxCellStructure } from "./xml-validation.ts"
|
|
|
|
export type EditOutcome =
|
|
| { ok: true; xml: string; applied: number; fixes: string[] }
|
|
| { ok: false; errors: string[]; pageError: boolean }
|
|
|
|
/** Number of top-level elements in an XML fragment. */
|
|
function countTopLevelElements(fragment: string): number {
|
|
const doc = new DOMParser().parseFromString(
|
|
`<wrapper>${fragment}</wrapper>`,
|
|
"text/xml",
|
|
)
|
|
// On a syntax error the browser adds a <parsererror> element (Chrome
|
|
// next to the cells, Firefox as the root); the syntax is checked later
|
|
const root = doc.documentElement
|
|
if (!root || root.tagName === "parsererror") return 1
|
|
return Array.from(root.children).filter(
|
|
(el) => el.tagName !== "parsererror",
|
|
).length
|
|
}
|
|
|
|
/** The target page as a one-page <mxfile>, or the whole document. */
|
|
export function targetPageXml(xml: string, selector: PageSelector): string {
|
|
const projection = projectPage(xml, selector)
|
|
return projection.ok ? projection.xml : xml
|
|
}
|
|
|
|
export function editDiagram(
|
|
xml: string,
|
|
operations: DiagramOperation[],
|
|
selector: PageSelector,
|
|
): EditOutcome {
|
|
const errors: string[] = []
|
|
const fixes: string[] = []
|
|
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) {
|
|
if (op.operation === "delete" || !op.new_xml) {
|
|
prepared.push(op)
|
|
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
|
|
// with a misleading "only one root" syntax error.
|
|
if (countTopLevelElements(op.new_xml) > 1) {
|
|
errors.push(
|
|
`${op.operation} ${op.cell_id}: new_xml must contain exactly one cell; use one add operation per cell`,
|
|
)
|
|
continue
|
|
}
|
|
const check = validateAndFixXml(op.new_xml)
|
|
if (!check.valid) {
|
|
errors.push(
|
|
`${op.operation} ${op.cell_id}: invalid new_xml: ${check.error}`,
|
|
)
|
|
continue
|
|
}
|
|
if (check.fixed) {
|
|
fixes.push(`${op.cell_id}: ${check.fixes.join(", ")}`)
|
|
}
|
|
// 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 }
|
|
|
|
const { result, errors: opErrors } = applyDiagramOperations(
|
|
xml,
|
|
prepared,
|
|
selector,
|
|
)
|
|
// An empty cellId means the page itself could not be edited
|
|
const pageLevel = opErrors.find((e) => e.cellId === "")
|
|
if (pageLevel) {
|
|
return { ok: false, errors: [pageLevel.message], pageError: true }
|
|
}
|
|
if (opErrors.length > 0) {
|
|
return {
|
|
ok: false,
|
|
errors: opErrors.map((e) => `${e.type} ${e.cellId}: ${e.message}`),
|
|
pageError: false,
|
|
}
|
|
}
|
|
|
|
// Validate only the target page, and reject only errors this edit
|
|
// introduced: problems already in other pages or in a loaded file must
|
|
// not block every edit.
|
|
const after = validateMxCellStructure(targetPageXml(result, selector))
|
|
if (after && !validateMxCellStructure(targetPageXml(xml, selector))) {
|
|
return {
|
|
ok: false,
|
|
errors: [`the edit would make the page invalid: ${after}`],
|
|
pageError: false,
|
|
}
|
|
}
|
|
|
|
return { ok: true, xml: result, applied: operations.length, fixes }
|
|
}
|