diff --git a/.cursor/skills/graph-wizard/SKILL.md b/.cursor/skills/graph-wizard/SKILL.md new file mode 100644 index 0000000..60ead3c --- /dev/null +++ b/.cursor/skills/graph-wizard/SKILL.md @@ -0,0 +1,149 @@ +--- +name: graph-wizard +description: Use when you need to turn a natural-language architecture or topology prompt into maxGraph XML, an mxGraph-compatible XML export, a rendered PNG, a diagram report, and a markdown summary using the `javascript/max-graph-editor` project. +--- + +# graph-wizard + +## Overview + +This skill captures the `graph-wizard` workflow implemented in `javascript/max-graph-editor`. + +Use it when the task is to: + +- draft a diagram from a plain-English prompt +- render the diagram to an image +- inspect the rendered output +- hand back machine-readable artifacts like XML, JSON, and markdown summary + +The workflow is Bun-first and Bash-friendly. Do not depend on Nushell. + + +## When To Use + +Use this skill when the user wants something like: + +- "make me a graph of ..." +- "generate a max graph / mxGraph diagram from this description" +- "render an architecture diagram from a prompt" +- "iterate on a topology diagram" +- "produce XML and an image for a system diagram" + +Do not use this skill if the task is only about manual editing of an existing diagram in the browser UI with no prompt-driven generation. + + +## Files And Commands + +Project root: + +- `javascript/max-graph-editor` + +Important files: + +- `javascript/max-graph-editor/graph-wizard.ts` +- `javascript/max-graph-editor/src/wizard/` +- `javascript/max-graph-editor/src/client/main.tsx` +- `javascript/max-graph-editor/README.md` + +Core commands: + +1. Install dependencies + - ```shell + export PATH="$HOME/.bun/bin:$PATH" + bun install + ``` +2. Build the browser client + - ```shell + export PATH="$HOME/.bun/bin:$PATH" + bun run build-client + ``` +3. Type-check the project + - ```shell + export PATH="$HOME/.bun/bin:$PATH" + bun run check + ``` +4. Run the wizard + - ```shell + export PATH="$HOME/.bun/bin:$PATH" + bun graph-wizard.ts "please give me a graph of a network topology for how AWS Lambda functions work and please show the logging infrastructure" + ``` + + +## Expected Outputs + +The wizard writes an artifact bundle under: + +- `javascript/max-graph-editor/out/-/` + +Expect these files: + +- `prompt.txt` +- `diagram-spec.json` +- `diagram.maxgraph.xml` +- `diagram.mxgraph.xml` +- `diagram.png` +- `diagram-report.json` +- `diagram-summary.md` + +The CLI also prints a JSON result to stdout describing the output directory, provider used, final spec, render report, summary, and artifact paths. + + +## Provider Behavior + +- If `OPENAI_API_KEY` is set, `graph-wizard` will attempt to use OpenAI for the first draft. +- If no API key is configured, or if the OpenAI call fails, the workflow falls back to a deterministic heuristic drafter. +- The fallback behavior is acceptable and expected in cloud environments where credentials are unavailable. + + +## Validation Workflow + +When using this skill, validate in this order: + +1. Run: + - ```shell + export PATH="$HOME/.bun/bin:$PATH" + bun run build-client + bun run check + ``` +2. Run `graph-wizard.ts` with the user's prompt. +3. Inspect: + - stdout JSON + - `diagram-report.json` + - `diagram-summary.md` + - `diagram.png` +4. If the change affects UI/rendering, manually validate in the browser: + - start the server against the generated `diagram.maxgraph.xml` + - open normal editor mode + - open render-only mode with `?mode=render` + - confirm the diagram is legible and the topology is coherent + +Recommended manual validation command: + +- ```shell + export PATH="$HOME/.bun/bin:$PATH" + bun src/server.ts "/absolute/path/to/generated/diagram.maxgraph.xml" 3200 + ``` + +Then visit: + +- `http://127.0.0.1:3200` +- `http://127.0.0.1:3200/?mode=render` + + +## Output Quality Checklist + +Before calling the work done, check: + +- nodes referenced by the prompt are present +- edges form a coherent flow rather than isolated boxes +- observability/logging nodes are included when the prompt asks for them +- network/topology prompts do not leave boundary nodes disconnected +- `diagram.png` is diagram-only and free of editor chrome +- `diagram-summary.md` explains the result and suggests useful next refinements + + +## Notes For Agents + +- Prefer Bun commands directly over `do.nu`. +- Preserve the generated bundle in `out/`; it is useful evidence. +- If you improve the workflow itself, update this skill and the project README together. diff --git a/javascript/max-graph-editor/.gitignore b/javascript/max-graph-editor/.gitignore index 1eafc8a..e199312 100644 --- a/javascript/max-graph-editor/.gitignore +++ b/javascript/max-graph-editor/.gitignore @@ -1 +1,3 @@ /public/app.js +/.my/ +/out/ diff --git a/javascript/max-graph-editor/README.md b/javascript/max-graph-editor/README.md index 544b36f..39bb9fd 100644 --- a/javascript/max-graph-editor/README.md +++ b/javascript/max-graph-editor/README.md @@ -2,7 +2,7 @@ **AI First Draft**: Much of the `max-graph-editor` project was AI-generated. Treat this as a first draft until I prune dead ends and fill in missing pieces. -A minimal [maxGraph][max-graph] editor with live disk sync. +A minimal [maxGraph][max-graph] editor with live disk sync, plus a `graph-wizard` CLI that drafts and renders diagrams from natural-language prompts. ## Overview @@ -12,82 +12,66 @@ I like Mermaid diagrams but I need some architecture diagrams which tend to incl ## Instructions -Follow these instructions to build and run the editor. - -1. Activate the Nushell `do` module - - ```nushell - do activate - ``` -2. Generate the `package.json` file (if needed) - - ```nushell - do package-json - ``` -3. Install dependencies - - ```nushell - do install - ``` -4. Build the browser client bundle - - ```nushell - do build-client - ``` -5. Type-check the TypeScript client code - - ```nushell - do check - ``` -6. Start the editor server with the example diagram file - - ```nushell - do server-start data/example-diagram.xml - ``` -7. Confirm server status - - ```nushell - do server-status - ``` -8. Start a Puppeteer-managed browser instance (headful) - - ```nushell - do browser-start --mode headful --url http://127.0.0.1:3000 - ``` -9. Confirm browser status - - ```nushell - do browser-status - ``` -10. Capture a screenshot via Puppeteer - - ```nushell - do screenshot - ``` -11. Dynamically inject JavaScript in the page (example: add a box) - - ```nushell - do browser-eval --js '({label,x,y,width,height}) => { const api = window.__maxGraphEditor; if (!api) return "missing api"; return api.insertVertex(label, x, y, width, height); }' --args-json '{"label":"New Box","x":220,"y":360,"width":140,"height":60}' - ``` -12. Optional: inject JavaScript as a screenshot pre-step (single command) - - ```nushell - do screenshot --pre-js '({label,x,y}) => { const api = window.__maxGraphEditor; if (!api) return null; return api.insertVertex(label, x, y, 140, 60); }' --pre-args-json '{"label":"PreShot Box","x":420,"y":460}' - ``` -13. Capture another screenshot after dynamic actions - - ```nushell - do screenshot - ``` -14. Print the newest screenshot path - - ```nushell - do screenshot-latest - ``` -15. Optional: explicit URL/output path - - ```nushell - do screenshot --url http://127.0.0.1:3000 --out .my/screenshots/manual.png - ``` -16. Export a maxGraph XML file to mxGraph-compatible XML - - ```nushell - do export-mxgraph data/example-diagram.xml - ``` -17. Stop both server and browser when done - - ```nushell - do stop - ``` +Follow these instructions to build and run the editor or the `graph-wizard` CLI. + +### Bun / Bash workflow + +1. Install dependencies + - `bun install` +2. Build the browser client bundle + - `bun run build-client` +3. Type-check the project + - `bun run check` +4. Start the editor server with the example diagram file + - `bun do.ts server-start --diagram data/example-diagram.xml` +5. Start a Puppeteer-managed browser instance (headful) + - `bun do.ts browser-start --mode headful --url http://127.0.0.1:3000` +6. Capture a screenshot + - `bun do.ts screenshot` +7. Stop the browser and server + - `bun do.ts stop` + +### `graph-wizard` workflow + +Run the wizard with a natural-language prompt. + +- `bun graph-wizard.ts "please give me a graph of a network topology for how AWS Lambda functions work and please show the logging infrastructure"` + +By default, the wizard: + +- drafts a structured diagram spec from the prompt +- generates maxGraph XML +- exports mxGraph-compatible XML +- renders a diagram-only PNG in a headless browser +- inspects the rendered diagram and applies a small revision pass +- writes a final summary plus suggested changes + +The artifact bundle is written under `out/-/`: + +- `prompt.txt` +- `diagram-spec.json` +- `diagram.maxgraph.xml` +- `diagram.mxgraph.xml` +- `diagram.png` +- `diagram-report.json` +- `diagram-summary.md` + +The wizard uses OpenAI automatically when `OPENAI_API_KEY` is set. Otherwise it falls back to a deterministic heuristic drafter so the command still works offline. + +### Nushell compatibility + +If you still want the old helper module, the `do.nu` wrappers remain available: + +- `do install` +- `do build-client` +- `do check` +- `do graph-wizard "describe the graph"` The key point is that interaction logic is dynamic: -- Use `do browser-eval --js '...'` or `--js-file ...` for browser-side logic. -- Use `--args-json ...` to parameterize injected JavaScript (no hardcoded operation flags needed). -- Use `do screenshot --pre-js ...` when you want “mutate page then capture” in one command. +- Use `bun do.ts browser-eval --js '...'` or `--js-file ...` for browser-side logic. +- Use `--args-json ...` to parameterize injected JavaScript. +- Use `bun do.ts screenshot --pre-js ...` when you want “mutate page then capture” in one command. ## Wish List @@ -98,7 +82,8 @@ General clean-ups, TODOs and things I wish to implement for this project: - [x] DONE Ability to add components - [x] DONE Ability to delete components - [x] DONE Add a minimal maxGraph-to-mxGraph compatibility pass for draw.io workflows (for example, compatibility renames in XML where feasible). -- [ ] Add a headless diagram-to-image rendering path (for LLM agentic coding loops where image context is useful). +- [x] DONE Add a headless diagram-to-image rendering path (for LLM agentic coding loops where image context is useful). +- [ ] Iterate on the `graph-wizard` prompting loop so rendered-image critique can feed richer revisions. - [ ] Consider wiring the server to be launched with `my-node-launcher`. - [ ] Use Bun instead of npm across my JavaScript projects. diff --git a/javascript/max-graph-editor/bun.lock b/javascript/max-graph-editor/bun.lock index c334167..20f3ff3 100644 --- a/javascript/max-graph-editor/bun.lock +++ b/javascript/max-graph-editor/bun.lock @@ -6,13 +6,16 @@ "name": "max-graph-editor", "dependencies": { "@maxgraph/core": "0.22.0", + "openai": "6.33.0", "puppeteer": "24.37.2", "react": "19.2.4", "react-dom": "19.2.4", }, "devDependencies": { + "@types/node": "25.5.0", "@types/react": "19.2.13", "@types/react-dom": "19.2.3", + "bun-types": "1.3.11", "typescript": "5.9.3", }, }, @@ -28,7 +31,7 @@ "@tootallnate/quickjs-emscripten": ["@tootallnate/quickjs-emscripten@0.23.0", "", {}, "sha512-C5Mc6rdnsaJDjO3UpGW/CQTHtCKaYlScZTly4JIu97Jxo/odCiH0ITnDXSJPTOrEKk/ycSZ0AOgTmkDtkOsvIA=="], - "@types/node": ["@types/node@25.2.2", "", { "dependencies": { "undici-types": "~7.16.0" } }, "sha512-BkmoP5/FhRYek5izySdkOneRyXYN35I860MFAGupTdebyE66uZaR+bXLHq8k4DirE5DwQi3NuhvRU1jqTVwUrQ=="], + "@types/node": ["@types/node@25.5.0", "", { "dependencies": { "undici-types": "~7.18.0" } }, "sha512-jp2P3tQMSxWugkCUKLRPVUpGaL5MVFwF8RDuSRztfwgN1wmqJeMSbKlnEtQqU8UrhTmzEmZdu2I6v2dpp7XIxw=="], "@types/react": ["@types/react@19.2.13", "", { "dependencies": { "csstype": "^3.2.2" } }, "sha512-KkiJeU6VbYbUOp5ITMIc7kBfqlYkKA5KhEHVrGMmUUMt7NeaZg65ojdPk+FtNrBAOXNVM5QM72jnADjM+XVRAQ=="], @@ -64,6 +67,8 @@ "buffer-crc32": ["buffer-crc32@0.2.13", "", {}, "sha512-VO9Ht/+p3SN7SKWqcrgEzjGbRSJYTx+Q1pTQC0wrWqHx0vpJraQ6GtHx8tvcg1rlK1byhU5gccxgOgj7B0TDkQ=="], + "bun-types": ["bun-types@1.3.11", "", { "dependencies": { "@types/node": "*" } }, "sha512-1KGPpoxQWl9f6wcZh57LvrPIInQMn2TQ7jsgxqpRzg+l0QPOFvJVH7HmvHo/AiPgwXy+/Thf6Ov3EdVn1vOabg=="], + "callsites": ["callsites@3.1.0", "", {}, "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ=="], "chromium-bidi": ["chromium-bidi@13.1.1", "", { "dependencies": { "mitt": "^3.0.1", "zod": "^3.24.1" }, "peerDependencies": { "devtools-protocol": "*" } }, "sha512-zB9MpoPd7VJwjowQqiW3FKOvQwffFMjQ8Iejp5ZW+sJaKLRhZX1sTxzl3Zt22TDB4zP0OOqs8lRoY7eAW5geyQ=="], @@ -148,6 +153,8 @@ "once": ["once@1.4.0", "", { "dependencies": { "wrappy": "1" } }, "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w=="], + "openai": ["openai@6.33.0", "", { "peerDependencies": { "ws": "^8.18.0", "zod": "^3.25 || ^4.0" }, "optionalPeers": ["ws", "zod"], "bin": { "openai": "bin/cli" } }, "sha512-xAYN1W3YsDXJWA5F277135YfkEk6H7D3D6vWwRhJ3OEkzRgcyK8z/P5P9Gyi/wB4N8kK9kM5ZjprfvyHagKmpw=="], + "pac-proxy-agent": ["pac-proxy-agent@7.2.0", "", { "dependencies": { "@tootallnate/quickjs-emscripten": "^0.23.0", "agent-base": "^7.1.2", "debug": "^4.3.4", "get-uri": "^6.0.1", "http-proxy-agent": "^7.0.0", "https-proxy-agent": "^7.0.6", "pac-resolver": "^7.0.1", "socks-proxy-agent": "^8.0.5" } }, "sha512-TEB8ESquiLMc0lV8vcd5Ql/JAKAoyzHFXaStwjkzpOpC5Yv+pIzLfHvjTSdf3vpa2bMiUQrg9i6276yn8666aA=="], "pac-resolver": ["pac-resolver@7.0.1", "", { "dependencies": { "degenerator": "^5.0.0", "netmask": "^2.0.2" } }, "sha512-5NPgf87AT2STgwa2ntRMr45jTKrYBGkVU36yT0ig/n/GMAa3oPqhZfIQ2kMEimReg0+t9kZViDVZ83qfVUlckg=="], @@ -210,7 +217,7 @@ "typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="], - "undici-types": ["undici-types@7.16.0", "", {}, "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw=="], + "undici-types": ["undici-types@7.18.2", "", {}, "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w=="], "webdriver-bidi-protocol": ["webdriver-bidi-protocol@0.4.0", "", {}, "sha512-U9VIlNRrq94d1xxR9JrCEAx5Gv/2W7ERSv8oWRoNe/QYbfccS0V3h/H6qeNeCRJxXGMhhnkqvwNrvPAYeuP9VA=="], @@ -229,5 +236,9 @@ "yauzl": ["yauzl@2.10.0", "", { "dependencies": { "buffer-crc32": "~0.2.3", "fd-slicer": "~1.1.0" } }, "sha512-p4a9I6X6nu6IhoGmBqAcbJy1mlC4j27vEPZX9F4L4/vZT3Lyq1VkFHw/V/PUcB9Buo+DG3iHkT0x3Qya58zc3g=="], "zod": ["zod@3.25.76", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="], + + "@types/yauzl/@types/node": ["@types/node@25.2.2", "", { "dependencies": { "undici-types": "~7.16.0" } }, "sha512-BkmoP5/FhRYek5izySdkOneRyXYN35I860MFAGupTdebyE66uZaR+bXLHq8k4DirE5DwQi3NuhvRU1jqTVwUrQ=="], + + "@types/yauzl/@types/node/undici-types": ["undici-types@7.16.0", "", {}, "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw=="], } } diff --git a/javascript/max-graph-editor/do.nu b/javascript/max-graph-editor/do.nu index 2b615bd..1249700 100644 --- a/javascript/max-graph-editor/do.nu +++ b/javascript/max-graph-editor/do.nu @@ -40,12 +40,12 @@ export def install [] { export def build-client [] { cd $DIR - bun build src/client/main.tsx --outfile public/app.js --format esm --target browser + run-external bun run build-client } export def check [] { cd $DIR - bunx tsc --project tsconfig.json --noEmit + run-external bun run check } export def run [diagram: string, --port: int = 3000] { @@ -144,3 +144,8 @@ export def stop [] { let args = ["stop"] run-external bun do.ts ...$args } + +export def graph-wizard [...args: string] { + cd $DIR + run-external bun graph-wizard.ts ...$args +} diff --git a/javascript/max-graph-editor/graph-wizard.ts b/javascript/max-graph-editor/graph-wizard.ts new file mode 100644 index 0000000..7a9f037 --- /dev/null +++ b/javascript/max-graph-editor/graph-wizard.ts @@ -0,0 +1,333 @@ +#!/usr/bin/env bun +import { mkdir } from 'node:fs/promises'; +import { dirname, join, resolve } from 'node:path'; +import { draftHeuristicDiagram } from './src/wizard/heuristic'; +import { applyAutoLayout } from './src/wizard/layout'; +import { draftOpenAiDiagram } from './src/wizard/openai'; +import { renderDiagramArtifacts } from './src/wizard/render'; +import { reviseSpecWithReport } from './src/wizard/revise'; +import type { DiagramDraft, DiagramSpec, WizardArtifacts, WizardResult } from './src/wizard/types'; +import { fileStemFromPrompt, json, titleFromPrompt, writeTextFile } from './src/wizard/utils'; +import { specToMaxGraphXml } from './src/wizard/xml'; + +const ROOT = dirname(new URL(import.meta.url).pathname); + +type Args = { + positional: string[]; + flags: Map; +}; + +const args = parseArgs(Bun.argv.slice(2)); + +try { + const result = await run(args); + process.stdout.write(`${JSON.stringify(result, null, 2)}\n`); +} catch (error) { + console.error(error instanceof Error ? error.message : String(error)); + process.exit(1); +} + +async function run(parsed: Args): Promise { + const prompt = getPrompt(parsed); + const provider = getFlag(parsed, 'provider', 'auto'); + const requestedOutDir = getOptionalFlag(parsed, 'out-dir'); + const renderPort = Number(getFlag(parsed, 'port', '3100')); + const maxIterations = Math.max(0, Number(getFlag(parsed, 'max-iterations', '1'))); + + if (!Number.isInteger(renderPort) || renderPort <= 0 || renderPort > 65535) { + throw new Error(`Invalid --port value: ${renderPort}`); + } + if (!Number.isInteger(maxIterations) || maxIterations < 0 || maxIterations > 4) { + throw new Error(`Invalid --max-iterations value: ${maxIterations}`); + } + + const draft = await draftDiagram(prompt, provider); + let spec = applyAutoLayout(draft.spec); + const outputDir = resolve(ROOT, requestedOutDir || join('out', fileStemFromPrompt(prompt))); + await mkdir(outputDir, { recursive: true }); + + const promptPath = join(outputDir, 'prompt.txt'); + const specPath = join(outputDir, 'diagram-spec.json'); + const xmlPath = join(outputDir, 'diagram.maxgraph.xml'); + const reportPath = join(outputDir, 'diagram-report.json'); + const imagePath = join(outputDir, 'diagram.png'); + const summaryPath = join(outputDir, 'diagram-summary.md'); + const mxgraphXmlPath = join(outputDir, 'diagram.mxgraph.xml'); + + const providerNotes = [...draft.notes]; + let revisionsApplied = 0; + let report = await writeAndRender(spec, { + outputDir, + prompt, + promptPath, + specPath, + xmlPath, + reportPath, + imagePath, + mxgraphXmlPath, + renderPort, + }); + + for (let iteration = 0; iteration < maxIterations; iteration += 1) { + const revision = reviseSpecWithReport(spec, report, prompt); + if (!revision.changed) { + break; + } + revisionsApplied += 1; + spec = applyAutoLayout(revision.spec); + report = await writeAndRender(spec, { + outputDir, + prompt, + promptPath, + specPath, + xmlPath, + reportPath, + imagePath, + mxgraphXmlPath, + renderPort, + }); + } + + const summary = renderSummary(prompt, draft.provider, spec, report, providerNotes, revisionsApplied); + await writeTextFile(summaryPath, summary); + + const artifacts: WizardArtifacts = { + promptPath, + specPath, + xmlPath, + mxgraphXmlPath, + imagePath, + reportPath, + summaryPath, + }; + + return { + outputDir, + provider: draft.provider, + spec, + report, + summary, + artifacts, + revisionsApplied, + providerNotes, + }; +} + +async function writeAndRender( + spec: DiagramSpec, + options: { + outputDir: string; + prompt: string; + promptPath: string; + specPath: string; + xmlPath: string; + reportPath: string; + imagePath: string; + mxgraphXmlPath: string; + renderPort: number; + }, +) { + const xml = specToMaxGraphXml(spec); + await writeTextFile(options.promptPath, `${options.prompt}\n`); + await writeTextFile(options.specPath, json(spec)); + await writeTextFile(options.xmlPath, xml); + await writeTextFile(options.mxgraphXmlPath, convertMaxGraphXmlToMxGraphXml(xml)); + + return renderDiagramArtifacts({ + rootDir: ROOT, + diagramPath: options.xmlPath, + imagePath: options.imagePath, + reportPath: options.reportPath, + port: options.renderPort, + }); +} + +async function draftDiagram(prompt: string, provider: string): Promise { + switch (provider) { + case 'heuristic': + return draftHeuristicDiagram(prompt); + case 'openai': + return draftOpenAiDiagram(prompt); + case 'auto': + return draftOpenAiDiagram(prompt); + default: + throw new Error(`Unknown --provider value: ${provider}`); + } +} + +function renderSummary( + prompt: string, + provider: string, + spec: DiagramSpec, + report: WizardResult['report'], + providerNotes: string[], + revisionsApplied: number, +) { + const lines = [ + `# ${titleFromPrompt(prompt)}`, + '', + `Provider: ${provider}`, + `Revisions applied after render inspection: ${revisionsApplied}`, + '', + '## Final diagram description', + spec.summary, + '', + '## Final graph contents', + `- Nodes: ${report.vertexCount}`, + `- Edges: ${report.edgeCount}`, + `- Labels: ${report.labels.join(', ') || '(none)'}`, + '', + '## Rationale', + ...spec.rationale.map((item) => `- ${item}`), + '', + '## Suggestions for changes', + ...spec.changeSuggestions.map((item) => `- ${item}`), + '', + '## Provider notes', + ...providerNotes.map((item) => `- ${item}`), + '', + ]; + return lines.join('\n'); +} + +function getPrompt(parsed: Args) { + const fromFlag = getOptionalFlag(parsed, 'prompt'); + if (fromFlag) { + return fromFlag.trim(); + } + if (parsed.positional.length > 0) { + return parsed.positional.join(' ').trim(); + } + throw new Error('Missing prompt. Use graph-wizard "describe your graph" or --prompt "..."'); +} + +function parseArgs(argv: string[]): Args { + const positional: string[] = []; + const flags = new Map(); + for (let i = 0; i < argv.length; i += 1) { + const token = argv[i]; + if (!token.startsWith('--')) { + positional.push(token); + continue; + } + const key = token.slice(2); + const next = argv[i + 1]; + if (!next || next.startsWith('--')) { + flags.set(key, true); + continue; + } + flags.set(key, next); + i += 1; + } + return { positional, flags }; +} + +function getFlag(parsed: Args, key: string, fallback: string) { + const value = parsed.flags.get(key); + if (typeof value === 'string') { + return value; + } + if (typeof value === 'boolean') { + throw new Error(`Missing value for --${key}`); + } + return fallback; +} + +function getOptionalFlag(parsed: Args, key: string) { + const value = parsed.flags.get(key); + if (typeof value === 'string') { + return value; + } + if (typeof value === 'boolean') { + throw new Error(`Missing value for --${key}`); + } + return null; +} + +function convertMaxGraphXmlToMxGraphXml(xml: string) { + let output = xml.replace(/\r\n/g, '\n'); + + output = output.replace(//g, (cellBlock) => convertCellBlockToMxGraph(cellBlock)); + output = output + .replace(//g, '') + .replace(//g, '') + .replace(//g, '') + .replace(//g, ''); + output = output.replace(/\b_([a-zA-Z][a-zA-Z0-9]*)=/g, '$1='); + + return output; +} + +function convertCellBlockToMxGraph(cellBlock: string) { + const openTagMatch = /^]*>/.exec(cellBlock); + if (!openTagMatch) { + return cellBlock; + } + const openTag = openTagMatch[0]; + const closeTag = ''; + if (!cellBlock.endsWith(closeTag)) { + return cellBlock; + } + const body = cellBlock.slice(openTag.length, cellBlock.length - closeTag.length); + const extracted = extractStyleNode(body); + if (!extracted) { + return cellBlock; + } + const cleanOpenTag = openTag.replace(/\sstyle="[^"]*"/g, ''); + const withStyleOpenTag = extracted.style.length > 0 + ? cleanOpenTag.replace(/>$/, ` style="${escapeXmlAttribute(extracted.style)}">`) + : cleanOpenTag; + return `${withStyleOpenTag}${extracted.bodyWithoutStyle}${closeTag}`; +} + +function extractStyleNode(body: string): { bodyWithoutStyle: string; style: string } | null { + const styleNodeRegexes = [ + /]*?)\bas="style"([^>]*)\/>/m, + /]*?)\bas="style"([^>]*)>([\s\S]*?)<\/Object>/m, + ]; + + for (const regex of styleNodeRegexes) { + const match = regex.exec(body); + if (!match || match.index < 0) { + continue; + } + const attributes = parseXmlAttributes(`${match[1] ?? ''} ${match[2] ?? ''}`); + const style = buildMxStyle(attributes); + const bodyWithoutStyle = `${body.slice(0, match.index)}${body.slice(match.index + match[0].length)}`; + return { bodyWithoutStyle, style }; + } + return null; +} + +function parseXmlAttributes(raw: string): Map { + const attributes = new Map(); + const attrRegex = /([a-zA-Z_:][a-zA-Z0-9_.:-]*)="([^"]*)"/g; + for (const match of raw.matchAll(attrRegex)) { + attributes.set(match[1], match[2]); + } + return attributes; +} + +function buildMxStyle(attributes: Map) { + const styleEntries: string[] = []; + for (const [rawKey, value] of attributes.entries()) { + if (rawKey === 'as') { + continue; + } + const mxKey = rawKey === 'autoSize' ? 'autosize' : rawKey; + styleEntries.push(`${mxKey}=${value}`); + } + return styleEntries.length === 0 ? '' : `${styleEntries.join(';')};`; +} + +function escapeXmlAttribute(value: string) { + return value + .replaceAll('&', '&') + .replaceAll('"', '"') + .replaceAll('<', '<') + .replaceAll('>', '>'); +} diff --git a/javascript/max-graph-editor/package.json b/javascript/max-graph-editor/package.json index 30b3292..c837f06 100644 --- a/javascript/max-graph-editor/package.json +++ b/javascript/max-graph-editor/package.json @@ -3,15 +3,25 @@ "version": "0.1.0", "private": true, "type": "module", + "scripts": { + "build-client": "bun build src/client/main.tsx --outfile public/app.js --format esm --target browser", + "check": "tsc --project tsconfig.json --noEmit && tsc --project tsconfig.bun.json --noEmit" + }, + "bin": { + "graph-wizard": "./graph-wizard.ts" + }, "dependencies": { "@maxgraph/core": "0.22.0", + "openai": "6.33.0", "puppeteer": "24.37.2", "react": "19.2.4", "react-dom": "19.2.4" }, "devDependencies": { + "@types/node": "25.5.0", "@types/react": "19.2.13", "@types/react-dom": "19.2.3", + "bun-types": "1.3.11", "typescript": "5.9.3" } } \ No newline at end of file diff --git a/javascript/max-graph-editor/public/index.html b/javascript/max-graph-editor/public/index.html index 164c32d..1f143a7 100644 --- a/javascript/max-graph-editor/public/index.html +++ b/javascript/max-graph-editor/public/index.html @@ -13,6 +13,11 @@ color: #f0f3f8; } + body.render-only { + background: #ffffff; + color: #111827; + } + header { padding: 12px 16px; border-bottom: 1px solid #242a33; @@ -148,6 +153,25 @@ margin-left: 0; } } + + body.render-only main { + display: block; + padding: 0; + height: 100vh; + min-height: 100vh; + } + + body.render-only .panel { + background: #ffffff; + border: 0; + border-radius: 0; + } + + body.render-only #graph { + margin: 0; + border-radius: 0; + min-height: 100vh; + } diff --git a/javascript/max-graph-editor/src/client/main.tsx b/javascript/max-graph-editor/src/client/main.tsx index 3c33b42..49a8ff5 100644 --- a/javascript/max-graph-editor/src/client/main.tsx +++ b/javascript/max-graph-editor/src/client/main.tsx @@ -1,6 +1,7 @@ import React, { useEffect, useMemo, useRef, useState } from 'react'; import { createRoot } from 'react-dom/client'; import { + type Cell, Graph, InternalEvent, KeyHandler, @@ -16,6 +17,41 @@ type SyncMessage = { message?: string; }; +type DiagramNodeReport = { + id: string; + label: string; + x: number; + y: number; + width: number; + height: number; + style: string; +}; + +type DiagramEdgeReport = { + id: string; + label: string; + sourceId: string; + sourceLabel: string; + targetId: string; + targetLabel: string; + style: string; +}; + +type DiagramReport = { + generatedAt: string; + vertexCount: number; + edgeCount: number; + bounds: { + x: number; + y: number; + width: number; + height: number; + }; + labels: string[]; + nodes: DiagramNodeReport[]; + edges: DiagramEdgeReport[]; +}; + type MaxGraphEditorApi = { graph: Graph; insertVertex: ( @@ -38,6 +74,8 @@ type MaxGraphEditorApi = { deleteSelection: () => number; getXml: () => string; setXml: (xml: string) => void; + getDiagramReport: () => DiagramReport; + prepareForExport: (options?: { padding?: number; maxScale?: number }) => DiagramReport; }; const RECT_STYLE = { rounded: 1, whiteSpace: 'wrap', html: 1, fillColor: '#dae8fc', strokeColor: '#6c8ebf' }; @@ -72,6 +110,14 @@ function App() { () => `${location.protocol === 'https:' ? 'wss' : 'ws'}://${location.host}/sync`, [], ); + const renderOnly = useMemo(() => new URLSearchParams(location.search).get('mode') === 'render', []); + + useEffect(() => { + document.body.classList.toggle('render-only', renderOnly); + return () => { + document.body.classList.remove('render-only'); + }; + }, [renderOnly]); useEffect(() => { const container = graphContainerRef.current; @@ -82,25 +128,30 @@ function App() { container.tabIndex = 0; InternalEvent.disableContextMenu(container); const graph = new Graph(container); - graph.setConnectable(true); - graph.setCellsEditable(true); - graph.setPanning(true); + graph.setConnectable(!renderOnly); + graph.setCellsEditable(!renderOnly); + graph.setCellsMovable(!renderOnly); + graph.setCellsResizable(!renderOnly); + graph.setPanning(!renderOnly); new RubberBandHandler(graph); graph.getDataModel().addListener(InternalEvent.CHANGE, onGraphModelChanged); const onSelectionChanged = () => setSelectionCount(graph.getSelectionCount()); graph.getSelectionModel().addListener(InternalEvent.CHANGE, onSelectionChanged); - const keyHandler = new KeyHandler(graph, container); - keyHandler.bindKey(46, () => { - removeSelection(); - }); - keyHandler.bindKey(8, () => { - removeSelection(); - }); - keyHandlerRef.current = keyHandler; + let keyHandler: KeyHandler | null = null; + if (!renderOnly) { + keyHandler = new KeyHandler(graph, container); + keyHandler.bindKey(46, () => { + removeSelection(); + }); + keyHandler.bindKey(8, () => { + removeSelection(); + }); + keyHandlerRef.current = keyHandler; + } const popupMenuHandler = graph.getPlugin('PopupMenuHandler'); - if (popupMenuHandler) { + if (popupMenuHandler && !renderOnly) { popupMenuHandler.factoryMethod = (menu, cell, mouseEvent) => { const point = graph.getPointForEvent(mouseEvent); menu.addItem('Add Box', null, () => { @@ -136,7 +187,9 @@ function App() { const focusGraph = () => { container.focus(); }; - container.addEventListener('pointerdown', focusGraph); + if (!renderOnly) { + container.addEventListener('pointerdown', focusGraph); + } onSelectionChanged(); graphRef.current = graph; @@ -170,6 +223,12 @@ function App() { applyingXmlRef.current = false; } }, + getDiagramReport() { + return buildDiagramReport(); + }, + prepareForExport(options) { + return prepareForExport(options); + }, }; window.__maxGraphEditor = api; @@ -177,15 +236,17 @@ function App() { if (window.__maxGraphEditor === api) { delete window.__maxGraphEditor; } - container.removeEventListener('pointerdown', focusGraph); - keyHandler.onDestroy(); + if (!renderOnly) { + container.removeEventListener('pointerdown', focusGraph); + } + keyHandler?.onDestroy(); keyHandlerRef.current = null; graph.getSelectionModel().removeListener(onSelectionChanged); graph.getDataModel().removeListener(onGraphModelChanged); graph.destroy(); graphRef.current = null; }; - }, []); + }, [renderOnly]); useEffect(() => { void loadInitial(); @@ -274,6 +335,12 @@ function App() { } finally { applyingXmlRef.current = false; } + + if (renderOnly) { + window.setTimeout(() => { + prepareForExport(); + }, 25); + } } function onGraphModelChanged() { @@ -425,44 +492,135 @@ function App() { setPreview(`[${new Date().toLocaleTimeString()}] ${source}\n\n${nextXml}`); } + function buildDiagramReport(): DiagramReport { + const graph = graphRef.current; + if (!graph) { + return { + generatedAt: new Date().toISOString(), + vertexCount: 0, + edgeCount: 0, + bounds: { x: 0, y: 0, width: 0, height: 0 }, + labels: [], + nodes: [], + edges: [], + }; + } + + const parent = graph.getDefaultParent() as Cell; + const nodes = parent.getChildVertices().map((cell) => { + const geometry = cell.getGeometry(); + return { + id: String(cell.id ?? ''), + label: graph.getLabel(cell) ?? '', + x: geometry?.x ?? 0, + y: geometry?.y ?? 0, + width: geometry?.width ?? 0, + height: geometry?.height ?? 0, + style: String(cell.getStyle() ?? ''), + }; + }); + const edges = parent.getChildEdges().map((cell) => { + const source = cell.getTerminal(true); + const target = cell.getTerminal(false); + return { + id: String(cell.id ?? ''), + label: graph.getLabel(cell) ?? '', + sourceId: String(source?.id ?? ''), + sourceLabel: source ? graph.getLabel(source) ?? '' : '', + targetId: String(target?.id ?? ''), + targetLabel: target ? graph.getLabel(target) ?? '' : '', + style: String(cell.getStyle() ?? ''), + }; + }); + + const bounds = graph.getGraphBounds(); + return { + generatedAt: new Date().toISOString(), + vertexCount: nodes.length, + edgeCount: edges.length, + bounds: { + x: Math.round(bounds.x), + y: Math.round(bounds.y), + width: Math.round(bounds.width), + height: Math.round(bounds.height), + }, + labels: nodes.map((node) => node.label).filter((label) => label.length > 0), + nodes, + edges, + }; + } + + function prepareForExport(options?: { padding?: number; maxScale?: number }): DiagramReport { + const graph = graphRef.current; + const container = graphContainerRef.current; + if (!graph || !container) { + return buildDiagramReport(); + } + + const padding = Math.max(0, Number(options?.padding ?? 24)); + const maxScale = Math.max(0.1, Number(options?.maxScale ?? 1.4)); + graph.refresh(); + graph.zoomActual(); + + const bounds = graph.getGraphBounds(); + if (bounds.width > 0 && bounds.height > 0) { + const availableWidth = Math.max(200, container.clientWidth - padding * 2); + const availableHeight = Math.max(200, container.clientHeight - padding * 2); + const scale = Math.min(availableWidth / bounds.width, availableHeight / bounds.height, maxScale); + if (Number.isFinite(scale) && scale > 0) { + graph.zoomTo(scale, false); + } + graph.center(true, true); + } + + graph.refresh(); + return buildDiagramReport(); + } + return ( <> -
- MaxGraph Editor + Live Disk Sync - {status} -
+ {!renderOnly && ( +
+ MaxGraph Editor + Live Disk Sync + {status} +
+ )}
+ {!renderOnly && ( +
+

Diagram XML (mxGraphModel)

+