mirror of
https://github.com/DayuanJiang/next-ai-draw-io.git
synced 2026-10-13 04:59:51 +08:00
refactor: remove dead code and over-engineering found by two audits (#986)
## Problem
Two audits of the repository (a workflow of finder, dedup and per-file verifier agents, run on 2026-10-11 against 1f4141e and again against 07f3e3c after a first round of refactoring) found dead code and features that were more machinery than the app needs: an Electron configuration-preset system with its own settings window, three copies of the about page, a hand-written `.env` parser, an "extended" system prompt nobody selected, two data migrations for formats from 2025-12 and 2026-01, a server-side example cache, type-to-confirm provider deletion, 81 unused dictionary keys, wrapper components around single call sites, and a long tail of unused exports, props, options and tests.
## Change
Every item below was either approved one by one or covered by "fix all of the others".
- **Electron:** the configuration-preset system is gone (settings window, preload, IPC, menu entry, dev watcher); `config-manager.ts` keeps only `userLocale`; `.env` files are parsed with `dotenv`. Entitlements file and a duplicate CI workflow removed; `test.yml` gets a type check and concurrency.
- **Server:** no extended prompt, no example cache, no `ENABLE_HISTORY_XML_REPLACE`, no message metadata or `hasConfig`; the connection test is one shared helper; EdgeOne loses its mock branch and model aliases; `DEBUG_LLM_PAYLOAD` logs message structure instead of payloads; Langfuse scoring helper; `apiKeyEnv` arrays kept and documented in `env.example` and the provider docs.
- **Pages:** one about page per language from a single component (old `/cn` and `/ja` URLs redirect with 308); layout uses `hasLocale`; the GitHub icon is a local component instead of `react-icons`; Cloudflare deploys without an R2 cache by default.
- **Chat and canvas:** the legacy `diagramHistory` conversion, the localStorage chat migration and the templates storage are gone; `chat-engine` reads the diagram through the context's refs; the diagram context has one tagged export path (`requestTaggedExport` / `fetchDiagramXml`); the screenshot check uses fetch + JSON and `generateText` with `Output.object`; `load-diagram` accepts `<svg:svg>` and strips the XML prolog.
- **Settings UI:** `model-selector` inlines its Dialog, Command and Tooltip; `ai-elements/model-selector`, `button-with-tooltip`, `error-toast` removed; no type-to-confirm, no footer, no `bedrockApiKey`; `use-model-config` loses the old-format migration; one reasoning component; the shimmer is CSS (no `motion`, no `@radix-ui/react-use-controllable-state`); 81 dictionary keys removed in all four languages.
- **MCP server:** `registerTool` / `ok` / `fail` replace per-tool try/catch; `syncFromBrowser`, `commitDocument`, `describePages`; the hand-written XML syntax check, unused result fields, `pickPageSelector`, `expandStyles` and the `host` attribute handling are gone; tests get a shared `setup.ts` and drive the server through the SDK client.
- **Prompt:** the web app's style rules come from the MCP drawing guide (`styleRules`), byte-identical to before.
- **Tests:** shared e2e helpers (`openApp`, `sse`, `toolCallEvents`, `chunkedReplies`); placeholder specs dropped; `settings-review-fixes` split by topic; unit tests use `vi.stubEnv`.
- **Dependencies removed:** `@xmldom/xmldom`, `remark-gfm`, `@anthropic-ai/tokenizer`, `@testing-library/user-event`, `concurrently`, `wait-on`, `eslint`, `eslint-config-next`, `@vitest/coverage-v8`, `shx`, `motion`, `@radix-ui/react-use-controllable-state`, `react-icons`.
Source lines: 74,983 before, 67,418 after (excluding lockfiles).
The last commit merges #984 (the canvas shell). Where the shell, the classic page or their tests depend on a wire format or signature this branch had changed, main's version wins (export fields of the poll response, `addHistory` signature); where main only extended code this branch restructured, main's change is carried into the new structure (`start_session`, `get_selection`, `prepareNewDiagram`, the diagram context's `requestExport`).
## Testing
- `tsc --noEmit` for the web app, `packages/mcp-server`, its shell and `electron`: clean
- `biome ci`: 0 errors (37 pre-existing warnings)
- Root `vitest run`: 719 passed; `packages/mcp-server` `vitest run`: 372 passed
- `next build`: clean
- Playwright, whole web suite on the dev server (4 workers): 280 tests, 279 passed; `language.spec.ts` "a stale offer to switch without saving does nothing once a message is being sent" failed under parallel load and passed alone, as it does on main
This commit is contained in:
+17
-245
@@ -1,6 +1,10 @@
|
||||
import { type ClassValue, clsx } from "clsx"
|
||||
import * as pako from "pako"
|
||||
import { twMerge } from "tailwind-merge"
|
||||
import { pageElement } from "@/lib/diagram-pages"
|
||||
import {
|
||||
decompressPageContent,
|
||||
extractSvgEmbeddedXml,
|
||||
} from "@/packages/mcp-server/src/load-diagram.ts"
|
||||
import { hasCells } from "@/packages/mcp-server/src/pages.ts"
|
||||
|
||||
export function cn(...inputs: ClassValue[]) {
|
||||
@@ -100,197 +104,6 @@ export function formatXML(xml: string, indent: string = " "): string {
|
||||
return formatted.trim()
|
||||
}
|
||||
|
||||
/**
|
||||
* Efficiently converts a potentially incomplete XML string to a legal XML string by closing any open tags properly.
|
||||
* Additionally, if an <mxCell> tag does not have an mxGeometry child (e.g. <mxCell id="3">),
|
||||
* it removes that tag from the output.
|
||||
* Also removes orphaned <mxPoint> elements that aren't inside <Array> or don't have proper 'as' attribute.
|
||||
* @param xmlString The potentially incomplete XML string
|
||||
* @returns A legal XML string with properly closed tags and removed incomplete mxCell elements.
|
||||
*/
|
||||
export function convertToLegalXml(xmlString: string): string {
|
||||
// This regex will match either self-closing <mxCell .../> or a block element
|
||||
// <mxCell ...> ... </mxCell>. Unfinished ones are left out because they don't match.
|
||||
const regex =
|
||||
/<mxCell\b(?:[^<>"']|"[^"]*"|'[^']*')*?(?:\/>|>([\s\S]*?)<\/mxCell>)/g
|
||||
let match: RegExpExecArray | null
|
||||
let result = "<root>\n"
|
||||
|
||||
while ((match = regex.exec(xmlString)) !== null) {
|
||||
// match[0] contains the entire matched mxCell block
|
||||
let cellContent = match[0]
|
||||
|
||||
// Remove orphaned <mxPoint> elements that are directly inside <mxGeometry>
|
||||
// without an 'as' attribute (like as="sourcePoint", as="targetPoint")
|
||||
// and not inside <Array as="points">
|
||||
// These cause "Could not add object mxPoint" errors in draw.io
|
||||
// First check if there's an <Array as="points"> - if so, keep all mxPoints inside it
|
||||
const hasArrayPoints = /<Array\s+as="points">/.test(cellContent)
|
||||
if (!hasArrayPoints) {
|
||||
// Remove mxPoint elements without 'as' attribute
|
||||
cellContent = cellContent.replace(
|
||||
/<mxPoint\b[^>]*\/>/g,
|
||||
(pointMatch) => {
|
||||
// Keep if it has an 'as' attribute
|
||||
if (/\sas=/.test(pointMatch)) {
|
||||
return pointMatch
|
||||
}
|
||||
// Remove orphaned mxPoint
|
||||
return ""
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
// Fix unescaped & characters in attribute values (but not valid entities)
|
||||
// This prevents DOMParser from failing on content like "semantic & missing-step"
|
||||
cellContent = cellContent.replace(
|
||||
/&(?!(?:lt|gt|amp|quot|apos|#[0-9]+|#x[0-9a-fA-F]+);)/g,
|
||||
"&",
|
||||
)
|
||||
|
||||
// Fix unescaped < and > in attribute values for XML parsing
|
||||
// HTML content in value attributes (e.g., <b>Title</b>) needs to be escaped
|
||||
// This is critical because DOMParser will fail on unescaped < > in attributes
|
||||
if (/=\s*"[^"]*<[^"]*"/.test(cellContent)) {
|
||||
cellContent = cellContent.replace(
|
||||
/=\s*"([^"]*)"/g,
|
||||
(_match, value) => {
|
||||
const escaped = value
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
return `="${escaped}"`
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
// Indent each line of the matched block for readability.
|
||||
const formatted = cellContent
|
||||
.split("\n")
|
||||
.map((line) => " " + line.trim())
|
||||
.filter((line) => line.trim()) // Remove empty lines from removed mxPoints
|
||||
.join("\n")
|
||||
result += formatted + "\n"
|
||||
}
|
||||
result += "</root>"
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
/**
|
||||
* Replace the cells of one page of a Draw.io XML document
|
||||
* @param currentXML - The original Draw.io XML string
|
||||
* @param nodes - The XML string containing new nodes to replace in the diagram
|
||||
* @param pageId - The page whose cells are replaced; the first page when
|
||||
* missing or not found
|
||||
* @returns The updated XML string with replaced nodes
|
||||
*/
|
||||
export function replaceNodes(
|
||||
currentXML: string,
|
||||
nodes: string,
|
||||
pageId?: string | null,
|
||||
): string {
|
||||
// Check for valid inputs
|
||||
if (!currentXML || !nodes) {
|
||||
throw new Error("Both currentXML and nodes must be provided")
|
||||
}
|
||||
|
||||
try {
|
||||
// Parse the XML strings to create DOM objects
|
||||
const parser = new DOMParser()
|
||||
const currentDoc = parser.parseFromString(currentXML, "text/xml")
|
||||
|
||||
// Handle nodes input - if it doesn't contain <root>, wrap it
|
||||
let nodesString = nodes
|
||||
if (!nodes.includes("<root>")) {
|
||||
nodesString = `<root>${nodes}</root>`
|
||||
}
|
||||
|
||||
const nodesDoc = parser.parseFromString(nodesString, "text/xml")
|
||||
|
||||
// The page to draw on, when the document has it
|
||||
const page = pageId
|
||||
? Array.from(currentDoc.getElementsByTagName("diagram")).find(
|
||||
(diagram) => diagram.getAttribute("id") === pageId,
|
||||
)
|
||||
: undefined
|
||||
const scope: ParentNode = page ?? currentDoc
|
||||
// Find the root element in the current document
|
||||
let currentRoot = scope.querySelector("mxGraphModel > root")
|
||||
if (!currentRoot) {
|
||||
// If no root element is found, create the proper structure
|
||||
const mxGraphModel =
|
||||
scope.querySelector("mxGraphModel") ||
|
||||
currentDoc.createElement("mxGraphModel")
|
||||
|
||||
if (!currentDoc.contains(mxGraphModel)) {
|
||||
;(page ?? currentDoc).appendChild(mxGraphModel)
|
||||
}
|
||||
|
||||
currentRoot = currentDoc.createElement("root")
|
||||
mxGraphModel.appendChild(currentRoot)
|
||||
}
|
||||
|
||||
// Find the root element in the nodes document
|
||||
const nodesRoot = nodesDoc.querySelector("root")
|
||||
if (!nodesRoot) {
|
||||
throw new Error(
|
||||
"Invalid nodes: Could not find or create <root> element",
|
||||
)
|
||||
}
|
||||
|
||||
// Clear all existing child elements from the current root
|
||||
while (currentRoot.firstChild) {
|
||||
currentRoot.removeChild(currentRoot.firstChild)
|
||||
}
|
||||
|
||||
// Ensure the base cells exist
|
||||
const hasCell0 = Array.from(nodesRoot.childNodes).some(
|
||||
(node) =>
|
||||
node.nodeName === "mxCell" &&
|
||||
(node as Element).getAttribute("id") === "0",
|
||||
)
|
||||
|
||||
const hasCell1 = Array.from(nodesRoot.childNodes).some(
|
||||
(node) =>
|
||||
node.nodeName === "mxCell" &&
|
||||
(node as Element).getAttribute("id") === "1",
|
||||
)
|
||||
|
||||
// Copy all child nodes from the nodes root to the current root
|
||||
Array.from(nodesRoot.childNodes).forEach((node) => {
|
||||
const importedNode = currentDoc.importNode(node, true)
|
||||
currentRoot.appendChild(importedNode)
|
||||
})
|
||||
|
||||
// Add default cells if they don't exist
|
||||
if (!hasCell0) {
|
||||
const cell0 = currentDoc.createElement("mxCell")
|
||||
cell0.setAttribute("id", "0")
|
||||
currentRoot.insertBefore(cell0, currentRoot.firstChild)
|
||||
}
|
||||
|
||||
if (!hasCell1) {
|
||||
const cell1 = currentDoc.createElement("mxCell")
|
||||
cell1.setAttribute("id", "1")
|
||||
cell1.setAttribute("parent", "0")
|
||||
|
||||
// Insert after cell0 if possible
|
||||
const cell0 = currentRoot.querySelector('mxCell[id="0"]')
|
||||
if (cell0?.nextSibling) {
|
||||
currentRoot.insertBefore(cell1, cell0.nextSibling)
|
||||
} else {
|
||||
currentRoot.appendChild(cell1)
|
||||
}
|
||||
}
|
||||
|
||||
// Convert the modified DOM back to a string
|
||||
const serializer = new XMLSerializer()
|
||||
return serializer.serializeToString(currentDoc)
|
||||
} catch (error) {
|
||||
throw new Error(`Error replacing nodes: ${error}`)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Decode an xmlsvg export (SVG data URL) into uncompressed diagram XML.
|
||||
* One page is returned: the one with the given id, else the first; for the
|
||||
@@ -301,67 +114,26 @@ export function extractDiagramXML(
|
||||
pageId?: string | null,
|
||||
): string {
|
||||
try {
|
||||
// 1. Parse the SVG string (using built-in DOMParser in a browser-like environment)
|
||||
const svgString = atob(xml_svg_string.slice(26))
|
||||
const parser = new DOMParser()
|
||||
const svgDoc = parser.parseFromString(svgString, "image/svg+xml")
|
||||
const svgElement = svgDoc.querySelector("svg")
|
||||
|
||||
if (!svgElement) {
|
||||
// The data URL holds the SVG; its root element's content attribute
|
||||
// holds the mxfile, whose pages are compressed
|
||||
const svg = atob(xml_svg_string.slice(26))
|
||||
const content = extractSvgEmbeddedXml(svg)
|
||||
if (content === undefined) {
|
||||
throw new Error("No SVG element found in the input string.")
|
||||
}
|
||||
// 2. Extract the 'content' attribute
|
||||
const encodedContent = svgElement.getAttribute("content")
|
||||
|
||||
if (!encodedContent) {
|
||||
if (!content) {
|
||||
throw new Error("SVG element does not have a 'content' attribute.")
|
||||
}
|
||||
|
||||
// 3. getAttribute already decoded the entities: decoding again
|
||||
// would break values that hold them (a page named "R & D")
|
||||
const xmlContent = encodedContent
|
||||
|
||||
// 4. Parse the XML content
|
||||
const xmlDoc = parser.parseFromString(xmlContent, "text/xml")
|
||||
const diagrams = Array.from(xmlDoc.getElementsByTagName("diagram"))
|
||||
const diagramElement =
|
||||
(pageId &&
|
||||
diagrams.find(
|
||||
(diagram) => diagram.getAttribute("id") === pageId,
|
||||
)) ||
|
||||
diagrams[0]
|
||||
|
||||
if (!diagramElement) {
|
||||
const doc = new DOMParser().parseFromString(content, "text/xml")
|
||||
const diagram = pageElement(doc, pageId)
|
||||
if (!diagram) {
|
||||
throw new Error("No diagram element found")
|
||||
}
|
||||
// 5. Extract base64 encoded data
|
||||
const base64EncodedData = diagramElement.textContent
|
||||
|
||||
if (!base64EncodedData) {
|
||||
const xml = decompressPageContent(diagram.textContent ?? "")
|
||||
if (!xml) {
|
||||
throw new Error("No encoded data found in the diagram element")
|
||||
}
|
||||
|
||||
// 6. Decode base64 data
|
||||
const binaryString = atob(base64EncodedData)
|
||||
|
||||
// 7. Convert binary string to Uint8Array
|
||||
const len = binaryString.length
|
||||
const bytes = new Uint8Array(len)
|
||||
for (let i = 0; i < len; i++) {
|
||||
bytes[i] = binaryString.charCodeAt(i)
|
||||
}
|
||||
|
||||
// 8. Decompress data using pako (equivalent to zlib.decompress with wbits=-15)
|
||||
const decompressedData = pako.inflate(bytes, { windowBits: -15 })
|
||||
|
||||
// 9. Convert the decompressed data to a string
|
||||
const decoder = new TextDecoder("utf-8")
|
||||
const decodedString = decoder.decode(decompressedData)
|
||||
|
||||
// Decode URL-encoded content (equivalent to Python's urllib.parse.unquote)
|
||||
const urlDecodedString = decodeURIComponent(decodedString)
|
||||
|
||||
return urlDecodedString
|
||||
return xml
|
||||
} catch (error) {
|
||||
console.error("Error extracting diagram XML:", error)
|
||||
throw error // Re-throw for caller handling
|
||||
|
||||
Reference in New Issue
Block a user