mirror of
https://github.com/DayuanJiang/next-ai-draw-io.git
synced 2026-10-11 20:19:51 +08:00
Compare commits
70
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
61c43d3b2a | ||
|
|
876d3dec8c | ||
|
|
24d1850518 | ||
|
|
fdc974e85a | ||
|
|
70d392729a | ||
|
|
00aa3c22e6 | ||
|
|
4bd15ffcf6 | ||
|
|
c9c610e929 | ||
|
|
e93f7547c2 | ||
|
|
f257d8ee99 | ||
|
|
467fd25fd1 | ||
|
|
734a31d4eb | ||
|
|
f7178510f4 | ||
|
|
0ed9643af1 | ||
|
|
58cef00873 | ||
|
|
27c49a68d0 | ||
|
|
af842ac287 | ||
|
|
b3dcd91f54 | ||
|
|
5c923adf7b | ||
|
|
0de1cdfe7c | ||
|
|
66c9daf5ca | ||
|
|
a96dcbb21f | ||
|
|
3422b5c55b | ||
|
|
e533aac6c4 | ||
|
|
3b3219c73d | ||
|
|
349ea26d8b | ||
|
|
3ef14318f9 | ||
|
|
b6bd032c2c | ||
|
|
5ed8940b48 | ||
|
|
21807417c5 | ||
|
|
a2ca229830 | ||
|
|
b4c34c9361 | ||
|
|
1f27036de1 | ||
|
|
7be9b5ace8 | ||
|
|
b6f42bcddc | ||
|
|
77c6a2d807 | ||
|
|
7e2edbf8a1 | ||
|
|
20ce02a602 | ||
|
|
a407f66268 | ||
|
|
6e375fb963 | ||
|
|
48c7e43521 | ||
|
|
8171693ef6 | ||
|
|
7fb78c2de6 | ||
|
|
7f84be7001 | ||
|
|
d415f19cf5 | ||
|
|
38fe675d6b | ||
|
|
f89d23880e | ||
|
|
1aa8abcd6e | ||
|
|
899b6a3b5b | ||
|
|
07f3e3c2a3 | ||
|
|
8ab837679e | ||
|
|
cb066a4825 | ||
|
|
77be43d996 | ||
|
|
c774a39c33 | ||
|
|
3d1a020b35 | ||
|
|
8d690503d4 | ||
|
|
4b4f36e374 | ||
|
|
a9091e96f1 | ||
|
|
213b672e92 | ||
|
|
d1ee1594f1 | ||
|
|
cd5352ca88 | ||
|
|
498ee628f1 | ||
|
|
a6e812d3a5 | ||
|
|
02eaa0bd22 | ||
|
|
4cba863fd9 | ||
|
|
7682e479e3 | ||
|
|
a7ae114edf | ||
|
|
e117095c7b | ||
|
|
f0f0bc0dc0 | ||
|
|
d5096b9628 |
@@ -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
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
name: Publish MCP Server
|
name: Publish MCP Server
|
||||||
|
|
||||||
# Publishes @next-ai-drawio/mcp-server to npm via OIDC trusted publishing
|
# Publishes @next-ai-drawio/mcp-server to npm via OIDC trusted publishing
|
||||||
# (no token, no OTP). Triggers when packages/mcp-server changes on main;
|
# (no token, no OTP). Triggers when packages/mcp-server or the web app code
|
||||||
|
# bundled into the canvas shell (scripts/build-shell.mjs) changes on main;
|
||||||
# skips silently if the package.json version is already on npm — so a
|
# skips silently if the package.json version is already on npm — so a
|
||||||
# release is just "bump the version in a PR and merge".
|
# release is just "bump the version in a PR and merge".
|
||||||
on:
|
on:
|
||||||
@@ -10,6 +11,16 @@ on:
|
|||||||
- main
|
- main
|
||||||
paths:
|
paths:
|
||||||
- "packages/mcp-server/**"
|
- "packages/mcp-server/**"
|
||||||
|
# The canvas shell is bundled from these with the root's lockfile
|
||||||
|
- "components/**"
|
||||||
|
- "contexts/**"
|
||||||
|
- "hooks/**"
|
||||||
|
- "lib/**"
|
||||||
|
- "stores/**"
|
||||||
|
- "app/globals.css"
|
||||||
|
- "package-lock.json"
|
||||||
|
# The package's build scripts import the root's scripts/drawio-zip.mjs
|
||||||
|
- "scripts/**"
|
||||||
workflow_dispatch:
|
workflow_dispatch:
|
||||||
|
|
||||||
permissions:
|
permissions:
|
||||||
@@ -35,7 +46,9 @@ jobs:
|
|||||||
with:
|
with:
|
||||||
node-version: 24
|
node-version: 24
|
||||||
cache: "npm"
|
cache: "npm"
|
||||||
cache-dependency-path: packages/mcp-server/package-lock.json
|
cache-dependency-path: |
|
||||||
|
package-lock.json
|
||||||
|
packages/mcp-server/package-lock.json
|
||||||
registry-url: "https://registry.npmjs.org"
|
registry-url: "https://registry.npmjs.org"
|
||||||
|
|
||||||
# Trusted publishing requires npm >= 11.5.1
|
# Trusted publishing requires npm >= 11.5.1
|
||||||
@@ -54,6 +67,13 @@ jobs:
|
|||||||
echo "publish=true" >> "$GITHUB_OUTPUT"
|
echo "publish=true" >> "$GITHUB_OUTPUT"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
# The canvas shell (scripts/build-shell.mjs) is bundled from the web
|
||||||
|
# app's components with the root's esbuild, Tailwind and React
|
||||||
|
- name: Install repository dependencies
|
||||||
|
if: steps.version.outputs.publish == 'true'
|
||||||
|
working-directory: .
|
||||||
|
run: npm ci
|
||||||
|
|
||||||
- name: Install dependencies
|
- name: Install dependencies
|
||||||
if: steps.version.outputs.publish == 'true'
|
if: steps.version.outputs.publish == 'true'
|
||||||
run: npm ci
|
run: npm ci
|
||||||
@@ -62,6 +82,9 @@ jobs:
|
|||||||
if: steps.version.outputs.publish == 'true'
|
if: steps.version.outputs.publish == 'true'
|
||||||
run: npm test
|
run: npm test
|
||||||
|
|
||||||
|
# The build downloads the pinned draw.war from GitHub releases
|
||||||
|
# (scripts/fetch-drawio.mjs) and bundles a trimmed draw.io into
|
||||||
|
# dist/drawio; check-package confirms it is in the tarball
|
||||||
- name: Build and check package contents
|
- name: Build and check package contents
|
||||||
if: steps.version.outputs.publish == 'true'
|
if: steps.version.outputs.publish == 'true'
|
||||||
run: npm run build && npm run check-package
|
run: npm run build && npm run check-package
|
||||||
|
|||||||
@@ -28,20 +28,104 @@ jobs:
|
|||||||
- name: Run unit tests
|
- name: Run unit tests
|
||||||
run: npm run test -- --run
|
run: npm run test -- --run
|
||||||
|
|
||||||
# The MCP server package ships its own vitest because its DOM polyfill
|
# The MCP server package: its own vitest (its DOM polyfill, linkedom, needs
|
||||||
# (linkedom) needs `environment: node`, while the root vitest uses jsdom
|
# `environment: node`, while the root vitest uses jsdom for the Next.js
|
||||||
# for the Next.js app. Install + run its tests separately so CI catches
|
# app), the npm package as it is built, the draw.io file list, and the
|
||||||
# multi-page mxfile regressions.
|
# canvas shell in a browser.
|
||||||
|
mcp-shell:
|
||||||
|
name: MCP Server & Shell
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v6
|
||||||
|
with:
|
||||||
|
# HEAD^1 is the base branch for the version bump reminder below
|
||||||
|
fetch-depth: 2
|
||||||
|
|
||||||
|
- name: Setup Node.js
|
||||||
|
uses: actions/setup-node@v6
|
||||||
|
with:
|
||||||
|
node-version: "20"
|
||||||
|
cache: "npm"
|
||||||
|
cache-dependency-path: |
|
||||||
|
package-lock.json
|
||||||
|
packages/mcp-server/package-lock.json
|
||||||
|
|
||||||
|
# The shell is bundled from the web app's components with the root's
|
||||||
|
# esbuild, Tailwind and React; Playwright comes from the root too
|
||||||
|
- name: Install dependencies
|
||||||
|
run: npm ci
|
||||||
|
|
||||||
- name: Install MCP server dependencies
|
- name: Install MCP server dependencies
|
||||||
run: npm --prefix packages/mcp-server ci
|
run: npm --prefix packages/mcp-server ci
|
||||||
|
|
||||||
|
- name: Cache Playwright browsers
|
||||||
|
uses: actions/cache@v5
|
||||||
|
id: playwright-cache
|
||||||
|
with:
|
||||||
|
path: ~/.cache/ms-playwright
|
||||||
|
key: playwright-${{ runner.os }}-${{ hashFiles('**/package-lock.json') }}
|
||||||
|
|
||||||
|
- name: Install Playwright browsers
|
||||||
|
if: steps.playwright-cache.outputs.cache-hit != 'true'
|
||||||
|
run: npx playwright install chromium --with-deps
|
||||||
|
|
||||||
|
- name: Install Playwright deps (cached)
|
||||||
|
if: steps.playwright-cache.outputs.cache-hit == 'true'
|
||||||
|
run: npx playwright install-deps chromium
|
||||||
|
|
||||||
- name: Run MCP server unit tests
|
- name: Run MCP server unit tests
|
||||||
run: npm --prefix packages/mcp-server test
|
run: npm --prefix packages/mcp-server test
|
||||||
|
|
||||||
# Tests run from src/, so check the built npm package separately
|
# Tests run from src/, so check the built npm package separately. The
|
||||||
|
# build downloads the pinned draw.war and bundles a trimmed draw.io.
|
||||||
- name: Build MCP server and check package contents
|
- name: Build MCP server and check package contents
|
||||||
run: npm --prefix packages/mcp-server run build && npm --prefix packages/mcp-server run check-package
|
run: npm --prefix packages/mcp-server run build && npm --prefix packages/mcp-server run check-package
|
||||||
|
|
||||||
|
# The trimmed draw.io is cut from this list; the guard drives the full
|
||||||
|
# copy (public/drawio) in Chromium and fails when the editor requests a
|
||||||
|
# file the list does not have
|
||||||
|
- name: Download the full draw.io
|
||||||
|
run: node scripts/fetch-drawio.mjs
|
||||||
|
|
||||||
|
- name: Check the MCP draw.io file list
|
||||||
|
run: npm --prefix packages/mcp-server run check-drawio
|
||||||
|
|
||||||
|
# The canvas shell in a browser, from the package as npm installs it:
|
||||||
|
# the packed tarball goes into an empty directory, its dist/index.js is
|
||||||
|
# started over stdio and headless Chromium opens the URL start_session
|
||||||
|
# returns (packages/mcp-server/tests/e2e)
|
||||||
|
- name: Run the MCP shell E2E tests on the packed tarball
|
||||||
|
run: |
|
||||||
|
mkdir -p "$RUNNER_TEMP/mcp-pack" "$RUNNER_TEMP/mcp-install"
|
||||||
|
npm pack ./packages/mcp-server --pack-destination "$RUNNER_TEMP/mcp-pack"
|
||||||
|
npm --prefix "$RUNNER_TEMP/mcp-install" install "$RUNNER_TEMP"/mcp-pack/*.tgz
|
||||||
|
MCP_SERVER_ENTRY="$RUNNER_TEMP/mcp-install/node_modules/@next-ai-drawio/mcp-server/dist/index.js" \
|
||||||
|
npm --prefix packages/mcp-server run test:e2e
|
||||||
|
env:
|
||||||
|
CI: true
|
||||||
|
|
||||||
|
# The shell bundles the web app's canvas and the root's dependencies,
|
||||||
|
# so a change under these paths (the ones publish-mcp.yml triggers on)
|
||||||
|
# reaches users with the next MCP release, and publish-mcp.yml only
|
||||||
|
# publishes a new version number
|
||||||
|
- name: Remind to bump the MCP version when bundled code changed
|
||||||
|
if: github.event_name == 'pull_request'
|
||||||
|
run: |
|
||||||
|
changed=$(git diff --name-only HEAD^1 HEAD -- components contexts hooks lib stores app/globals.css package-lock.json scripts packages/mcp-server)
|
||||||
|
base_version=$(git show HEAD^1:packages/mcp-server/package.json | node -p "JSON.parse(require('fs').readFileSync(0, 'utf8')).version")
|
||||||
|
head_version=$(node -p "require('./packages/mcp-server/package.json').version")
|
||||||
|
if [ -n "$changed" ] && [ "$base_version" = "$head_version" ]; then
|
||||||
|
echo "::warning::Code bundled into the MCP server changed without a version bump in packages/mcp-server/package.json (still ${head_version}). Bump it if the change should be published."
|
||||||
|
fi
|
||||||
|
|
||||||
|
- name: Upload test results
|
||||||
|
uses: actions/upload-artifact@v6
|
||||||
|
if: always()
|
||||||
|
with:
|
||||||
|
name: playwright-report-mcp-shell
|
||||||
|
path: packages/mcp-server/test-results/
|
||||||
|
retention-days: 7
|
||||||
|
|
||||||
e2e:
|
e2e:
|
||||||
# Six jobs at once, each with a sixth of the tests and one worker: the
|
# Six jobs at once, each with a sixth of the tests and one worker: the
|
||||||
# same conditions per test, a fraction of the time
|
# same conditions per test, a fraction of the time
|
||||||
|
|||||||
@@ -0,0 +1,55 @@
|
|||||||
|
# Issues labelled "wait for confrim" are waiting for the reporter. After 15
|
||||||
|
# days without activity the issue gets a reminder and the "no response"
|
||||||
|
# label; after 15 more days it is closed. A reply from the reporter removes
|
||||||
|
# both labels, so the issue shows up as needing the maintainer again.
|
||||||
|
name: Wait for confirmation
|
||||||
|
|
||||||
|
on:
|
||||||
|
schedule:
|
||||||
|
- cron: "17 3 * * *"
|
||||||
|
workflow_dispatch:
|
||||||
|
issue_comment:
|
||||||
|
types: [created]
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
issues: write
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
remind-and-close:
|
||||||
|
if: github.event_name != 'issue_comment'
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/stale@v9
|
||||||
|
with:
|
||||||
|
only-issue-labels: "wait for confrim"
|
||||||
|
days-before-issue-stale: 15
|
||||||
|
days-before-issue-close: 15
|
||||||
|
days-before-pr-stale: -1
|
||||||
|
days-before-pr-close: -1
|
||||||
|
stale-issue-label: "no response"
|
||||||
|
stale-issue-message: >-
|
||||||
|
Still there? We asked for more information 15 days ago. A reply
|
||||||
|
would help; if we hear nothing in another 15 days this issue will
|
||||||
|
be closed. It can be reopened any time.
|
||||||
|
close-issue-message: >-
|
||||||
|
No reply in 30 days, so this issue is closed for now. If the
|
||||||
|
problem is still there, reply or reopen it.
|
||||||
|
close-issue-reason: not_planned
|
||||||
|
remove-stale-when-updated: true
|
||||||
|
|
||||||
|
reporter-replied:
|
||||||
|
if: >-
|
||||||
|
github.event_name == 'issue_comment' &&
|
||||||
|
contains(github.event.issue.labels.*.name, 'wait for confrim') &&
|
||||||
|
github.event.comment.user.type != 'Bot' &&
|
||||||
|
!contains(fromJSON('["OWNER","MEMBER","COLLABORATOR"]'), github.event.comment.author_association)
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- name: The reporter answered, so the issue is the maintainer's again
|
||||||
|
run: >-
|
||||||
|
gh issue edit "$NUMBER" --repo "$REPO"
|
||||||
|
--remove-label "wait for confrim" --remove-label "no response"
|
||||||
|
env:
|
||||||
|
GH_TOKEN: ${{ github.token }}
|
||||||
|
NUMBER: ${{ github.event.issue.number }}
|
||||||
|
REPO: ${{ github.repository }}
|
||||||
@@ -16,6 +16,8 @@ packages/*/dist
|
|||||||
/coverage
|
/coverage
|
||||||
/playwright-report/
|
/playwright-report/
|
||||||
/test-results/
|
/test-results/
|
||||||
|
packages/*/playwright-report/
|
||||||
|
packages/*/test-results/
|
||||||
|
|
||||||
# next.js
|
# next.js
|
||||||
/.next/
|
/.next/
|
||||||
|
|||||||
@@ -1,121 +1,98 @@
|
|||||||
# Next AI Draw.io
|
|
||||||
|
|
||||||
<div align="center">
|
<div align="center">
|
||||||
|
|
||||||
**AI-Powered Diagram Creation Tool - Chat, Draw, Visualize**
|
# Next AI Draw.io
|
||||||
|
|
||||||
|
**Draw and edit draw.io diagrams by chatting with an AI.**
|
||||||
|
|
||||||
English | [中文](./docs/cn/README_CN.md) | [日本語](./docs/ja/README_JA.md)
|
English | [中文](./docs/cn/README_CN.md) | [日本語](./docs/ja/README_JA.md)
|
||||||
|
|
||||||
[](https://next-ai-drawio.jiang.jp/)
|
[](https://next-ai-drawio.jiang.jp/)
|
||||||
|
|
||||||
[](https://opensource.org/licenses/Apache-2.0)
|
[](https://opensource.org/licenses/Apache-2.0)
|
||||||
[](https://nextjs.org/)
|
|
||||||
[](https://react.dev/)
|
|
||||||
[](https://github.com/sponsors/DayuanJiang)
|
[](https://github.com/sponsors/DayuanJiang)
|
||||||
|
|
||||||
[](https://next-ai-drawio.jiang.jp/)
|
[**Live Demo**](https://next-ai-drawio.jiang.jp/) · [**Desktop App**](https://github.com/DayuanJiang/next-ai-draw-io/releases) · [**MCP Server**](#in-your-ai-agent-mcp)
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
A Next.js web application that integrates AI capabilities with draw.io diagrams. Create, modify, and enhance diagrams through natural language commands and AI-assisted visualization.
|
https://github.com/user-attachments/assets/66b9f12f-219f-4d62-acc0-0725e6850eec
|
||||||
|
|
||||||
> Note: Thanks to <img src="https://raw.githubusercontent.com/DayuanJiang/next-ai-draw-io/main/public/doubao-color.png" alt="" height="20" /> [ByteDance Doubao](https://www.volcengine.com/activity/codingplan?ac=MMAP8JTTCAQ2&rc=Z9Z3LDTJ&utm_campaign=drawio&utm_content=drawio&utm_medium=devrel&utm_source=OWO&utm_term=drawio) sponsorship, the demo site now uses the powerful glm-4.7 model!
|
Describe a diagram in a sentence and the AI draws it on a real draw.io canvas. Edit it by hand like any draw.io file, or select a few shapes and tell the AI what to change. Every AI change is a version you can compare, restore or undo, and the result exports as `.drawio`, `.png` or `.svg`.
|
||||||
|
|
||||||
<p align="center">
|
Available as a web app, a desktop app for Windows, macOS and Linux, and an MCP server for AI agents such as Claude Code, Cursor and VS Code.
|
||||||
<a href="https://www.atlascloud.ai/?utm_source=github&utm_medium=link&utm_campaign=next-ai-draw-io">
|
|
||||||
<picture>
|
|
||||||
<source media="(prefers-color-scheme: dark)" srcset="./public/atlas-cloud-logo-white.svg">
|
|
||||||
<img src="./public/atlas-cloud-logo.svg" alt="Atlas Cloud" width="200">
|
|
||||||
</picture>
|
|
||||||
</a>
|
|
||||||
</p>
|
|
||||||
|
|
||||||
> 🎁 Thanks to **[Atlas Cloud](https://www.atlascloud.ai/?utm_source=github&utm_medium=link&utm_campaign=next-ai-draw-io)** for sponsoring next-ai-draw-io. Its OpenAI-compatible API gives diagram workflows one provider connection for DeepSeek, Qwen, GLM, Kimi, MiniMax, and more. Budget-friendly access is available through the [Coding Plan](https://www.atlascloud.ai/console/coding-plan).
|
## Highlights
|
||||||
|
|
||||||
|
**Draw**
|
||||||
|
|
||||||
https://github.com/user-attachments/assets/9d60a3e8-4a1c-4b5e-acbb-26af2d3eabd1
|
- Architecture diagrams, flowcharts, sequence diagrams and more from one sentence, with built-in AWS, Azure, GCP and Kubernetes icon libraries
|
||||||
|
- Connectors can carry a flowing animation
|
||||||
|
- Upload a screenshot or a sketch and the AI redraws it; upload PDF, Markdown, code and other text files and get a diagram of their content
|
||||||
|
|
||||||
|
**Edit**
|
||||||
|
|
||||||
|
- Edit by chat: changes stream onto the canvas and the shapes the AI just changed are highlighted
|
||||||
|
- Ask about a selection: select shapes on the canvas and the AI changes only those
|
||||||
|
- Versions and undo: every AI change is a version card with a thumbnail; compare it with the canvas, restore it or undo it, and Ctrl+Z on the canvas also takes back an AI change in one step
|
||||||
|
- It is a normal draw.io diagram: double-click to rename, drag, restyle, use several pages, and export as `.drawio`, `.png`, `.svg` or `.drawio.svg` at any time
|
||||||
|
|
||||||
## Table of Contents
|
**Use**
|
||||||
- [Next AI Draw.io](#next-ai-drawio)
|
|
||||||
- [Table of Contents](#table-of-contents)
|
- 24 model providers; enter your own API key in the browser and it stays on your machine
|
||||||
- [Examples](#examples)
|
- Models that reason show their thinking
|
||||||
- [Features](#features)
|
- Dark mode; the interface is available in English, Simplified Chinese, Traditional Chinese and Japanese
|
||||||
- [MCP Server](#mcp-server)
|
|
||||||
- [Claude Code CLI](#claude-code-cli)
|
|
||||||
- [Getting Started](#getting-started)
|
|
||||||
- [Try it Online](#try-it-online)
|
|
||||||
- [Desktop Application](#desktop-application)
|
|
||||||
- [Run with Docker](#run-with-docker)
|
|
||||||
- [Installation](#installation)
|
|
||||||
- [Deployment](#deployment)
|
|
||||||
- [Deploy to EdgeOne Pages](#deploy-to-edgeone-pages)
|
|
||||||
- [Deploy on Vercel](#deploy-on-vercel)
|
|
||||||
- [Deploy on Cloudflare Workers](#deploy-on-cloudflare-workers)
|
|
||||||
- [Multi-Provider Support](#multi-provider-support)
|
|
||||||
- [Server-Side Multi-Model Configuration](#server-side-multi-model-configuration)
|
|
||||||
- [Admin Panel](#admin-panel)
|
|
||||||
- [How It Works](#how-it-works)
|
|
||||||
- [Support \& Contact](#support--contact)
|
|
||||||
- [FAQ](#faq)
|
|
||||||
- [Star History](#star-history)
|
|
||||||
|
|
||||||
## Examples
|
## Examples
|
||||||
|
|
||||||
Here are some example prompts and their generated diagrams:
|
|
||||||
|
|
||||||
<div align="center">
|
<div align="center">
|
||||||
<table width="100%">
|
<table width="100%">
|
||||||
<tr>
|
<tr>
|
||||||
<td colspan="2" valign="top" align="center">
|
<td colspan="2" valign="top" align="center">
|
||||||
<strong>Animated transformer connectors</strong><br />
|
<strong>Transformer architecture with animated connectors</strong><br />
|
||||||
<p><strong>Prompt:</strong> Give me a **animated connector** diagram of transformer's architecture.</p>
|
<p><strong>Prompt:</strong> Give me a <strong>animated connector</strong> diagram of transformer's architecture.</p>
|
||||||
<img src="./public/animated_connectors.svg" alt="Transformer Architecture with Animated Connectors" width="480" />
|
<img src="./public/animated_connectors.svg" alt="Transformer architecture with animated connectors" width="440" />
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
<tr>
|
<tr>
|
||||||
<td width="50%" valign="top">
|
<td width="50%" valign="top">
|
||||||
<strong>RAG Technique Diagram</strong><br />
|
<strong>RAG architecture</strong><br />
|
||||||
<p><strong>Prompt:</strong> Generate a RAG architecture diagram for **chat application**. Use connected diagram for data ingestion</p>
|
<p><strong>Prompt:</strong> Generate a RAG architecture diagram for <strong>chat application</strong>. Use connected diagram for data ingestion</p>
|
||||||
<img src="./public/rag_prod.svg" alt="RAG Architecture Diagram" width="480" />
|
<img src="./public/rag_prod.svg" alt="RAG architecture diagram" width="400" />
|
||||||
</td>
|
</td>
|
||||||
<td width="50%" valign="top">
|
<td width="50%" valign="top">
|
||||||
<strong>Authentication using React and AWS</strong><br />
|
<strong>Authentication with React and AWS</strong><br />
|
||||||
<p><strong>Prompt:</strong> Generate authentication process using React with **AWS**. Use Serverless architecture.</p>
|
<p><strong>Prompt:</strong> Generate authentication process using React with <strong>AWS</strong>. Use Serverless architecture.</p>
|
||||||
<img src="./public/auth.svg" alt="Authentication Architecture Diagram" width="480" />
|
<img src="./public/auth.svg" alt="Authentication architecture diagram" width="400" />
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
<tr>
|
<tr>
|
||||||
<td width="50%" valign="top">
|
<td width="50%" valign="top">
|
||||||
<strong>Open Innovation</strong><br />
|
<strong>Open Innovation model</strong><br />
|
||||||
<p><strong>Prompt:</strong> Create visualization of Henry Chesbrough's Open Innovation model.</p>
|
<p><strong>Prompt:</strong> Create visualization of Henry Chesbrough's Open Innovation model.</p>
|
||||||
<img src="./public/inno.svg" alt="Open Innovation Diagram" width="480" />
|
<img src="./public/inno.svg" alt="Open Innovation diagram" width="400" />
|
||||||
</td>
|
</td>
|
||||||
<td width="50%" valign="top">
|
<td width="50%" valign="top">
|
||||||
<strong>Cat sketch</strong><br />
|
<strong>Cat sketch</strong><br />
|
||||||
<p><strong>Prompt:</strong> Draw a cute cat for me.</p>
|
<p><strong>Prompt:</strong> Draw a cute cat for me.</p>
|
||||||
<img src="./public/cat_demo.svg" alt="Cat Drawing" width="240" />
|
<img src="./public/cat_demo.svg" alt="Cat drawing" width="200" />
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
## Features
|
## Use it
|
||||||
|
|
||||||
- **LLM-Powered Diagram Creation**: Leverage Large Language Models to create and manipulate draw.io diagrams directly through natural language commands
|
### Online demo
|
||||||
- **Image-Based Diagram Replication**: Upload existing diagrams or images and have the AI replicate and enhance them automatically
|
|
||||||
- **PDF & Text File Upload**: Upload PDF documents and text files to extract content and generate diagrams from existing documents
|
|
||||||
- **AI Reasoning Display**: View the AI's thinking process for supported models (OpenAI o1/o3, Gemini, Claude, etc.)
|
|
||||||
- **Versions and Undo**: Every AI change shows up in the chat as a version with a thumbnail. Compare it with the canvas, restore it, or undo it; Ctrl+Z on the canvas also takes back an AI change in one step. Shapes the AI just changed are highlighted.
|
|
||||||
- **Ask About a Selection**: Select shapes on the canvas and ask the AI to change just those.
|
|
||||||
- **Interactive Chat Interface**: Communicate with AI to refine your diagrams in real-time
|
|
||||||
- **Cloud Architecture Diagram Support**: Specialized support for generating cloud architecture diagrams (AWS, GCP, Azure)
|
|
||||||
- **Animated Connectors**: Create dynamic and animated connectors between diagram elements for better visualization
|
|
||||||
|
|
||||||
## MCP Server
|
Open [next-ai-drawio.jiang.jp](https://next-ai-drawio.jiang.jp/), nothing to install. The demo has a usage limit; click the settings icon in the chat panel and enter your own provider and API key to lift it. The key stays in your browser and is never sent to the server for storage.
|
||||||
|
|
||||||
Use Next AI Draw.io with AI agents like Claude Desktop, Cursor, and VS Code via MCP (Model Context Protocol).
|
### Desktop app
|
||||||
|
|
||||||
|
Download the Windows, macOS or Linux installer from the [Releases page](https://github.com/DayuanJiang/next-ai-draw-io/releases).
|
||||||
|
|
||||||
|
### In your AI agent (MCP)
|
||||||
|
|
||||||
|
Through MCP (Model Context Protocol, the protocol AI agents use to call outside tools), Claude Desktop, Cursor, VS Code and others can draw draw.io diagrams directly. Add this to your client's MCP configuration:
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
@@ -128,165 +105,64 @@ Use Next AI Draw.io with AI agents like Claude Desktop, Cursor, and VS Code via
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
### Claude Code CLI
|
For Claude Code, one command does it:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
claude mcp add drawio -- npx @next-ai-drawio/mcp-server@latest
|
claude mcp add drawio -- npx @next-ai-drawio/mcp-server@latest
|
||||||
```
|
```
|
||||||
|
|
||||||
Then ask Claude to create diagrams:
|
Then ask the AI for "a flowchart of user authentication with login, MFA and session management" and the diagram appears in your browser as it is drawn. The MCP server has most of the web app's drawing features:
|
||||||
> "Create a flowchart showing user authentication with login, MFA, and session management"
|
|
||||||
|
|
||||||
The diagram appears in your browser in real-time!
|
- The same drawing rules and icon libraries (AWS, Azure, GCP, Kubernetes and more)
|
||||||
|
- The preview is the same canvas as the web app: the AI's changes are outlined, one Ctrl+Z takes them back, and the AI can read the shapes you select
|
||||||
|
- The draw.io editor ships inside the package, so after install the preview works offline (`DRAWIO_BASE_URL` points it at an external draw.io instead)
|
||||||
|
- A screenshot tool, so the AI can look at the rendered diagram and fix it
|
||||||
|
- Version history, multi-page diagrams, and download as `.drawio`, `.png`, `.svg` or `.drawio.svg`
|
||||||
|
- Auto-save to `~/.next-ai-drawio/`, so a diagram survives a restart
|
||||||
|
- Ask the AI to undo, and keep your own drawing rules in `~/.next-ai-drawio/instructions.md`
|
||||||
|
|
||||||
The MCP server includes most of the web app's drawing features:
|
See the [MCP server README](./packages/mcp-server/README.md) for VS Code, Cursor and other client configurations.
|
||||||
|
|
||||||
- The same drawing rules and shape libraries (AWS, Azure, GCP, Kubernetes and more)
|
## Self-host
|
||||||
- A screenshot tool, so the AI can check the rendered diagram and fix it
|
|
||||||
- Version history, multi-page diagrams, and download as `.drawio`, `.png`, `.svg`, or `.drawio.svg`
|
|
||||||
- Auto-save to `~/.next-ai-drawio/`, so you can continue a diagram after a restart
|
|
||||||
|
|
||||||
See the [MCP Server README](./packages/mcp-server/README.md) for VS Code, Cursor, and other client configurations.
|
### Run locally
|
||||||
|
|
||||||
## Getting Started
|
|
||||||
|
|
||||||
### Try it Online
|
|
||||||
|
|
||||||
No installation needed! Try the app directly on our demo site:
|
|
||||||
|
|
||||||
[](https://next-ai-drawio.jiang.jp/)
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
> **Bring Your Own API Key**: You can use your own API key to bypass usage limits on the demo site. Click the Settings icon in the chat panel to configure your provider and API key. Your key is stored locally in your browser and is never stored on the server.
|
|
||||||
|
|
||||||
### Desktop Application
|
|
||||||
|
|
||||||
Download the native desktop app for your platform from the [Releases page](https://github.com/DayuanJiang/next-ai-draw-io/releases):
|
|
||||||
|
|
||||||
Supported platforms: Windows, macOS, Linux.
|
|
||||||
|
|
||||||
### Run with Docker
|
|
||||||
|
|
||||||
[Go to Docker Guide](./docs/en/docker.md)
|
|
||||||
|
|
||||||
### Installation
|
|
||||||
|
|
||||||
1. Clone the repository:
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
git clone https://github.com/DayuanJiang/next-ai-draw-io
|
git clone https://github.com/DayuanJiang/next-ai-draw-io
|
||||||
cd next-ai-draw-io
|
cd next-ai-draw-io
|
||||||
npm install
|
npm install
|
||||||
cp env.example .env.local
|
cp env.example .env.local # add your provider and API key, see "Models and providers" below
|
||||||
```
|
|
||||||
|
|
||||||
See the [Provider Configuration Guide](./docs/en/ai-providers.md) for detailed setup instructions for each provider.
|
|
||||||
|
|
||||||
2. Run the development server:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
npm run dev
|
npm run dev
|
||||||
```
|
```
|
||||||
|
|
||||||
3. Open [http://localhost:6002](http://localhost:6002) in your browser to see the application.
|
Open [http://localhost:6002](http://localhost:6002).
|
||||||
|
|
||||||
## Deployment
|
### One-click deploy
|
||||||
|
|
||||||
### Deploy to EdgeOne Pages
|
| Platform | How |
|
||||||
|
| --- | --- |
|
||||||
|
| Tencent EdgeOne Pages | [](https://edgeone.ai/pages/new?repository-url=https%3A%2F%2Fgithub.com%2FDayuanJiang%2Fnext-ai-draw-io) Deploying there also gives you a [daily free quota for DeepSeek models](https://pages.edgeone.ai/document/edge-ai) |
|
||||||
|
| Vercel | [](https://vercel.com/new/clone?repository-url=https%3A%2F%2Fgithub.com%2FDayuanJiang%2Fnext-ai-draw-io) Set the same environment variables in the Vercel dashboard as in your `.env.local` |
|
||||||
|
| Cloudflare Workers | [Cloudflare deploy guide](./docs/en/cloudflare-deploy.md) |
|
||||||
|
| Docker | [Docker guide](./docs/en/docker.md) |
|
||||||
|
| Offline or intranet | [Offline deployment](./docs/en/offline-deployment.md) |
|
||||||
|
|
||||||
You can deploy with one click using [Tencent EdgeOne Pages](https://pages.edgeone.ai/).
|
### Models and providers
|
||||||
|
|
||||||
Deploy by this button:
|
24 providers are supported: AWS Bedrock (default), OpenAI, Anthropic, Google AI, Google Vertex AI, Azure OpenAI, Ollama, OpenRouter, AIHubMix, DeepSeek, SiliconFlow, SGLang, Vercel AI Gateway, Tencent EdgeOne, ByteDance Doubao, ModelScope, Zhipu GLM, Qwen, Qiniu, Kimi, MiniMax, Novita, Xiaomi MiMo and Atlas Cloud. The environment variables and notes for each are in the [provider configuration guide](./docs/en/ai-providers.md).
|
||||||
|
|
||||||
[](https://edgeone.ai/pages/new?repository-url=https%3A%2F%2Fgithub.com%2FDayuanJiang%2Fnext-ai-draw-io)
|
**Which model**: the task is to produce long text in a strict format (draw.io XML), so pick a capable model; small models tend to produce broken diagrams.
|
||||||
|
|
||||||
Check out the [Tencent EdgeOne Pages documentation](https://pages.edgeone.ai/document/deployment-overview) for more details.
|
**Several models and the admin panel**: list several model IDs in `AI_MODEL` separated by commas, or configure models from several providers with the `AI_MODELS_CONFIG` environment variable or an `ai-models.json` file, and every user can use them without a key of their own. Set `ADMIN_PASSWORD` and open `/admin` to manage models, access codes, feature switches, observability and quotas from a web page; see the [admin panel guide](./docs/en/admin-panel.md).
|
||||||
|
|
||||||
Additionally, deploying through Tencent EdgeOne Pages will also grant you a [daily free quota for DeepSeek models](https://pages.edgeone.ai/document/edge-ai).
|
## Support
|
||||||
|
|
||||||
### Deploy on Vercel
|
- Questions and ideas: open a [GitHub issue](https://github.com/DayuanJiang/next-ai-draw-io/issues) or email me[at]jiang.jp
|
||||||
|
- Common problems: [FAQ](./docs/en/FAQ.md)
|
||||||
|
- If the project is useful to you, consider [sponsoring](https://github.com/sponsors/DayuanJiang) to help keep the demo site running
|
||||||
|
|
||||||
[](https://vercel.com/new/clone?repository-url=https%3A%2F%2Fgithub.com%2FDayuanJiang%2Fnext-ai-draw-io)
|
<div align="center">
|
||||||
|
|
||||||
The easiest way to deploy is using [Vercel](https://vercel.com/new), the creators of Next.js. Be sure to **set the environment variables** in the Vercel dashboard as you did in your local `.env.local` file.
|
|
||||||
|
|
||||||
See the [Next.js deployment documentation](https://nextjs.org/docs/app/building-your-application/deploying) for more details.
|
|
||||||
|
|
||||||
### Deploy on Cloudflare Workers
|
|
||||||
|
|
||||||
[Go to Cloudflare Deploy Guide](./docs/en/cloudflare-deploy.md)
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
## Multi-Provider Support
|
|
||||||
|
|
||||||
- [ByteDance Doubao](https://www.volcengine.com/activity/codingplan?ac=MMAP8JTTCAQ2&rc=Z9Z3LDTJ&utm_campaign=drawio&utm_content=drawio&utm_medium=devrel&utm_source=OWO&utm_term=drawio)
|
|
||||||
- AWS Bedrock (default)
|
|
||||||
- OpenAI
|
|
||||||
- Anthropic
|
|
||||||
- Google AI
|
|
||||||
- Google Vertex AI
|
|
||||||
- Azure OpenAI
|
|
||||||
- Ollama
|
|
||||||
- OpenRouter
|
|
||||||
- AIHubMix
|
|
||||||
- DeepSeek
|
|
||||||
- SiliconFlow
|
|
||||||
- ModelScope
|
|
||||||
- SGLang
|
|
||||||
- Vercel AI Gateway
|
|
||||||
- [Atlas Cloud](https://www.atlascloud.ai/?utm_source=github&utm_medium=link&utm_campaign=next-ai-draw-io)
|
|
||||||
|
|
||||||
|
|
||||||
All providers except AWS Bedrock and OpenRouter support custom endpoints.
|
|
||||||
|
|
||||||
📖 **[Detailed Provider Configuration Guide](./docs/en/ai-providers.md)** - See setup instructions for each provider.
|
|
||||||
|
|
||||||
### Server-Side Multi-Model Configuration
|
|
||||||
|
|
||||||
Administrators can configure multiple server-side models that are available to all users without requiring personal API keys. Configure via `AI_MODELS_CONFIG` environment variable (JSON string) or `ai-models.json` file. For a single-provider quick setup, list comma-separated model IDs in `AI_MODEL`.
|
|
||||||
|
|
||||||
### Admin Panel
|
|
||||||
|
|
||||||
Set the `ADMIN_PASSWORD` environment variable and visit `/admin` to manage server settings (models, access codes, features, observability, quota) from a web panel instead of hand-editing `.env`.
|
|
||||||
|
|
||||||
📖 **[Admin Panel Guide](./docs/en/admin-panel.md)** — setup, precedence rules, and notes.
|
|
||||||
|
|
||||||
**Model Requirements**: This task requires strong model capabilities for generating long-form text with strict formatting constraints (draw.io XML). Recommended models include Claude Sonnet 4.5, GPT-5.1, Gemini 3 Pro, and DeepSeek V3.2/R1.
|
|
||||||
|
|
||||||
Note that the `claude` series has been trained on draw.io diagrams with cloud architecture logos like AWS, Azure, GCP. So if you want to create cloud architecture diagrams, this is the best choice.
|
|
||||||
|
|
||||||
|
|
||||||
## How It Works
|
|
||||||
|
|
||||||
The application uses the following technologies:
|
|
||||||
|
|
||||||
- **Next.js**: For the frontend framework and routing
|
|
||||||
- **Vercel AI SDK** (`ai` + `@ai-sdk/*`): For streaming AI responses and multi-provider support
|
|
||||||
- **draw.io**: The editor runs from a copy bundled into `public/drawio` (downloaded by `npm run dev` / `npm run build`), so the app can drive it directly
|
|
||||||
|
|
||||||
Diagrams are represented as XML that can be rendered in draw.io. The AI processes your commands and generates or modifies this XML accordingly.
|
|
||||||
|
|
||||||
|
|
||||||
## Support & Contact
|
|
||||||
|
|
||||||
**Special thanks to [ByteDance Doubao](https://www.volcengine.com/activity/codingplan?ac=MMAP8JTTCAQ2&rc=Z9Z3LDTJ&utm_campaign=drawio&utm_content=drawio&utm_medium=devrel&utm_source=OWO&utm_term=drawio) for sponsoring the API token usage of the demo site!** Register on the ARK platform to get 500K free tokens for all models!
|
|
||||||
|
|
||||||
**Special thanks to [Atlas Cloud](https://www.atlascloud.ai/?utm_source=github&utm_medium=link&utm_campaign=next-ai-draw-io) for sponsoring next-ai-draw-io and supporting its multi-provider ecosystem!** Try its OpenAI-compatible LLM API through the [Atlas Cloud Coding Plan](https://www.atlascloud.ai/console/coding-plan).
|
|
||||||
|
|
||||||
If you find this project useful, please consider [sponsoring](https://github.com/sponsors/DayuanJiang) to help me host the live demo site!
|
|
||||||
|
|
||||||
For support or inquiries, please open an issue on the GitHub repository or contact the maintainer at:
|
|
||||||
|
|
||||||
- Email: me[at]jiang.jp
|
|
||||||
|
|
||||||
## FAQ
|
|
||||||
|
|
||||||
See [FAQ](./docs/en/FAQ.md) for common issues and solutions.
|
|
||||||
|
|
||||||
## Star History
|
|
||||||
|
|
||||||
[](https://www.star-history.com/#DayuanJiang/next-ai-draw-io&type=date&legend=top-left)
|
[](https://www.star-history.com/#DayuanJiang/next-ai-draw-io&type=date&legend=top-left)
|
||||||
|
|
||||||
---
|
</div>
|
||||||
|
|||||||
@@ -66,54 +66,6 @@ export default function AboutCN() {
|
|||||||
<div className="relative mb-8 rounded-2xl bg-gradient-to-br from-amber-50 via-orange-50 to-yellow-50 p-[1px] shadow-lg">
|
<div className="relative mb-8 rounded-2xl bg-gradient-to-br from-amber-50 via-orange-50 to-yellow-50 p-[1px] shadow-lg">
|
||||||
<div className="absolute inset-0 rounded-2xl bg-gradient-to-br from-amber-400 via-orange-400 to-yellow-400 opacity-20" />
|
<div className="absolute inset-0 rounded-2xl bg-gradient-to-br from-amber-400 via-orange-400 to-yellow-400 opacity-20" />
|
||||||
<div className="relative rounded-2xl bg-white/80 backdrop-blur-sm p-6">
|
<div className="relative rounded-2xl bg-white/80 backdrop-blur-sm p-6">
|
||||||
{/* Header */}
|
|
||||||
<div className="mb-4">
|
|
||||||
<h3 className="text-lg font-bold text-gray-900 tracking-tight">
|
|
||||||
由字节跳动豆包提供支持
|
|
||||||
</h3>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Story */}
|
|
||||||
<div className="space-y-3 text-sm text-gray-700 leading-relaxed mb-5">
|
|
||||||
<p>
|
|
||||||
好消息!感谢{" "}
|
|
||||||
<a
|
|
||||||
href="https://www.volcengine.com/activity/codingplan?ac=MMAP8JTTCAQ2&rc=Z9Z3LDTJ&utm_campaign=drawio&utm_content=drawio&utm_medium=devrel&utm_source=OWO&utm_term=drawio"
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
className="font-semibold text-blue-600 hover:underline"
|
|
||||||
>
|
|
||||||
字节跳动豆包
|
|
||||||
</a>
|
|
||||||
的慷慨赞助,演示站点现已接入强大的{" "}
|
|
||||||
<span className="font-semibold text-amber-700">
|
|
||||||
glm-4.7
|
|
||||||
</span>{" "}
|
|
||||||
模型,图表生成效果更佳!点击链接注册即可领取{" "}
|
|
||||||
<span className="font-semibold text-amber-700">
|
|
||||||
50万免费Token
|
|
||||||
</span>
|
|
||||||
,适用于所有模型!
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Invite Poster */}
|
|
||||||
<div className="text-center mb-5">
|
|
||||||
<a
|
|
||||||
href="https://www.volcengine.com/activity/codingplan?ac=MMAP8JTTCAQ2&rc=Z9Z3LDTJ&utm_campaign=drawio&utm_content=drawio&utm_medium=devrel&utm_source=OWO&utm_term=drawio"
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
>
|
|
||||||
<Image
|
|
||||||
src="/volcengine-invite.png"
|
|
||||||
alt="火山引擎方舟 Coding Plan"
|
|
||||||
width={300}
|
|
||||||
height={400}
|
|
||||||
className="mx-auto rounded-lg"
|
|
||||||
/>
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Bring Your Own Key */}
|
{/* Bring Your Own Key */}
|
||||||
<div className="text-center">
|
<div className="text-center">
|
||||||
<h4 className="text-base font-bold text-gray-900 mb-2">
|
<h4 className="text-base font-bold text-gray-900 mb-2">
|
||||||
@@ -309,16 +261,7 @@ export default function AboutCN() {
|
|||||||
多提供商支持
|
多提供商支持
|
||||||
</h2>
|
</h2>
|
||||||
<ul className="list-disc pl-6 text-gray-700 space-y-1">
|
<ul className="list-disc pl-6 text-gray-700 space-y-1">
|
||||||
<li>
|
<li>字节跳动豆包</li>
|
||||||
<a
|
|
||||||
href="https://www.volcengine.com/activity/codingplan?ac=MMAP8JTTCAQ2&rc=Z9Z3LDTJ&utm_campaign=drawio&utm_content=drawio&utm_medium=devrel&utm_source=OWO&utm_term=drawio"
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
className="text-blue-600 hover:underline"
|
|
||||||
>
|
|
||||||
字节跳动豆包
|
|
||||||
</a>
|
|
||||||
</li>
|
|
||||||
<li>AWS Bedrock(默认)</li>
|
<li>AWS Bedrock(默认)</li>
|
||||||
<li>
|
<li>
|
||||||
OpenAI / OpenAI兼容API(通过{" "}
|
OpenAI / OpenAI兼容API(通过{" "}
|
||||||
@@ -343,18 +286,6 @@ export default function AboutCN() {
|
|||||||
<h2 className="text-2xl font-semibold text-gray-900 mt-10 mb-4">
|
<h2 className="text-2xl font-semibold text-gray-900 mt-10 mb-4">
|
||||||
支持与联系
|
支持与联系
|
||||||
</h2>
|
</h2>
|
||||||
<p className="text-gray-700 mb-4 font-semibold">
|
|
||||||
特别感谢{" "}
|
|
||||||
<a
|
|
||||||
href="https://www.volcengine.com/activity/codingplan?ac=MMAP8JTTCAQ2&rc=Z9Z3LDTJ&utm_campaign=drawio&utm_content=drawio&utm_medium=devrel&utm_source=OWO&utm_term=drawio"
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
className="text-blue-600 hover:underline"
|
|
||||||
>
|
|
||||||
字节跳动豆包
|
|
||||||
</a>{" "}
|
|
||||||
为本站提供 API Token 支持!
|
|
||||||
</p>
|
|
||||||
<p className="text-gray-700">
|
<p className="text-gray-700">
|
||||||
如果您觉得这个项目有用,请考虑{" "}
|
如果您觉得这个项目有用,请考虑{" "}
|
||||||
<a
|
<a
|
||||||
|
|||||||
@@ -74,37 +74,6 @@ export default function AboutJA() {
|
|||||||
<div className="relative mb-8 rounded-2xl bg-gradient-to-br from-amber-50 via-orange-50 to-yellow-50 p-[1px] shadow-lg">
|
<div className="relative mb-8 rounded-2xl bg-gradient-to-br from-amber-50 via-orange-50 to-yellow-50 p-[1px] shadow-lg">
|
||||||
<div className="absolute inset-0 rounded-2xl bg-gradient-to-br from-amber-400 via-orange-400 to-yellow-400 opacity-20" />
|
<div className="absolute inset-0 rounded-2xl bg-gradient-to-br from-amber-400 via-orange-400 to-yellow-400 opacity-20" />
|
||||||
<div className="relative rounded-2xl bg-white/80 backdrop-blur-sm p-6">
|
<div className="relative rounded-2xl bg-white/80 backdrop-blur-sm p-6">
|
||||||
{/* Header */}
|
|
||||||
<div className="mb-4">
|
|
||||||
<h3 className="text-lg font-bold text-gray-900 tracking-tight">
|
|
||||||
ByteDance Doubao提供
|
|
||||||
</h3>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Story */}
|
|
||||||
<div className="space-y-3 text-sm text-gray-700 leading-relaxed mb-5">
|
|
||||||
<p>
|
|
||||||
朗報です!
|
|
||||||
<a
|
|
||||||
href="https://www.volcengine.com/activity/codingplan?ac=MMAP8JTTCAQ2&rc=Z9Z3LDTJ&utm_campaign=drawio&utm_content=drawio&utm_medium=devrel&utm_source=OWO&utm_term=drawio"
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
className="font-semibold text-blue-600 hover:underline"
|
|
||||||
>
|
|
||||||
ByteDance Doubao
|
|
||||||
</a>
|
|
||||||
様のご支援により、デモサイトでは強力な{" "}
|
|
||||||
<span className="font-semibold text-amber-700">
|
|
||||||
glm-4.7
|
|
||||||
</span>{" "}
|
|
||||||
モデルを利用できるようになり、より高品質なダイアグラム生成が可能になりました。リンクから登録すると、すべてのモデルで使える{" "}
|
|
||||||
<span className="font-semibold text-amber-700">
|
|
||||||
50万トークン
|
|
||||||
</span>
|
|
||||||
が無料でもらえます!
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Bring Your Own Key */}
|
{/* Bring Your Own Key */}
|
||||||
<div className="text-center">
|
<div className="text-center">
|
||||||
<h4 className="text-base font-bold text-gray-900 mb-2">
|
<h4 className="text-base font-bold text-gray-900 mb-2">
|
||||||
@@ -305,16 +274,7 @@ export default function AboutJA() {
|
|||||||
マルチプロバイダーサポート
|
マルチプロバイダーサポート
|
||||||
</h2>
|
</h2>
|
||||||
<ul className="list-disc pl-6 text-gray-700 space-y-1">
|
<ul className="list-disc pl-6 text-gray-700 space-y-1">
|
||||||
<li>
|
<li>ByteDance Doubao</li>
|
||||||
<a
|
|
||||||
href="https://www.volcengine.com/activity/codingplan?ac=MMAP8JTTCAQ2&rc=Z9Z3LDTJ&utm_campaign=drawio&utm_content=drawio&utm_medium=devrel&utm_source=OWO&utm_term=drawio"
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
className="text-blue-600 hover:underline"
|
|
||||||
>
|
|
||||||
ByteDance Doubao
|
|
||||||
</a>
|
|
||||||
</li>
|
|
||||||
<li>AWS Bedrock(デフォルト)</li>
|
<li>AWS Bedrock(デフォルト)</li>
|
||||||
<li>
|
<li>
|
||||||
OpenAI / OpenAI互換API(<code>OPENAI_BASE_URL</code>
|
OpenAI / OpenAI互換API(<code>OPENAI_BASE_URL</code>
|
||||||
@@ -339,18 +299,6 @@ export default function AboutJA() {
|
|||||||
<h2 className="text-2xl font-semibold text-gray-900 mt-10 mb-4">
|
<h2 className="text-2xl font-semibold text-gray-900 mt-10 mb-4">
|
||||||
サポート&お問い合わせ
|
サポート&お問い合わせ
|
||||||
</h2>
|
</h2>
|
||||||
<p className="text-gray-700 mb-4 font-semibold">
|
|
||||||
デモサイトのAPIトークン使用を支援してくださった{" "}
|
|
||||||
<a
|
|
||||||
href="https://www.volcengine.com/activity/codingplan?ac=MMAP8JTTCAQ2&rc=Z9Z3LDTJ&utm_campaign=drawio&utm_content=drawio&utm_medium=devrel&utm_source=OWO&utm_term=drawio"
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
className="text-blue-600 hover:underline"
|
|
||||||
>
|
|
||||||
ByteDance Doubao
|
|
||||||
</a>{" "}
|
|
||||||
様に、心より感謝申し上げます。
|
|
||||||
</p>
|
|
||||||
<p className="text-gray-700">
|
<p className="text-gray-700">
|
||||||
このプロジェクトが役に立ったら、ライブデモサイトのホスティングを支援するために{" "}
|
このプロジェクトが役に立ったら、ライブデモサイトのホスティングを支援するために{" "}
|
||||||
<a
|
<a
|
||||||
|
|||||||
@@ -74,39 +74,6 @@ export default function About() {
|
|||||||
<div className="relative mb-8 rounded-2xl bg-gradient-to-br from-amber-50 via-orange-50 to-yellow-50 p-[1px] shadow-lg">
|
<div className="relative mb-8 rounded-2xl bg-gradient-to-br from-amber-50 via-orange-50 to-yellow-50 p-[1px] shadow-lg">
|
||||||
<div className="absolute inset-0 rounded-2xl bg-gradient-to-br from-amber-400 via-orange-400 to-yellow-400 opacity-20" />
|
<div className="absolute inset-0 rounded-2xl bg-gradient-to-br from-amber-400 via-orange-400 to-yellow-400 opacity-20" />
|
||||||
<div className="relative rounded-2xl bg-white/80 backdrop-blur-sm p-6">
|
<div className="relative rounded-2xl bg-white/80 backdrop-blur-sm p-6">
|
||||||
{/* Header */}
|
|
||||||
<div className="mb-4">
|
|
||||||
<h3 className="text-lg font-bold text-gray-900 tracking-tight">
|
|
||||||
Sponsored by ByteDance Doubao
|
|
||||||
</h3>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Story */}
|
|
||||||
<div className="space-y-3 text-sm text-gray-700 leading-relaxed mb-5">
|
|
||||||
<p>
|
|
||||||
Great news! Thanks to the generous
|
|
||||||
sponsorship from{" "}
|
|
||||||
<a
|
|
||||||
href="https://www.volcengine.com/activity/codingplan?ac=MMAP8JTTCAQ2&rc=Z9Z3LDTJ&utm_campaign=drawio&utm_content=drawio&utm_medium=devrel&utm_source=OWO&utm_term=drawio"
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
className="font-semibold text-blue-600 hover:underline"
|
|
||||||
>
|
|
||||||
ByteDance Doubao
|
|
||||||
</a>
|
|
||||||
, the demo site now uses the powerful{" "}
|
|
||||||
<span className="font-semibold text-amber-700">
|
|
||||||
glm-4.7
|
|
||||||
</span>{" "}
|
|
||||||
model for better diagram generation! Sign up
|
|
||||||
via the link to get{" "}
|
|
||||||
<span className="font-semibold text-amber-700">
|
|
||||||
500K free tokens
|
|
||||||
</span>{" "}
|
|
||||||
for all models!
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Bring Your Own Key */}
|
{/* Bring Your Own Key */}
|
||||||
<div className="text-center">
|
<div className="text-center">
|
||||||
<h4 className="text-base font-bold text-gray-900 mb-2">
|
<h4 className="text-base font-bold text-gray-900 mb-2">
|
||||||
@@ -319,16 +286,7 @@ export default function About() {
|
|||||||
Multi-Provider Support
|
Multi-Provider Support
|
||||||
</h2>
|
</h2>
|
||||||
<ul className="list-disc pl-6 text-gray-700 space-y-1">
|
<ul className="list-disc pl-6 text-gray-700 space-y-1">
|
||||||
<li>
|
<li>ByteDance Doubao</li>
|
||||||
<a
|
|
||||||
href="https://www.volcengine.com/activity/codingplan?ac=MMAP8JTTCAQ2&rc=Z9Z3LDTJ&utm_campaign=drawio&utm_content=drawio&utm_medium=devrel&utm_source=OWO&utm_term=drawio"
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
className="text-blue-600 hover:underline"
|
|
||||||
>
|
|
||||||
ByteDance Doubao
|
|
||||||
</a>
|
|
||||||
</li>
|
|
||||||
<li>AWS Bedrock (default)</li>
|
<li>AWS Bedrock (default)</li>
|
||||||
<li>
|
<li>
|
||||||
OpenAI / OpenAI-compatible APIs (via{" "}
|
OpenAI / OpenAI-compatible APIs (via{" "}
|
||||||
@@ -355,18 +313,6 @@ export default function About() {
|
|||||||
<h2 className="text-2xl font-semibold text-gray-900 mt-10 mb-4">
|
<h2 className="text-2xl font-semibold text-gray-900 mt-10 mb-4">
|
||||||
Support & Contact
|
Support & Contact
|
||||||
</h2>
|
</h2>
|
||||||
<p className="text-gray-700 mb-4 font-semibold">
|
|
||||||
Special thanks to{" "}
|
|
||||||
<a
|
|
||||||
href="https://www.volcengine.com/activity/codingplan?ac=MMAP8JTTCAQ2&rc=Z9Z3LDTJ&utm_campaign=drawio&utm_content=drawio&utm_medium=devrel&utm_source=OWO&utm_term=drawio"
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
className="text-blue-600 hover:underline"
|
|
||||||
>
|
|
||||||
ByteDance Doubao
|
|
||||||
</a>{" "}
|
|
||||||
for sponsoring the API token usage of the demo site!
|
|
||||||
</p>
|
|
||||||
<p className="text-gray-700">
|
<p className="text-gray-700">
|
||||||
If you find this project useful, please consider{" "}
|
If you find this project useful, please consider{" "}
|
||||||
<a
|
<a
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import type { Metadata, Viewport } from "next"
|
|||||||
import { Instrument_Sans, JetBrains_Mono } from "next/font/google"
|
import { Instrument_Sans, JetBrains_Mono } from "next/font/google"
|
||||||
import { notFound } from "next/navigation"
|
import { notFound } from "next/navigation"
|
||||||
import Script from "next/script"
|
import Script from "next/script"
|
||||||
|
import { LocaleProvider } from "@/components/canvas/locale-context"
|
||||||
import { AppToaster } from "@/components/workspace/app-toaster"
|
import { AppToaster } from "@/components/workspace/app-toaster"
|
||||||
import { DiagramProvider } from "@/contexts/diagram-context"
|
import { DiagramProvider } from "@/contexts/diagram-context"
|
||||||
import { DictionaryProvider } from "@/hooks/use-dictionary"
|
import { DictionaryProvider } from "@/hooks/use-dictionary"
|
||||||
@@ -184,7 +185,9 @@ export default async function RootLayout({
|
|||||||
className={`${instrumentSans.variable} ${jetbrainsMono.variable} antialiased`}
|
className={`${instrumentSans.variable} ${jetbrainsMono.variable} antialiased`}
|
||||||
>
|
>
|
||||||
<DictionaryProvider dictionary={dictionary}>
|
<DictionaryProvider dictionary={dictionary}>
|
||||||
<DiagramProvider>{children}</DiagramProvider>
|
<LocaleProvider locale={validLang}>
|
||||||
|
<DiagramProvider>{children}</DiagramProvider>
|
||||||
|
</LocaleProvider>
|
||||||
</DictionaryProvider>
|
</DictionaryProvider>
|
||||||
<AppToaster />
|
<AppToaster />
|
||||||
</body>
|
</body>
|
||||||
|
|||||||
+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
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Binary file not shown.
|
Before Width: | Height: | Size: 15 KiB After Width: | Height: | Size: 2.0 KiB |
@@ -11,6 +11,7 @@
|
|||||||
"**",
|
"**",
|
||||||
"!public",
|
"!public",
|
||||||
"!packages/mcp-server/src/preview",
|
"!packages/mcp-server/src/preview",
|
||||||
|
"!packages/mcp-server/shell/index.html",
|
||||||
"!lib/model-catalog.json"
|
"!lib/model-catalog.json"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1,11 +1,14 @@
|
|||||||
import { useParams } from "next/navigation"
|
|
||||||
import { useEffect, useRef, useState } from "react"
|
import { useEffect, useRef, useState } from "react"
|
||||||
import { DrawioFrame } from "@/components/canvas/drawio-frame"
|
import { DrawioFrame } from "@/components/canvas/drawio-frame"
|
||||||
import { SelectionAsk } from "@/components/canvas/selection-ask"
|
import { useLocale } from "@/components/canvas/locale-context"
|
||||||
import { useChatEngine } from "@/components/chat/chat-engine"
|
|
||||||
import { useDiagram } from "@/contexts/diagram-context"
|
import { useDiagram } from "@/contexts/diagram-context"
|
||||||
import { useDictionary } from "@/hooks/use-dictionary"
|
import { useDictionary } from "@/hooks/use-dictionary"
|
||||||
import { DRAWIO_CSS, getDrawioSrc } from "@/lib/drawio/drawio-config"
|
import {
|
||||||
|
DRAWIO_CSS,
|
||||||
|
type DrawioSource,
|
||||||
|
getDrawioSrc,
|
||||||
|
webDrawioSource,
|
||||||
|
} from "@/lib/drawio/drawio-config"
|
||||||
import { keepCenter, refitIfRecent } from "@/lib/drawio/editor-bridge"
|
import { keepCenter, refitIfRecent } from "@/lib/drawio/editor-bridge"
|
||||||
import { i18n, type Locale } from "@/lib/i18n/config"
|
import { i18n, type Locale } from "@/lib/i18n/config"
|
||||||
import { STORAGE_KEYS } from "@/lib/storage"
|
import { STORAGE_KEYS } from "@/lib/storage"
|
||||||
@@ -13,28 +16,42 @@ import { cn } from "@/lib/utils"
|
|||||||
import { useCanvasStore } from "@/stores/canvas-store"
|
import { useCanvasStore } from "@/stores/canvas-store"
|
||||||
import { useSettingsStore } from "@/stores/settings-store"
|
import { useSettingsStore } from "@/stores/settings-store"
|
||||||
|
|
||||||
|
/** Size of the canvas area, for overlays that stay inside it */
|
||||||
|
export interface CanvasSize {
|
||||||
|
width: number
|
||||||
|
height: number
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The draw.io canvas, with draw.io's own toolbar, menus and page tabs, and
|
* The draw.io canvas, with draw.io's own toolbar, menus and page tabs, and
|
||||||
* the "ask AI" button laid over it (only when the page can drive draw.io
|
* an overlay laid over it (the web app's "ask AI" button; only when the
|
||||||
* directly, not with an external cross-origin draw.io).
|
* page can drive draw.io directly, not with an external cross-origin
|
||||||
|
* draw.io).
|
||||||
*/
|
*/
|
||||||
export function CanvasStage({
|
export function CanvasStage({
|
||||||
className,
|
className,
|
||||||
style,
|
style,
|
||||||
hideOverlays = false,
|
hideOverlays = false,
|
||||||
|
overlay,
|
||||||
|
drawioSource,
|
||||||
|
waitForSavedLocale = false,
|
||||||
}: {
|
}: {
|
||||||
className?: string
|
className?: string
|
||||||
style?: React.CSSProperties
|
style?: React.CSSProperties
|
||||||
/** Hide the "ask AI" button (e.g. while the start screen covers the canvas) */
|
/** Hide the overlay (e.g. while the start screen covers the canvas) */
|
||||||
hideOverlays?: boolean
|
hideOverlays?: boolean
|
||||||
|
/** Rendered over the canvas once draw.io is ready */
|
||||||
|
overlay?: (size: CanvasSize) => React.ReactNode
|
||||||
|
/** Where the editor comes from; none: the web app's own copy (the MCP
|
||||||
|
* shell passes what its server configured) */
|
||||||
|
drawioSource?: DrawioSource
|
||||||
|
/** Web app: the page is about to switch to the language picked last
|
||||||
|
* time (useSavedLocale in the workspace); do not load draw.io in the
|
||||||
|
* URL's language meanwhile */
|
||||||
|
waitForSavedLocale?: boolean
|
||||||
}) {
|
}) {
|
||||||
const dict = useDictionary()
|
const dict = useDictionary()
|
||||||
const params = useParams<{ lang: string }>()
|
const lang = useLocale()
|
||||||
const lang = (
|
|
||||||
i18n.locales.includes(params.lang as Locale)
|
|
||||||
? params.lang
|
|
||||||
: i18n.defaultLocale
|
|
||||||
) as Locale
|
|
||||||
const {
|
const {
|
||||||
drawioRef,
|
drawioRef,
|
||||||
handleDiagramAutoSave,
|
handleDiagramAutoSave,
|
||||||
@@ -43,14 +60,8 @@ export function CanvasStage({
|
|||||||
resetDrawioReady,
|
resetDrawioReady,
|
||||||
isDrawioReady,
|
isDrawioReady,
|
||||||
} = useDiagram()
|
} = useDiagram()
|
||||||
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)
|
||||||
|
|
||||||
@@ -58,20 +69,24 @@ export function CanvasStage({
|
|||||||
// null until draw.io tells us whether we can drive it directly
|
// null until draw.io tells us whether we can drive it directly
|
||||||
const [editorAccess, setEditorAccess] = useState<boolean | null>(null)
|
const [editorAccess, setEditorAccess] = useState<boolean | null>(null)
|
||||||
const stageRef = useRef<HTMLDivElement>(null)
|
const stageRef = useRef<HTMLDivElement>(null)
|
||||||
const [size, setSize] = useState({ width: 0, height: 0 })
|
const [size, setSize] = useState<CanvasSize>({ width: 0, height: 0 })
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
// The page is about to switch to the language picked last time
|
// Load draw.io once, in the language the page will have
|
||||||
// (useSavedLocale): load draw.io once, in that language
|
if (waitForSavedLocale) {
|
||||||
const saved = localStorage.getItem(STORAGE_KEYS.locale)
|
const saved = localStorage.getItem(STORAGE_KEYS.locale)
|
||||||
if (saved && saved !== lang && i18n.locales.includes(saved as Locale)) {
|
if (
|
||||||
return
|
saved &&
|
||||||
|
saved !== lang &&
|
||||||
|
i18n.locales.includes(saved as Locale)
|
||||||
|
) {
|
||||||
|
return
|
||||||
|
}
|
||||||
}
|
}
|
||||||
const isElectron = !!(window as { electronAPI?: unknown }).electronAPI
|
|
||||||
// The theme class is set before the page renders; the settings store
|
// The theme class is set before the page renders; the settings store
|
||||||
// may not have read it yet
|
// may not have read it yet
|
||||||
const dark = document.documentElement.classList.contains("dark")
|
const dark = document.documentElement.classList.contains("dark")
|
||||||
setSrc(getDrawioSrc({ lang, isElectron, dark }))
|
setSrc(getDrawioSrc(drawioSource ?? webDrawioSource(lang), dark))
|
||||||
}, [lang])
|
}, [lang])
|
||||||
|
|
||||||
// A new iframe starts over: draw.io reports load and access again
|
// A new iframe starts over: draw.io reports load and access again
|
||||||
@@ -87,8 +102,7 @@ export function CanvasStage({
|
|||||||
if (editorAccess !== false || !src) return
|
if (editorAccess !== false || !src) return
|
||||||
const startedDark = new URL(src).searchParams.get("dark") === "1"
|
const startedDark = new URL(src).searchParams.get("dark") === "1"
|
||||||
if (startedDark === isDark) return
|
if (startedDark === isDark) return
|
||||||
const isElectron = !!(window as { electronAPI?: unknown }).electronAPI
|
setSrc(getDrawioSrc(drawioSource ?? webDrawioSource(lang), isDark))
|
||||||
setSrc(getDrawioSrc({ lang, isElectron, dark: isDark }))
|
|
||||||
}, [isDark, editorAccess, lang, src])
|
}, [isDark, editorAccess, lang, src])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -164,13 +178,7 @@ export function CanvasStage({
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{showOverlays && (
|
{showOverlays && overlay?.(size)}
|
||||||
<SelectionAsk
|
|
||||||
width={size.width}
|
|
||||||
height={size.height}
|
|
||||||
hidden={engine.isBusy || !onFirstPage}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
import { ChevronLeft, ChevronRight, RotateCcw } from "lucide-react"
|
import { ChevronLeft, ChevronRight, RotateCcw } from "lucide-react"
|
||||||
import { useEffect, useMemo, useState } from "react"
|
import { useEffect, useMemo, useState } from "react"
|
||||||
|
import { VersionThumb } from "@/components/canvas/version-card"
|
||||||
|
import { useVersionsContext } from "@/components/canvas/versions-context"
|
||||||
import {
|
import {
|
||||||
Dialog,
|
Dialog,
|
||||||
DialogContent,
|
DialogContent,
|
||||||
@@ -11,11 +13,8 @@ import { useDiagram } from "@/contexts/diagram-context"
|
|||||||
import { useDictionary } from "@/hooks/use-dictionary"
|
import { useDictionary } from "@/hooks/use-dictionary"
|
||||||
import { diffDiagrams, isSameDocument } from "@/lib/diagram-diff"
|
import { diffDiagrams, isSameDocument } from "@/lib/diagram-diff"
|
||||||
import { formatMessage } from "@/lib/i18n/utils"
|
import { formatMessage } from "@/lib/i18n/utils"
|
||||||
import { describeChanges } from "@/lib/version-text"
|
import { describeChangeSummary } from "@/lib/version-text"
|
||||||
import { useUiStore } from "@/stores/ui-store"
|
import { useUiStore } from "@/stores/ui-store"
|
||||||
import { useVersionsStore } from "@/stores/versions-store"
|
|
||||||
import { useChatEngine } from "./chat-engine"
|
|
||||||
import { VersionThumb } from "./tool-activity"
|
|
||||||
|
|
||||||
function Sheet({
|
function Sheet({
|
||||||
label,
|
label,
|
||||||
@@ -40,12 +39,11 @@ function Sheet({
|
|||||||
export function CompareDialog() {
|
export function CompareDialog() {
|
||||||
const dict = useDictionary()
|
const dict = useDictionary()
|
||||||
const t = dict.versions
|
const t = dict.versions
|
||||||
const engine = useChatEngine()
|
const { versions, isBusy, restore } = useVersionsContext()
|
||||||
const { getVersionSvg, chartXML } = useDiagram()
|
const { getVersionSvg, chartXML } = useDiagram()
|
||||||
const versionId = useUiStore((s) => s.compareVersionId)
|
const versionId = useUiStore((s) => s.compareVersionId)
|
||||||
const openCompare = useUiStore((s) => s.openCompare)
|
const openCompare = useUiStore((s) => s.openCompare)
|
||||||
const closeCompare = useUiStore((s) => s.closeCompare)
|
const closeCompare = useUiStore((s) => s.closeCompare)
|
||||||
const versions = useVersionsStore((s) => s.versions)
|
|
||||||
const [currentSvg, setCurrentSvg] = useState<string | null>(null)
|
const [currentSvg, setCurrentSvg] = useState<string | null>(null)
|
||||||
|
|
||||||
const index = versions.findIndex((v) => v.id === versionId)
|
const index = versions.findIndex((v) => v.id === versionId)
|
||||||
@@ -56,11 +54,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])
|
||||||
|
|
||||||
@@ -82,7 +80,7 @@ export function CompareDialog() {
|
|||||||
return () => {
|
return () => {
|
||||||
cancelled = true
|
cancelled = true
|
||||||
}
|
}
|
||||||
}, [versionId, engine.isBusy])
|
}, [versionId, isBusy])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog
|
<Dialog
|
||||||
@@ -98,7 +96,7 @@ export function CompareDialog() {
|
|||||||
: same
|
: same
|
||||||
? t.isOnCanvas
|
? t.isOnCanvas
|
||||||
: changes
|
: changes
|
||||||
? `${formatMessage(t.compareSince, { n: version.number })} ${describeChanges(changes, t)}`
|
? `${formatMessage(t.compareSince, { n: version.number })} ${describeChangeSummary(changes, t)}`
|
||||||
: formatMessage(t.differsElsewhere, {
|
: formatMessage(t.differsElsewhere, {
|
||||||
n: version.number,
|
n: version.number,
|
||||||
})}
|
})}
|
||||||
@@ -139,10 +137,10 @@ export function CompareDialog() {
|
|||||||
</span>
|
</span>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
disabled={!version || same || engine.isBusy}
|
disabled={!version || same || isBusy}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
if (!version) return
|
if (!version) return
|
||||||
engine.restoreVersion(version.id)
|
restore(version.id)
|
||||||
closeCompare()
|
closeCompare()
|
||||||
}}
|
}}
|
||||||
className="ml-auto inline-flex h-9 items-center gap-1.5 rounded-lg bg-primary px-3.5 text-[13px] font-medium text-primary-foreground hover:opacity-90 disabled:opacity-40"
|
className="ml-auto inline-flex h-9 items-center gap-1.5 rounded-lg bg-primary px-3.5 text-[13px] font-medium text-primary-foreground hover:opacity-90 disabled:opacity-40"
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import { createContext, useContext } from "react"
|
||||||
|
import type { Locale } from "@/lib/i18n/config"
|
||||||
|
|
||||||
|
const LocaleContext = createContext<Locale | null>(null)
|
||||||
|
|
||||||
|
/** The page's language; the web layout feeds it from its route param */
|
||||||
|
export function LocaleProvider({
|
||||||
|
locale,
|
||||||
|
children,
|
||||||
|
}: {
|
||||||
|
locale: Locale
|
||||||
|
children: React.ReactNode
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<LocaleContext.Provider value={locale}>
|
||||||
|
{children}
|
||||||
|
</LocaleContext.Provider>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useLocale(): Locale {
|
||||||
|
const locale = useContext(LocaleContext)
|
||||||
|
if (!locale) {
|
||||||
|
throw new Error("useLocale must be used within a LocaleProvider")
|
||||||
|
}
|
||||||
|
return locale
|
||||||
|
}
|
||||||
@@ -10,25 +10,25 @@ const GAP = 10
|
|||||||
/**
|
/**
|
||||||
* "Ask AI to change this" next to the selected shapes. Clicking it moves
|
* "Ask AI to change this" next to the selected shapes. Clicking it moves
|
||||||
* focus to the chat input, where the selection already shows as a chip.
|
* focus to the chat input, where the selection already shows as a chip.
|
||||||
|
* Hidden while an answer is changing the canvas.
|
||||||
*/
|
*/
|
||||||
export function SelectionAsk({
|
export function SelectionAsk({
|
||||||
width,
|
width,
|
||||||
height,
|
height,
|
||||||
hidden,
|
|
||||||
}: {
|
}: {
|
||||||
/** Size of the canvas area, for keeping the button inside it */
|
/** Size of the canvas area, for keeping the button inside it */
|
||||||
width: number
|
width: number
|
||||||
height: number
|
height: number
|
||||||
hidden: boolean
|
|
||||||
}) {
|
}) {
|
||||||
const dict = useDictionary()
|
const dict = useDictionary()
|
||||||
const rect = useCanvasStore((s) => s.selectionRect)
|
const rect = useCanvasStore((s) => s.selectionRect)
|
||||||
const selection = useCanvasStore((s) => s.selection)
|
const selection = useCanvasStore((s) => s.selection)
|
||||||
const isFreehand = useCanvasStore((s) => s.isFreehand)
|
const isFreehand = useCanvasStore((s) => s.isFreehand)
|
||||||
const popupOpen = useCanvasStore((s) => s.isDrawioPopupOpen)
|
const popupOpen = useCanvasStore((s) => s.isDrawioPopupOpen)
|
||||||
|
const isBusy = useCanvasStore((s) => s.isBusy)
|
||||||
const focusComposer = useUiStore((s) => s.focusComposer)
|
const focusComposer = useUiStore((s) => s.focusComposer)
|
||||||
|
|
||||||
if (hidden || popupOpen || !rect || selection.length === 0 || isFreehand) {
|
if (isBusy || popupOpen || !rect || selection.length === 0 || isFreehand) {
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
// Selection scrolled out of view
|
// Selection scrolled out of view
|
||||||
|
|||||||
@@ -0,0 +1,217 @@
|
|||||||
|
import { Redo2, RotateCcw, Undo2 } from "lucide-react"
|
||||||
|
import { Shimmer } from "@/components/ai-elements/shimmer"
|
||||||
|
import {
|
||||||
|
useVersionsContext,
|
||||||
|
type VersionItem,
|
||||||
|
} from "@/components/canvas/versions-context"
|
||||||
|
import { useDictionary } from "@/hooks/use-dictionary"
|
||||||
|
import { formatMessage } from "@/lib/i18n/utils"
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
import { describeChangeSummary, describeTotals } from "@/lib/version-text"
|
||||||
|
import { useUiStore } from "@/stores/ui-store"
|
||||||
|
|
||||||
|
/** Picture of a version (SVG data URL), or what stands in for it */
|
||||||
|
export function VersionThumb({
|
||||||
|
svg,
|
||||||
|
dimmed,
|
||||||
|
className,
|
||||||
|
}: {
|
||||||
|
/** undefined or null: still being made; "": none could be made */
|
||||||
|
svg: string | null | undefined
|
||||||
|
dimmed?: boolean
|
||||||
|
className?: string
|
||||||
|
}) {
|
||||||
|
const t = useDictionary().versions
|
||||||
|
if (svg) {
|
||||||
|
return (
|
||||||
|
// biome-ignore lint/performance/noImgElement: data URL thumbnail
|
||||||
|
<img
|
||||||
|
src={svg}
|
||||||
|
alt=""
|
||||||
|
className={cn(
|
||||||
|
"object-contain transition-opacity",
|
||||||
|
dimmed && "opacity-40",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if (svg === "") {
|
||||||
|
return <span className="text-xs text-faint">{t.noPreview}</span>
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<Shimmer as="span" className="text-xs">
|
||||||
|
{t.rendering}
|
||||||
|
</Shimmer>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
interface VersionCardProps extends React.ComponentProps<"div"> {
|
||||||
|
version: VersionItem
|
||||||
|
/** The newest version shows its picture; older ones fold into one row */
|
||||||
|
isLatest: boolean
|
||||||
|
/** What happened ("Drew the diagram"); an undone change says so instead */
|
||||||
|
label: string
|
||||||
|
/** Goes after the summary text (the chat adds its "Show XML" link) */
|
||||||
|
afterSummary?: React.ReactNode
|
||||||
|
/** Panel below the card (the chat shows the AI's XML there) */
|
||||||
|
children?: React.ReactNode
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Card for one diagram version: picture, number, label, what changed, and
|
||||||
|
* the action (undo or redo on the latest, restore on older ones). The
|
||||||
|
* picture opens Compare. Other attributes go on the card's element.
|
||||||
|
*/
|
||||||
|
export function VersionCard({
|
||||||
|
version,
|
||||||
|
isLatest,
|
||||||
|
label,
|
||||||
|
afterSummary,
|
||||||
|
children,
|
||||||
|
className,
|
||||||
|
...rest
|
||||||
|
}: VersionCardProps) {
|
||||||
|
const t = useDictionary().versions
|
||||||
|
const source = useVersionsContext()
|
||||||
|
const openCompare = useUiStore((s) => s.openCompare)
|
||||||
|
|
||||||
|
const isUndone = source.undoneId === version.id
|
||||||
|
const isOnCanvas = source.onCanvasId === version.id
|
||||||
|
const title = isUndone ? t.undone : label
|
||||||
|
const summary = version.fromScratch
|
||||||
|
? describeTotals(version.summary, t)
|
||||||
|
: describeChangeSummary(version.summary, t)
|
||||||
|
const badge = (
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
"shrink-0 rounded-md px-1.5 py-0.5 text-[11px] font-semibold tabular-nums",
|
||||||
|
isOnCanvas
|
||||||
|
? "bg-marker-soft text-marker-ink"
|
||||||
|
: "bg-muted text-muted-foreground",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
v{version.number}
|
||||||
|
</span>
|
||||||
|
)
|
||||||
|
const actionClass =
|
||||||
|
"inline-flex h-7 shrink-0 items-center gap-1 rounded-lg px-2 text-xs text-muted-foreground hover:bg-accent hover:text-foreground disabled:opacity-40"
|
||||||
|
const restoreButton = (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
disabled={source.isBusy || isOnCanvas}
|
||||||
|
onClick={() => source.restore(version.id)}
|
||||||
|
className={actionClass}
|
||||||
|
data-testid="version-restore"
|
||||||
|
>
|
||||||
|
<RotateCcw className="size-3.5" />
|
||||||
|
{formatMessage(t.restoreVersion, { n: version.number })}
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
const summaryLine = (
|
||||||
|
<div className="text-xs text-muted-foreground">
|
||||||
|
{summary}
|
||||||
|
{afterSummary}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
|
||||||
|
if (!isLatest) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"overflow-hidden rounded-xl border border-border bg-card",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
data-testid="version-card"
|
||||||
|
{...rest}
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-2.5 p-1.5 pr-2">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => openCompare(version.id)}
|
||||||
|
className="sheet-light flex h-9 w-14 shrink-0 items-center justify-center overflow-hidden rounded-md border border-border bg-white hover:border-foreground/30"
|
||||||
|
aria-label={t.compare}
|
||||||
|
title={t.compare}
|
||||||
|
>
|
||||||
|
<VersionThumb
|
||||||
|
svg={version.svg}
|
||||||
|
className="max-h-8 max-w-[52px]"
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
{badge}
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<div className="truncate text-[13px] text-foreground">
|
||||||
|
{title}
|
||||||
|
</div>
|
||||||
|
{summaryLine}
|
||||||
|
</div>
|
||||||
|
{restoreButton}
|
||||||
|
</div>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Undo and redo belong to the newest change while it is (or was) on
|
||||||
|
// the canvas; after an older version was restored, this one is
|
||||||
|
// restored too
|
||||||
|
const canUndo = source.canUndo || source.canRedo
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"overflow-hidden rounded-xl border border-border bg-card",
|
||||||
|
isOnCanvas && "border-foreground/15",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
data-testid="version-card"
|
||||||
|
{...rest}
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => openCompare(version.id)}
|
||||||
|
className="group/thumb sheet-light relative flex h-24 w-full items-center justify-center border-b border-border bg-white"
|
||||||
|
aria-label={t.compare}
|
||||||
|
>
|
||||||
|
<VersionThumb
|
||||||
|
svg={version.svg}
|
||||||
|
dimmed={isUndone}
|
||||||
|
className="max-h-20 max-w-[88%]"
|
||||||
|
/>
|
||||||
|
<span className="absolute right-2 bottom-2 rounded-md bg-black/60 px-1.5 py-0.5 text-[11px] text-white opacity-0 transition-opacity group-hover/thumb:opacity-100">
|
||||||
|
{t.compare}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
{/* Title row with the action; the summary runs under both */}
|
||||||
|
<div className="grid grid-cols-[auto_minmax(0,1fr)_auto] items-center gap-x-2.5 px-3 py-2.5">
|
||||||
|
{badge}
|
||||||
|
<div className="truncate text-[13px] font-medium text-foreground">
|
||||||
|
{title}
|
||||||
|
</div>
|
||||||
|
{canUndo ? (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
disabled={source.isBusy}
|
||||||
|
onClick={() =>
|
||||||
|
source.canRedo ? source.redo() : source.undo()
|
||||||
|
}
|
||||||
|
className={actionClass}
|
||||||
|
data-testid="version-undo"
|
||||||
|
>
|
||||||
|
{source.canRedo ? (
|
||||||
|
<Redo2 className="size-3.5" />
|
||||||
|
) : (
|
||||||
|
<Undo2 className="size-3.5" />
|
||||||
|
)}
|
||||||
|
{source.canRedo ? t.redo : t.undo}
|
||||||
|
</button>
|
||||||
|
) : (
|
||||||
|
restoreButton
|
||||||
|
)}
|
||||||
|
<div className="col-span-2 col-start-2 mt-0.5">
|
||||||
|
{summaryLine}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,17 +1,20 @@
|
|||||||
import { useEffect, useRef } from "react"
|
import { useEffect, useRef } from "react"
|
||||||
|
import { useVersionsContext } from "@/components/canvas/versions-context"
|
||||||
import { useDictionary } from "@/hooks/use-dictionary"
|
import { useDictionary } from "@/hooks/use-dictionary"
|
||||||
import { cn } from "@/lib/utils"
|
import { cn } from "@/lib/utils"
|
||||||
import { useUiStore } from "@/stores/ui-store"
|
import { useUiStore } from "@/stores/ui-store"
|
||||||
import { useVersionsStore } from "@/stores/versions-store"
|
|
||||||
import { useChatEngine } from "./chat-engine"
|
|
||||||
|
|
||||||
/** Row of version thumbnails under the panel header; click to compare */
|
/** Row of version thumbnails under the panel header; click to compare */
|
||||||
export function VersionStrip() {
|
export function VersionStrip({
|
||||||
|
minVersions = 2,
|
||||||
|
}: {
|
||||||
|
/** Hidden below this many versions: one version alone adds nothing to
|
||||||
|
* its card, unless the caller knows there is no card for it */
|
||||||
|
minVersions?: number
|
||||||
|
}) {
|
||||||
const dict = useDictionary()
|
const dict = useDictionary()
|
||||||
const versions = useVersionsStore((s) => s.versions)
|
const { versions, onCanvasId } = useVersionsContext()
|
||||||
const onCanvasVersionId = useVersionsStore((s) => s.onCanvasVersionId)
|
|
||||||
const openCompare = useUiStore((s) => s.openCompare)
|
const openCompare = useUiStore((s) => s.openCompare)
|
||||||
const engine = useChatEngine()
|
|
||||||
const scrollRef = useRef<HTMLDivElement>(null)
|
const scrollRef = useRef<HTMLDivElement>(null)
|
||||||
|
|
||||||
// Keep the newest version in view
|
// Keep the newest version in view
|
||||||
@@ -20,24 +23,7 @@ export function VersionStrip() {
|
|||||||
if (el) el.scrollTo({ left: el.scrollWidth, behavior: "smooth" })
|
if (el) el.scrollTo({ left: el.scrollWidth, behavior: "smooth" })
|
||||||
}, [versions.length])
|
}, [versions.length])
|
||||||
|
|
||||||
// Versions with no card in the chat: saved by older app versions, from
|
if (versions.length < minVersions) return null
|
||||||
// a turn that was retried, or drawn by a call that then failed its
|
|
||||||
// screenshot check (a call still running gets its card when done)
|
|
||||||
const hasCardless = versions.some(
|
|
||||||
(v) =>
|
|
||||||
!v.toolCallId ||
|
|
||||||
!engine.messages.some((m) =>
|
|
||||||
m.parts?.some((p) => {
|
|
||||||
const part = p as { toolCallId?: string; state?: string }
|
|
||||||
return (
|
|
||||||
part.toolCallId === v.toolCallId &&
|
|
||||||
part.state !== "output-error"
|
|
||||||
)
|
|
||||||
}),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
// One version with a card: the strip adds nothing to it
|
|
||||||
if (versions.length < 2 && !hasCardless) return null
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center gap-2 border-b border-border px-3.5 pt-1 pb-3">
|
<div className="flex items-center gap-2 border-b border-border px-3.5 pt-1 pb-3">
|
||||||
@@ -49,13 +35,13 @@ export function VersionStrip() {
|
|||||||
className="flex min-w-0 flex-1 gap-1.5 overflow-x-auto p-1 scrollbar-thin"
|
className="flex min-w-0 flex-1 gap-1.5 overflow-x-auto p-1 scrollbar-thin"
|
||||||
>
|
>
|
||||||
{versions.map((version) => {
|
{versions.map((version) => {
|
||||||
const isCurrent = version.id === onCanvasVersionId
|
const isCurrent = version.id === onCanvasId
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
key={version.id}
|
key={version.id}
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => openCompare(version.id)}
|
onClick={() => openCompare(version.id)}
|
||||||
title={`v${version.number}${version.id === onCanvasVersionId ? ` · ${dict.versions.currentCanvas}` : ""}`}
|
title={`v${version.number}${isCurrent ? ` · ${dict.versions.currentCanvas}` : ""}`}
|
||||||
className={cn(
|
className={cn(
|
||||||
"sheet-light relative flex h-12 w-[72px] shrink-0 items-center justify-center overflow-hidden rounded-lg border border-border bg-white transition-colors hover:border-foreground/30",
|
"sheet-light relative flex h-12 w-[72px] shrink-0 items-center justify-center overflow-hidden rounded-lg border border-border bg-white transition-colors hover:border-foreground/30",
|
||||||
// An outline with a gap reads on the white
|
// An outline with a gap reads on the white
|
||||||
@@ -0,0 +1,72 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import { createContext, useContext } from "react"
|
||||||
|
import type { ChangeSummary } from "@/lib/diagram-diff"
|
||||||
|
|
||||||
|
/** One saved state of the diagram, as the version card and the compare
|
||||||
|
* dialog show it */
|
||||||
|
export interface VersionItem {
|
||||||
|
id: string
|
||||||
|
/** Shown as v{number} */
|
||||||
|
number: number
|
||||||
|
/** Full multi-page document */
|
||||||
|
xml: string
|
||||||
|
/** The page the change was made on; none: the first page */
|
||||||
|
pageId?: string
|
||||||
|
/** Document before the change, when it is known */
|
||||||
|
beforeXml?: string
|
||||||
|
/** SVG data URL thumbnail; undefined: still being made; "": none could
|
||||||
|
* be made */
|
||||||
|
svg?: string
|
||||||
|
summary: ChangeSummary
|
||||||
|
/** Drawn on an empty canvas (the card shows totals, not changes) */
|
||||||
|
fromScratch?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Where the versions come from and what can be done with them. The web app
|
||||||
|
* fills it from the chat's version store; the MCP shell from the server's
|
||||||
|
* history.
|
||||||
|
*/
|
||||||
|
export interface VersionsSource {
|
||||||
|
versions: VersionItem[]
|
||||||
|
/** The version the canvas shows now, or null (hand edits, undone) */
|
||||||
|
onCanvasId: string | null
|
||||||
|
/** The latest version, when its change was undone */
|
||||||
|
undoneId: string | null
|
||||||
|
/** Something is changing the canvas: version actions wait */
|
||||||
|
isBusy: boolean
|
||||||
|
/** The latest version's change can be undone */
|
||||||
|
canUndo: boolean
|
||||||
|
/** The latest version's change was undone and can be put back */
|
||||||
|
canRedo: boolean
|
||||||
|
restore: (id: string) => void
|
||||||
|
undo: () => void
|
||||||
|
redo: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
const VersionsContext = createContext<VersionsSource | null>(null)
|
||||||
|
|
||||||
|
export function VersionsProvider({
|
||||||
|
value,
|
||||||
|
children,
|
||||||
|
}: {
|
||||||
|
value: VersionsSource
|
||||||
|
children: React.ReactNode
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<VersionsContext.Provider value={value}>
|
||||||
|
{children}
|
||||||
|
</VersionsContext.Provider>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useVersionsContext(): VersionsSource {
|
||||||
|
const source = useContext(VersionsContext)
|
||||||
|
if (!source) {
|
||||||
|
throw new Error(
|
||||||
|
"useVersionsContext must be used within a VersionsProvider",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return source
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
@@ -716,6 +726,11 @@ export function ChatEngineProvider({
|
|||||||
// isBusy of the latest render, for code that runs after an await
|
// isBusy of the latest render, for code that runs after an await
|
||||||
const busyRef = useRef(isBusy)
|
const busyRef = useRef(isBusy)
|
||||||
busyRef.current = isBusy
|
busyRef.current = isBusy
|
||||||
|
// Canvas components (version cards, the compare dialog, "ask AI") read
|
||||||
|
// the busy flag from the canvas store
|
||||||
|
useEffect(() => {
|
||||||
|
useCanvasStore.getState().set({ isBusy })
|
||||||
|
}, [isBusy])
|
||||||
// The page goes (another language mounts a new one): the answer stops,
|
// The page goes (another language mounts a new one): the answer stops,
|
||||||
// so it cannot reach the next page's canvas
|
// so it cannot reach the next page's canvas
|
||||||
const stopRef = useRef(stop)
|
const stopRef = useRef(stop)
|
||||||
@@ -748,6 +763,7 @@ export function ChatEngineProvider({
|
|||||||
processedToolCallsRef,
|
processedToolCallsRef,
|
||||||
editDiagramOriginalXmlRef,
|
editDiagramOriginalXmlRef,
|
||||||
loadedMessageIdsRef,
|
loadedMessageIdsRef,
|
||||||
|
turnPageIdRef,
|
||||||
})
|
})
|
||||||
|
|
||||||
const { restoreVersion, undoVersion } = useVersions({
|
const { restoreVersion, undoVersion } = useVersions({
|
||||||
@@ -762,14 +778,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 +1190,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 +1211,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 +1268,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 +1343,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 +1512,7 @@ export function ChatEngineProvider({
|
|||||||
})
|
})
|
||||||
sendChatMessage(
|
sendChatMessage(
|
||||||
parts,
|
parts,
|
||||||
getFirstPageXml(savedXml),
|
getPageXml(savedXml, viewedPageId()),
|
||||||
previousXml,
|
previousXml,
|
||||||
index,
|
index,
|
||||||
selectionOf(messages[index]),
|
selectionOf(messages[index]),
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import {
|
|||||||
Settings,
|
Settings,
|
||||||
} from "lucide-react"
|
} from "lucide-react"
|
||||||
import { useRef } from "react"
|
import { useRef } from "react"
|
||||||
|
import { VersionStrip } from "@/components/canvas/version-strip"
|
||||||
import { DevXmlSimulator } from "@/components/dev-xml-simulator"
|
import { DevXmlSimulator } from "@/components/dev-xml-simulator"
|
||||||
import { BrandMark } from "@/components/workspace/brand-mark"
|
import { BrandMark } from "@/components/workspace/brand-mark"
|
||||||
import { IconButton } from "@/components/workspace/icon-button"
|
import { IconButton } from "@/components/workspace/icon-button"
|
||||||
@@ -21,11 +22,11 @@ import {
|
|||||||
useSettingsStore,
|
useSettingsStore,
|
||||||
} from "@/stores/settings-store"
|
} from "@/stores/settings-store"
|
||||||
import { useUiStore } from "@/stores/ui-store"
|
import { useUiStore } from "@/stores/ui-store"
|
||||||
|
import { useVersionsStore } from "@/stores/versions-store"
|
||||||
import { useChatEngine } from "./chat-engine"
|
import { useChatEngine } from "./chat-engine"
|
||||||
import { Composer } from "./composer"
|
import { Composer } from "./composer"
|
||||||
import { MessageList } from "./message-list"
|
import { MessageList } from "./message-list"
|
||||||
import { SessionMenu, useSessionTitle } from "./session-menu"
|
import { SessionMenu, useSessionTitle } from "./session-menu"
|
||||||
import { VersionStrip } from "./version-strip"
|
|
||||||
|
|
||||||
const DEBUG = process.env.NODE_ENV === "development"
|
const DEBUG = process.env.NODE_ENV === "development"
|
||||||
|
|
||||||
@@ -97,6 +98,25 @@ export function ChatPanel({
|
|||||||
const openSettings = useUiStore((s) => s.openSettings)
|
const openSettings = useUiStore((s) => s.openSettings)
|
||||||
const setPanelOpen = useUiStore((s) => s.setPanelOpen)
|
const setPanelOpen = useUiStore((s) => s.setPanelOpen)
|
||||||
|
|
||||||
|
// Versions with no card in the chat: saved by older app versions, from
|
||||||
|
// a turn that was retried, or drawn by a call that then failed its
|
||||||
|
// screenshot check (a call still running gets its card when done). The
|
||||||
|
// strip shows them even when there is only one.
|
||||||
|
const versions = useVersionsStore((s) => s.versions)
|
||||||
|
const hasCardless = versions.some(
|
||||||
|
(v) =>
|
||||||
|
!v.toolCallId ||
|
||||||
|
!engine.messages.some((m) =>
|
||||||
|
m.parts?.some((p) => {
|
||||||
|
const part = p as { toolCallId?: string; state?: string }
|
||||||
|
return (
|
||||||
|
part.toolCallId === v.toolCallId &&
|
||||||
|
part.state !== "output-error"
|
||||||
|
)
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<aside
|
<aside
|
||||||
className={cn(
|
className={cn(
|
||||||
@@ -160,7 +180,7 @@ export function ChatPanel({
|
|||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<VersionStrip />
|
<VersionStrip minVersions={hasCardless ? 1 : 2} />
|
||||||
|
|
||||||
<div className="min-h-0 flex-1 overflow-y-auto scrollbar-thin">
|
<div className="min-h-0 flex-1 overflow-y-auto scrollbar-thin">
|
||||||
{engine.messages.length > 0 ? <MessageList /> : <EmptyThread />}
|
{engine.messages.length > 0 ? <MessageList /> : <EmptyThread />}
|
||||||
|
|||||||
@@ -0,0 +1,46 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import {
|
||||||
|
VersionsProvider,
|
||||||
|
type VersionsSource,
|
||||||
|
} from "@/components/canvas/versions-context"
|
||||||
|
import { useCanvasStore } from "@/stores/canvas-store"
|
||||||
|
import { useVersionsStore } from "@/stores/versions-store"
|
||||||
|
import { useChatEngine } from "./chat-engine"
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The chat's versions for the canvas components (version cards, the strip,
|
||||||
|
* the compare dialog): the version store's list and flags, and the chat
|
||||||
|
* engine's restore and undo
|
||||||
|
*/
|
||||||
|
export function ChatVersionsProvider({
|
||||||
|
children,
|
||||||
|
}: {
|
||||||
|
children: React.ReactNode
|
||||||
|
}) {
|
||||||
|
const { restoreVersion, undoVersion } = useChatEngine()
|
||||||
|
const versions = useVersionsStore((s) => s.versions)
|
||||||
|
const onCanvasId = useVersionsStore((s) => s.onCanvasVersionId)
|
||||||
|
const undoneId = useVersionsStore((s) => s.undoneVersionId)
|
||||||
|
const isBusy = useCanvasStore((s) => s.isBusy)
|
||||||
|
|
||||||
|
// Only the latest version can be undone, and only while it is (or was)
|
||||||
|
// on the canvas: after an older version was restored, the latest one
|
||||||
|
// is restored too
|
||||||
|
const latest = versions.at(-1)
|
||||||
|
const undoable = !!latest && latest.beforeXml !== undefined
|
||||||
|
const value: VersionsSource = {
|
||||||
|
versions,
|
||||||
|
onCanvasId,
|
||||||
|
undoneId,
|
||||||
|
isBusy,
|
||||||
|
canUndo: undoable && latest.id === onCanvasId,
|
||||||
|
canRedo: undoable && latest.id === undoneId,
|
||||||
|
restore: restoreVersion,
|
||||||
|
// The engine's undo toggles: it redoes a change that is undone
|
||||||
|
undo: () => latest && undoVersion(latest.id),
|
||||||
|
redo: () => latest && undoVersion(latest.id),
|
||||||
|
}
|
||||||
|
|
||||||
|
return <VersionsProvider value={value}>{children}</VersionsProvider>
|
||||||
|
}
|
||||||
@@ -5,19 +5,14 @@ import {
|
|||||||
Copy,
|
Copy,
|
||||||
Library,
|
Library,
|
||||||
PenTool,
|
PenTool,
|
||||||
Redo2,
|
|
||||||
RotateCcw,
|
|
||||||
Undo2,
|
|
||||||
} from "lucide-react"
|
} from "lucide-react"
|
||||||
import { useState } from "react"
|
import { useState } from "react"
|
||||||
import { Shimmer } from "@/components/ai-elements/shimmer"
|
import { Shimmer } from "@/components/ai-elements/shimmer"
|
||||||
|
import { VersionCard as VersionCardView } from "@/components/canvas/version-card"
|
||||||
import { CodeBlock } from "@/components/code-block"
|
import { CodeBlock } from "@/components/code-block"
|
||||||
import { useDictionary } from "@/hooks/use-dictionary"
|
import { useDictionary } from "@/hooks/use-dictionary"
|
||||||
import { useCopy } from "@/lib/clipboard"
|
import { useCopy } from "@/lib/clipboard"
|
||||||
import { formatMessage } from "@/lib/i18n/utils"
|
|
||||||
import { cn, isMxCellXmlComplete } from "@/lib/utils"
|
import { cn, isMxCellXmlComplete } from "@/lib/utils"
|
||||||
import { describeChanges, describeTotals } from "@/lib/version-text"
|
|
||||||
import { useUiStore } from "@/stores/ui-store"
|
|
||||||
import { type DiagramVersion, useVersionsStore } from "@/stores/versions-store"
|
import { type DiagramVersion, useVersionsStore } from "@/stores/versions-store"
|
||||||
import { useChatEngine } from "./chat-engine"
|
import { useChatEngine } from "./chat-engine"
|
||||||
import type { DiagramOperation, ToolPartLike } from "./types"
|
import type { DiagramOperation, ToolPartLike } from "./types"
|
||||||
@@ -83,45 +78,9 @@ function toolText(part: ToolPartLike): string {
|
|||||||
return JSON.stringify(input, null, 2)
|
return JSON.stringify(input, null, 2)
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Picture of a version (SVG data URL), or what stands in for it */
|
|
||||||
export function VersionThumb({
|
|
||||||
svg,
|
|
||||||
dimmed,
|
|
||||||
className,
|
|
||||||
}: {
|
|
||||||
/** undefined or null: still being made; "": none could be made */
|
|
||||||
svg: string | null | undefined
|
|
||||||
dimmed?: boolean
|
|
||||||
className?: string
|
|
||||||
}) {
|
|
||||||
const t = useDictionary().versions
|
|
||||||
if (svg) {
|
|
||||||
return (
|
|
||||||
// biome-ignore lint/performance/noImgElement: data URL thumbnail
|
|
||||||
<img
|
|
||||||
src={svg}
|
|
||||||
alt=""
|
|
||||||
className={cn(
|
|
||||||
"object-contain transition-opacity",
|
|
||||||
dimmed && "opacity-40",
|
|
||||||
className,
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
if (svg === "") {
|
|
||||||
return <span className="text-xs text-faint">{t.noPreview}</span>
|
|
||||||
}
|
|
||||||
return (
|
|
||||||
<Shimmer as="span" className="text-xs">
|
|
||||||
{t.rendering}
|
|
||||||
</Shimmer>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Card for one diagram version produced by a tool call. The latest shows
|
* Card for one diagram version produced by a tool call: the shared card
|
||||||
* its picture; older ones fold into one row (the picture opens Compare).
|
* with a link to what the AI wrote (shown in a panel under the card)
|
||||||
*/
|
*/
|
||||||
function VersionCard({
|
function VersionCard({
|
||||||
part,
|
part,
|
||||||
@@ -134,175 +93,49 @@ function VersionCard({
|
|||||||
}) {
|
}) {
|
||||||
const dict = useDictionary()
|
const dict = useDictionary()
|
||||||
const t = dict.versions
|
const t = dict.versions
|
||||||
const engine = useChatEngine()
|
|
||||||
const undoneVersionId = useVersionsStore((s) => s.undoneVersionId)
|
|
||||||
const onCanvasVersionId = useVersionsStore((s) => s.onCanvasVersionId)
|
|
||||||
const openCompare = useUiStore((s) => s.openCompare)
|
|
||||||
const [showCode, setShowCode] = useState(false)
|
const [showCode, setShowCode] = useState(false)
|
||||||
const { copied, copy } = useCopy()
|
const { copied, copy } = useCopy()
|
||||||
|
|
||||||
const isUndone = undoneVersionId === version.id
|
|
||||||
const isOnCanvas = onCanvasVersionId === version.id
|
|
||||||
const isEdit = part.type === "tool-edit_diagram"
|
const isEdit = part.type === "tool-edit_diagram"
|
||||||
// Undo/redo belongs to the newest change while it is (or was) on the
|
|
||||||
// canvas; after an older version was restored, this one is restored too
|
|
||||||
const canUndo =
|
|
||||||
isLatest && version.beforeXml !== undefined && (isOnCanvas || isUndone)
|
|
||||||
const title = isUndone ? t.undone : isEdit ? t.edited : t.created
|
|
||||||
const summary = version.fromScratch
|
|
||||||
? describeTotals(version.summary, t)
|
|
||||||
: describeChanges(version.summary, t)
|
|
||||||
const badge = (
|
|
||||||
<span
|
|
||||||
className={cn(
|
|
||||||
"shrink-0 rounded-md px-1.5 py-0.5 text-[11px] font-semibold tabular-nums",
|
|
||||||
isOnCanvas
|
|
||||||
? "bg-marker-soft text-marker-ink"
|
|
||||||
: "bg-muted text-muted-foreground",
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
v{version.number}
|
|
||||||
</span>
|
|
||||||
)
|
|
||||||
const actionClass =
|
|
||||||
"inline-flex h-7 shrink-0 items-center gap-1 rounded-lg px-2 text-xs text-muted-foreground hover:bg-accent hover:text-foreground disabled:opacity-40"
|
|
||||||
const restoreButton = (
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
disabled={engine.isBusy || isOnCanvas}
|
|
||||||
onClick={() => engine.restoreVersion(version.id)}
|
|
||||||
className={actionClass}
|
|
||||||
data-testid="version-restore"
|
|
||||||
>
|
|
||||||
<RotateCcw className="size-3.5" />
|
|
||||||
{formatMessage(t.restoreVersion, { n: version.number })}
|
|
||||||
</button>
|
|
||||||
)
|
|
||||||
|
|
||||||
// What the AI wrote: a text link in the summary line, so the card's
|
|
||||||
// only button is the action (undo or restore)
|
|
||||||
const summaryLine = (
|
|
||||||
<div className="text-xs text-muted-foreground">
|
|
||||||
{summary}
|
|
||||||
{/* The link wraps as one unit with its separator */}
|
|
||||||
<span className="whitespace-nowrap">
|
|
||||||
<span aria-hidden> · </span>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => setShowCode((v) => !v)}
|
|
||||||
className="underline decoration-foreground/30 underline-offset-2 hover:text-foreground hover:decoration-foreground"
|
|
||||||
>
|
|
||||||
{showCode ? t.hideCode : t.showCode}
|
|
||||||
</button>
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
const codePanel = showCode && (
|
|
||||||
<div className="relative border-t border-border bg-surface-1 px-3 py-2.5">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => copy(toolText(part))}
|
|
||||||
className="absolute top-2 right-2 z-10 inline-flex size-7 items-center justify-center rounded-lg bg-card text-muted-foreground shadow-float hover:text-foreground"
|
|
||||||
aria-label={dict.chat.copyResponse}
|
|
||||||
>
|
|
||||||
{copied ? (
|
|
||||||
<Check className="size-3.5" />
|
|
||||||
) : (
|
|
||||||
<Copy className="size-3.5" />
|
|
||||||
)}
|
|
||||||
</button>
|
|
||||||
<ToolInputDetails part={part} />
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
|
|
||||||
if (!isLatest) {
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
className="overflow-hidden rounded-xl border border-border bg-card"
|
|
||||||
data-testid="version-card"
|
|
||||||
data-tool-state={part.state}
|
|
||||||
>
|
|
||||||
<div className="flex items-center gap-2.5 p-1.5 pr-2">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => openCompare(version.id)}
|
|
||||||
className="sheet-light flex h-9 w-14 shrink-0 items-center justify-center overflow-hidden rounded-md border border-border bg-white hover:border-foreground/30"
|
|
||||||
aria-label={t.compare}
|
|
||||||
title={t.compare}
|
|
||||||
>
|
|
||||||
<VersionThumb
|
|
||||||
svg={version.svg}
|
|
||||||
className="max-h-8 max-w-[52px]"
|
|
||||||
/>
|
|
||||||
</button>
|
|
||||||
{badge}
|
|
||||||
<div className="min-w-0 flex-1">
|
|
||||||
<div className="truncate text-[13px] text-foreground">
|
|
||||||
{title}
|
|
||||||
</div>
|
|
||||||
{summaryLine}
|
|
||||||
</div>
|
|
||||||
{restoreButton}
|
|
||||||
</div>
|
|
||||||
{codePanel}
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<VersionCardView
|
||||||
className={cn(
|
version={version}
|
||||||
"overflow-hidden rounded-xl border border-border bg-card",
|
isLatest={isLatest}
|
||||||
isOnCanvas && "border-foreground/15",
|
label={isEdit ? t.edited : t.created}
|
||||||
)}
|
|
||||||
data-testid="version-card"
|
|
||||||
data-tool-state={part.state}
|
data-tool-state={part.state}
|
||||||
>
|
// A text link, so the card's only button is the action (undo
|
||||||
<button
|
// or restore); it wraps as one unit with its separator
|
||||||
type="button"
|
afterSummary={
|
||||||
onClick={() => openCompare(version.id)}
|
<span className="whitespace-nowrap">
|
||||||
className="group/thumb sheet-light relative flex h-24 w-full items-center justify-center border-b border-border bg-white"
|
<span aria-hidden> · </span>
|
||||||
aria-label={t.compare}
|
|
||||||
>
|
|
||||||
<VersionThumb
|
|
||||||
svg={version.svg}
|
|
||||||
dimmed={isUndone}
|
|
||||||
className="max-h-20 max-w-[88%]"
|
|
||||||
/>
|
|
||||||
<span className="absolute right-2 bottom-2 rounded-md bg-black/60 px-1.5 py-0.5 text-[11px] text-white opacity-0 transition-opacity group-hover/thumb:opacity-100">
|
|
||||||
{t.compare}
|
|
||||||
</span>
|
|
||||||
</button>
|
|
||||||
{/* Title row with the action; the summary runs under both */}
|
|
||||||
<div className="grid grid-cols-[auto_minmax(0,1fr)_auto] items-center gap-x-2.5 px-3 py-2.5">
|
|
||||||
{badge}
|
|
||||||
<div className="truncate text-[13px] font-medium text-foreground">
|
|
||||||
{title}
|
|
||||||
</div>
|
|
||||||
{canUndo ? (
|
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
disabled={engine.isBusy}
|
onClick={() => setShowCode((v) => !v)}
|
||||||
onClick={() => engine.undoVersion(version.id)}
|
className="underline decoration-foreground/30 underline-offset-2 hover:text-foreground hover:decoration-foreground"
|
||||||
className={actionClass}
|
|
||||||
data-testid="version-undo"
|
|
||||||
>
|
>
|
||||||
{isUndone ? (
|
{showCode ? t.hideCode : t.showCode}
|
||||||
<Redo2 className="size-3.5" />
|
|
||||||
) : (
|
|
||||||
<Undo2 className="size-3.5" />
|
|
||||||
)}
|
|
||||||
{isUndone ? t.redo : t.undo}
|
|
||||||
</button>
|
</button>
|
||||||
) : (
|
</span>
|
||||||
restoreButton
|
}
|
||||||
)}
|
>
|
||||||
<div className="col-span-2 col-start-2 mt-0.5">
|
{showCode && (
|
||||||
{summaryLine}
|
<div className="relative border-t border-border bg-surface-1 px-3 py-2.5">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => copy(toolText(part))}
|
||||||
|
className="absolute top-2 right-2 z-10 inline-flex size-7 items-center justify-center rounded-lg bg-card text-muted-foreground shadow-float hover:text-foreground"
|
||||||
|
aria-label={dict.chat.copyResponse}
|
||||||
|
>
|
||||||
|
{copied ? (
|
||||||
|
<Check className="size-3.5" />
|
||||||
|
) : (
|
||||||
|
<Copy className="size-3.5" />
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
<ToolInputDetails part={part} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
)}
|
||||||
{codePanel}
|
</VersionCardView>
|
||||||
</div>
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -4,7 +4,10 @@ import { toast } from "sonner"
|
|||||||
import { useDiagram } from "@/contexts/diagram-context"
|
import { useDiagram } from "@/contexts/diagram-context"
|
||||||
import { useDictionary } from "@/hooks/use-dictionary"
|
import { useDictionary } from "@/hooks/use-dictionary"
|
||||||
import { diffDiagrams, isSameDocument } from "@/lib/diagram-diff"
|
import { diffDiagrams, isSameDocument } from "@/lib/diagram-diff"
|
||||||
import { clearHighlights, highlightCells } from "@/lib/drawio/editor-bridge"
|
import {
|
||||||
|
clearHighlights,
|
||||||
|
highlightChangedCells,
|
||||||
|
} from "@/lib/drawio/editor-bridge"
|
||||||
import { formatMessage } from "@/lib/i18n/utils"
|
import { formatMessage } from "@/lib/i18n/utils"
|
||||||
import { BLANK_MXFILE } from "@/packages/mcp-server/src/pages.ts"
|
import { BLANK_MXFILE } from "@/packages/mcp-server/src/pages.ts"
|
||||||
import { useVersionsStore } from "@/stores/versions-store"
|
import { useVersionsStore } from "@/stores/versions-store"
|
||||||
@@ -46,29 +49,24 @@ 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,
|
||||||
fromScratch,
|
fromScratch,
|
||||||
})
|
})
|
||||||
// Mark what changed, unless the whole diagram is new
|
// Mark what changed, unless the whole diagram is new
|
||||||
if (!fromScratch) {
|
if (!fromScratch) highlightChangedCells(touchedIds)
|
||||||
setTimeout(() => {
|
|
||||||
const marker = getComputedStyle(document.documentElement)
|
|
||||||
.getPropertyValue("--marker")
|
|
||||||
.trim()
|
|
||||||
highlightCells(touchedIds, marker || "#ffd84d")
|
|
||||||
}, 60)
|
|
||||||
}
|
|
||||||
// The picture is of the canvas: take it right away, before a
|
// The picture is of the canvas: take it right away, before a
|
||||||
// newer version can be on it; once one is, this one gets none
|
// newer version can be on it; once one is, this one gets none
|
||||||
// rather than the newer one's
|
// rather than the newer one's
|
||||||
|
|||||||
@@ -85,18 +85,6 @@ export function QuotaLimitToast({
|
|||||||
{/* Message */}
|
{/* Message */}
|
||||||
<div className="text-sm text-muted-foreground leading-relaxed mb-4 space-y-2">
|
<div className="text-sm text-muted-foreground leading-relaxed mb-4 space-y-2">
|
||||||
<p>{quotaMessage}</p>
|
<p>{quotaMessage}</p>
|
||||||
{!isSelfHosted && (
|
|
||||||
<p
|
|
||||||
dangerouslySetInnerHTML={{
|
|
||||||
__html: formatMessage(
|
|
||||||
dict.quota.doubaoSponsorship,
|
|
||||||
{
|
|
||||||
link: "https://www.volcengine.com/activity/codingplan?ac=MMAP8JTTCAQ2&rc=Z9Z3LDTJ&utm_campaign=drawio&utm_content=drawio&utm_medium=devrel&utm_source=OWO&utm_term=drawio",
|
|
||||||
},
|
|
||||||
),
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
<p
|
<p
|
||||||
dangerouslySetInnerHTML={{
|
dangerouslySetInnerHTML={{
|
||||||
__html: tipHtml,
|
__html: tipHtml,
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
import { getAssetUrl } from "@/lib/base-path"
|
import { getAssetUrl } from "@/lib/base-path"
|
||||||
import { cn } from "@/lib/utils"
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
/** The app icon in a rounded tile; the white variant is used in dark mode */
|
/** The app icon in a rounded tile; the icon has its own white tile, so dark mode uses it too */
|
||||||
export function BrandMark({ className }: { className?: string }) {
|
export function BrandMark({ className }: { className?: string }) {
|
||||||
return (
|
return (
|
||||||
<span
|
<span
|
||||||
@@ -16,13 +16,7 @@ export function BrandMark({ className }: { className?: string }) {
|
|||||||
<img
|
<img
|
||||||
src={getAssetUrl("/favicon-192x192.png")}
|
src={getAssetUrl("/favicon-192x192.png")}
|
||||||
alt=""
|
alt=""
|
||||||
className="size-full object-contain dark:hidden"
|
className="size-full object-contain"
|
||||||
/>
|
|
||||||
{/* biome-ignore lint/performance/noImgElement: tiny static icon */}
|
|
||||||
<img
|
|
||||||
src={getAssetUrl("/favicon-white.svg")}
|
|
||||||
alt=""
|
|
||||||
className="hidden size-5 object-contain dark:block"
|
|
||||||
/>
|
/>
|
||||||
</span>
|
</span>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -4,12 +4,14 @@ import { MessageSquare, PanelRightOpen, Settings, Shapes } from "lucide-react"
|
|||||||
import { usePathname, useRouter } from "next/navigation"
|
import { usePathname, useRouter } from "next/navigation"
|
||||||
import { useEffect, useRef, useState } from "react"
|
import { useEffect, useRef, useState } from "react"
|
||||||
import { CanvasStage } from "@/components/canvas/canvas-stage"
|
import { CanvasStage } from "@/components/canvas/canvas-stage"
|
||||||
|
import { CompareDialog } from "@/components/canvas/compare-dialog"
|
||||||
|
import { SelectionAsk } from "@/components/canvas/selection-ask"
|
||||||
import {
|
import {
|
||||||
ChatEngineProvider,
|
ChatEngineProvider,
|
||||||
useChatEngine,
|
useChatEngine,
|
||||||
} from "@/components/chat/chat-engine"
|
} from "@/components/chat/chat-engine"
|
||||||
import { ChatPanel } from "@/components/chat/chat-panel"
|
import { ChatPanel } from "@/components/chat/chat-panel"
|
||||||
import { CompareDialog } from "@/components/chat/compare-dialog"
|
import { ChatVersionsProvider } from "@/components/chat/chat-versions"
|
||||||
import { LobbyHero } from "@/components/chat/lobby-hero"
|
import { LobbyHero } from "@/components/chat/lobby-hero"
|
||||||
import { SaveDialog } from "@/components/save-dialog"
|
import { SaveDialog } from "@/components/save-dialog"
|
||||||
import { SettingsDialog } from "@/components/settings-dialog"
|
import { SettingsDialog } from "@/components/settings-dialog"
|
||||||
@@ -116,7 +118,9 @@ export function Workspace() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<ChatEngineProvider>
|
<ChatEngineProvider>
|
||||||
<WorkspaceLayout />
|
<ChatVersionsProvider>
|
||||||
|
<WorkspaceLayout />
|
||||||
|
</ChatVersionsProvider>
|
||||||
</ChatEngineProvider>
|
</ChatEngineProvider>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -267,6 +271,13 @@ function WorkspaceLayout() {
|
|||||||
}
|
}
|
||||||
style={isMobile ? undefined : { right: canvasRight }}
|
style={isMobile ? undefined : { right: canvasRight }}
|
||||||
hideOverlays={!isMobile && showHero}
|
hideOverlays={!isMobile && showHero}
|
||||||
|
overlay={(size) => (
|
||||||
|
<SelectionAsk
|
||||||
|
width={size.width}
|
||||||
|
height={size.height}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
waitForSavedLocale
|
||||||
/>
|
/>
|
||||||
{isMobile ? (
|
{isMobile ? (
|
||||||
mobileView === "chat" &&
|
mobileView === "chat" &&
|
||||||
|
|||||||
+110
-10
@@ -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,22 @@ 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
|
||||||
|
}
|
||||||
|
|
||||||
|
/** An export as draw.io's export action takes it: the format, and for PNG
|
||||||
|
* its scale, width cap and the page to render (pageId) */
|
||||||
|
export interface ExportRequest {
|
||||||
|
format: string
|
||||||
|
[key: string]: unknown
|
||||||
|
}
|
||||||
|
|
||||||
|
/** What draw.io answered: the image (a data URL, or SVG text) in data; for
|
||||||
|
* format "xml" the document in xml instead */
|
||||||
|
export interface ExportResult {
|
||||||
|
data?: string
|
||||||
|
xml?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
interface DiagramContextType {
|
interface DiagramContextType {
|
||||||
@@ -65,12 +83,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>
|
||||||
>
|
>
|
||||||
@@ -87,6 +105,19 @@ interface DiagramContextType {
|
|||||||
getThumbnailSvg: () => Promise<string | null>
|
getThumbnailSvg: () => Promise<string | null>
|
||||||
getVersionSvg: () => Promise<string | null>
|
getVersionSvg: () => Promise<string | null>
|
||||||
captureValidationPng: () => Promise<string | null>
|
captureValidationPng: () => Promise<string | null>
|
||||||
|
/** One export with its own result; null when draw.io does not answer
|
||||||
|
* in time (the MCP shell answers the server's export requests with it) */
|
||||||
|
requestExport: (
|
||||||
|
request: ExportRequest,
|
||||||
|
timeoutMs?: number,
|
||||||
|
) => Promise<ExportResult | null>
|
||||||
|
/** Show a document for an export only (the MCP's one-page projection of
|
||||||
|
* another page): not recorded as the diagram, its autosaves ignored,
|
||||||
|
* until the next loadDiagram puts the real document back */
|
||||||
|
showTransient: (xml: string) => void
|
||||||
|
/** A full load was sent and draw.io has not reported it yet: the
|
||||||
|
* editor still shows the previous document */
|
||||||
|
hasPendingLoad: () => boolean
|
||||||
isDrawioReady: boolean
|
isDrawioReady: boolean
|
||||||
onDrawioLoad: () => void
|
onDrawioLoad: () => void
|
||||||
resetDrawioReady: () => void
|
resetDrawioReady: () => void
|
||||||
@@ -134,17 +165,34 @@ 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)
|
||||||
|
// A transient document (showTransient) is on the canvas, and the page
|
||||||
|
// the user was on before it
|
||||||
|
const transientRef = useRef(false)
|
||||||
|
const pageBeforeTransientRef = 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,
|
||||||
|
pageId: string | null = useCanvasStore.getState().currentPageId,
|
||||||
|
) => {
|
||||||
if (!drawioRef.current) return
|
if (!drawioRef.current) return
|
||||||
|
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
|
||||||
@@ -237,15 +285,53 @@ export function DiagramProvider({ children }: { children: React.ReactNode }) {
|
|||||||
return pngData?.startsWith("data:image/png") ? pngData : null
|
return pngData?.startsWith("data:image/png") ? pngData : null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Like requestTaggedExport, with the request's other parameters (a PNG's
|
||||||
|
// page and width) and both parts of the answer
|
||||||
|
const requestExport = useCallback(
|
||||||
|
(request: ExportRequest, timeoutMs = 10000) =>
|
||||||
|
new Promise<ExportResult | null>((resolve) => {
|
||||||
|
if (!drawioRef.current) {
|
||||||
|
resolve(null)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const id = `export-${++exportSeqRef.current}`
|
||||||
|
const finish = (value: ExportResult | null) => {
|
||||||
|
clearTimeout(timer)
|
||||||
|
delete exportResolversRef.current[id]
|
||||||
|
resolve(value)
|
||||||
|
}
|
||||||
|
const timer = setTimeout(() => finish(null), timeoutMs)
|
||||||
|
exportResolversRef.current[id] = (data, xml) =>
|
||||||
|
finish({ data, xml })
|
||||||
|
drawioRef.current.exportDiagram({ ...request, message: id })
|
||||||
|
}),
|
||||||
|
[],
|
||||||
|
)
|
||||||
|
|
||||||
|
const showTransient = (xml: string) => {
|
||||||
|
if (!transientRef.current) {
|
||||||
|
pageBeforeTransientRef.current =
|
||||||
|
useCanvasStore.getState().currentPageId
|
||||||
|
}
|
||||||
|
transientRef.current = true
|
||||||
|
fullLoad(xml)
|
||||||
|
}
|
||||||
|
|
||||||
const loadDiagram = (
|
const loadDiagram = (
|
||||||
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
|
||||||
if (!mountedRef.current) return null
|
if (!mountedRef.current) return null
|
||||||
|
// A real document ends a transient one; the user goes back to the
|
||||||
|
// page they were on before it
|
||||||
|
const pageBeforeTransient = transientRef.current
|
||||||
|
? pageBeforeTransientRef.current
|
||||||
|
: undefined
|
||||||
|
transientRef.current = false
|
||||||
let xmlToLoad = chart
|
let xmlToLoad = chart
|
||||||
|
|
||||||
// Validate XML structure before loading (unless skipped for internal
|
// Validate XML structure before loading (unless skipped for internal
|
||||||
@@ -300,7 +386,7 @@ export function DiagramProvider({ children }: { children: React.ReactNode }) {
|
|||||||
if (!applied) {
|
if (!applied) {
|
||||||
// A full load replaces any preview, and its base is stale now
|
// A full load replaces any preview, and its base is stale now
|
||||||
resetPreview()
|
resetPreview()
|
||||||
fullLoad(xmlToLoad)
|
fullLoad(xmlToLoad, pageBeforeTransient)
|
||||||
}
|
}
|
||||||
|
|
||||||
if (mode === "commit") {
|
if (mode === "commit") {
|
||||||
@@ -308,6 +394,7 @@ export function DiagramProvider({ children }: { children: React.ReactNode }) {
|
|||||||
beforeXml,
|
beforeXml,
|
||||||
afterXml: xmlToLoad,
|
afterXml: xmlToLoad,
|
||||||
toolCallId: meta?.toolCallId,
|
toolCallId: meta?.toolCallId,
|
||||||
|
pageId: meta?.pageId,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -320,10 +407,10 @@ export function DiagramProvider({ children }: { children: React.ReactNode }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const handleDiagramExport = (data: DrawioExportEvent) => {
|
const handleDiagramExport = (data: DrawioExportEvent) => {
|
||||||
// Thumbnail, version, validation PNG and file save exports go only
|
// Thumbnail, version, validation PNG, file save and requestExport
|
||||||
// to their own caller
|
// exports go only to their own caller
|
||||||
const tag = data.message?.message
|
const tag = data.message?.message
|
||||||
if (/^(thumbnail|validation|version|save)-/.test(tag ?? "")) {
|
if (/^(thumbnail|validation|version|save|export)-/.test(tag ?? "")) {
|
||||||
exportResolversRef.current[tag as string]?.(data.data, data.xml)
|
exportResolversRef.current[tag as string]?.(data.data, data.xml)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -332,7 +419,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
|
||||||
@@ -348,6 +440,11 @@ export function DiagramProvider({ children }: { children: React.ReactNode }) {
|
|||||||
// read refs: state captured in its closure would stay stale after a remount
|
// read refs: state captured in its closure would stay stale after a remount
|
||||||
const handleDiagramAutoSave = (data: { xml?: string }) => {
|
const handleDiagramAutoSave = (data: { xml?: string }) => {
|
||||||
if (!data?.xml) return
|
if (!data?.xml) return
|
||||||
|
// An edit of a transient document is not the diagram's; nor is one
|
||||||
|
// of the canvas a full load still on its way replaces (draw.io
|
||||||
|
// reports each load, in order, so no autosave of the new document
|
||||||
|
// comes before that)
|
||||||
|
if (transientRef.current || pendingLoadsRef.current > 0) return
|
||||||
// Don't overwrite a pending restore - if we have a diagram but
|
// Don't overwrite a pending restore - if we have a diagram but
|
||||||
// DrawIO hasn't loaded yet, it means we're waiting to restore
|
// DrawIO hasn't loaded yet, it means we're waiting to restore
|
||||||
if (!hasCalledOnLoadRef.current && chartXMLRef.current) return
|
if (!hasCalledOnLoadRef.current && chartXMLRef.current) return
|
||||||
@@ -501,6 +598,9 @@ export function DiagramProvider({ children }: { children: React.ReactNode }) {
|
|||||||
getThumbnailSvg,
|
getThumbnailSvg,
|
||||||
getVersionSvg,
|
getVersionSvg,
|
||||||
captureValidationPng,
|
captureValidationPng,
|
||||||
|
requestExport,
|
||||||
|
showTransient,
|
||||||
|
hasPendingLoad: () => pendingLoadsRef.current > 0,
|
||||||
isDrawioReady,
|
isDrawioReady,
|
||||||
onDrawioLoad,
|
onDrawioLoad,
|
||||||
resetDrawioReady,
|
resetDrawioReady,
|
||||||
|
|||||||
+80
-182
@@ -1,107 +1,98 @@
|
|||||||
# Next AI Draw.io
|
|
||||||
|
|
||||||
<div align="center">
|
<div align="center">
|
||||||
|
|
||||||
**AI驱动的图表创建工具 - 对话、绘制、可视化**
|
# Next AI Draw.io
|
||||||
|
|
||||||
|
**和 AI 对话,画出并修改 draw.io 图表。**
|
||||||
|
|
||||||
[English](../../README.md) | 中文 | [日本語](../ja/README_JA.md)
|
[English](../../README.md) | 中文 | [日本語](../ja/README_JA.md)
|
||||||
|
|
||||||
[](https://next-ai-drawio.jiang.jp/)
|
[](https://next-ai-drawio.jiang.jp/)
|
||||||
|
|
||||||
[](https://opensource.org/licenses/Apache-2.0)
|
[](https://opensource.org/licenses/Apache-2.0)
|
||||||
[](https://nextjs.org/)
|
|
||||||
[](https://react.dev/)
|
|
||||||
[](https://github.com/sponsors/DayuanJiang)
|
[](https://github.com/sponsors/DayuanJiang)
|
||||||
|
|
||||||
[](https://next-ai-drawio.jiang.jp/)
|
[**在线体验**](https://next-ai-drawio.jiang.jp/) · [**桌面应用**](https://github.com/DayuanJiang/next-ai-draw-io/releases) · [**MCP 服务器**](#在-ai-代理里使用mcp)
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
一个集成了AI功能的Next.js网页应用,与draw.io图表无缝结合。通过自然语言命令和AI辅助可视化来创建、修改和增强图表。
|
https://github.com/user-attachments/assets/66b9f12f-219f-4d62-acc0-0725e6850eec
|
||||||
|
|
||||||
> 注:感谢 <img src="https://raw.githubusercontent.com/DayuanJiang/next-ai-draw-io/main/public/doubao-color.png" alt="" height="20" /> [字节跳动豆包](https://www.volcengine.com/activity/codingplan?ac=MMAP8JTTCAQ2&rc=Z9Z3LDTJ&utm_campaign=drawio&utm_content=drawio&utm_medium=devrel&utm_source=OWO&utm_term=drawio) 的赞助支持,本项目的 Demo 现已接入强大的 glm-4.7 模型!
|
用一句话描述想要的图,AI 就在真正的 draw.io 画布上把它画出来。你可以像编辑任何 draw.io 文件一样手动修改,也可以选中几个图形、告诉 AI 要改什么。AI 的每次修改都是一个版本,可以对比、恢复或撤销;结果可以导出为 `.drawio`、`.png` 或 `.svg`。
|
||||||
|
|
||||||
<a href="https://www.volcengine.com/activity/codingplan?ac=MMAP8JTTCAQ2&rc=Z9Z3LDTJ&utm_campaign=drawio&utm_content=drawio&utm_medium=devrel&utm_source=OWO&utm_term=drawio" target="_blank"><img src="../../public/volcengine-invite.png" alt="火山引擎方舟 Coding Plan" width="300" /></a>
|
提供网页版、Windows / macOS / Linux 桌面版,以及供 Claude Code、Cursor、VS Code 等 AI 代理调用的 MCP 服务器。
|
||||||
|
|
||||||
https://github.com/user-attachments/assets/b2eef5f3-b335-4e71-a755-dc2e80931979
|
## 亮点
|
||||||
|
|
||||||
## 目录
|
**画**
|
||||||
- [Next AI Draw.io](#next-ai-drawio)
|
|
||||||
- [目录](#目录)
|
- 一句话生成架构图、流程图、时序图等,内置 AWS、Azure、GCP、Kubernetes 等图标库
|
||||||
- [示例](#示例)
|
- 连接线可以带流动动画
|
||||||
- [功能特性](#功能特性)
|
- 上传截图或手绘图,让 AI 照着画;上传 PDF、Markdown、代码等文本文件,从内容生成图
|
||||||
- [MCP服务器](#mcp服务器)
|
|
||||||
- [Claude Code CLI](#claude-code-cli)
|
**改**
|
||||||
- [快速开始](#快速开始)
|
|
||||||
- [在线试用](#在线试用)
|
- 用对话修改:改动流式呈现,刚改过的图形在画布上高亮
|
||||||
- [桌面应用](#桌面应用)
|
- 选中图形提问:在画布上选中几个图形,AI 只改这几个
|
||||||
- [使用Docker运行](#使用docker运行)
|
- 版本与撤销:每次 AI 修改都是一张带缩略图的版本卡片,可以和画布对比、恢复、撤销;画布上按 Ctrl+Z 也能一步撤回 AI 的修改
|
||||||
- [安装](#安装)
|
- 它就是普通的 draw.io:双击改名、拖动、调样式、多页,随时导出 `.drawio`、`.png`、`.svg`、`.drawio.svg`
|
||||||
- [部署](#部署)
|
|
||||||
- [部署到腾讯云EdgeOne Pages](#部署到腾讯云edgeone-pages)
|
**用**
|
||||||
- [部署到Vercel](#部署到vercel)
|
|
||||||
- [部署到Cloudflare Workers](#部署到cloudflare-workers)
|
- 支持 24 家模型服务商,可以在浏览器里填自己的 API Key,Key 只保存在本地
|
||||||
- [多提供商支持](#多提供商支持)
|
- 支持推理的模型会显示思考过程
|
||||||
- [工作原理](#工作原理)
|
- 深色模式;界面有英文、简体中文、繁体中文、日文
|
||||||
- [支持与联系](#支持与联系)
|
|
||||||
- [常见问题](#常见问题)
|
|
||||||
- [Star历史](#star历史)
|
|
||||||
|
|
||||||
## 示例
|
## 示例
|
||||||
|
|
||||||
以下是一些示例提示词及其生成的图表:
|
|
||||||
|
|
||||||
<div align="center">
|
<div align="center">
|
||||||
<table width="100%">
|
<table width="100%">
|
||||||
<tr>
|
<tr>
|
||||||
<td colspan="2" valign="top" align="center">
|
<td colspan="2" valign="top" align="center">
|
||||||
<strong>动画Transformer连接器</strong><br />
|
<strong>带动画连接线的 Transformer 架构</strong><br />
|
||||||
<p><strong>Prompt:</strong> Give me a **animated connector** diagram of transformer's architecture.</p>
|
<p><strong>Prompt:</strong> Give me a <strong>animated connector</strong> diagram of transformer's architecture.</p>
|
||||||
<img src="../../public/animated_connectors.svg" alt="带动画连接器的Transformer架构" width="480" />
|
<img src="../../public/animated_connectors.svg" alt="带动画连接线的 Transformer 架构" width="440" />
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
<tr>
|
<tr>
|
||||||
<td width="50%" valign="top">
|
<td width="50%" valign="top">
|
||||||
<strong>RAG技术图</strong><br />
|
<strong>RAG 架构图</strong><br />
|
||||||
<p><strong>Prompt:</strong> Generate a RAG architecture diagram for **chat application**. Use connected diagram for data ingestion</p>
|
<p><strong>Prompt:</strong> Generate a RAG architecture diagram for <strong>chat application</strong>. Use connected diagram for data ingestion</p>
|
||||||
<img src="../../public/rag_prod.svg" alt="RAG架构图" width="480" />
|
<img src="../../public/rag_prod.svg" alt="RAG 架构图" width="400" />
|
||||||
</td>
|
</td>
|
||||||
<td width="50%" valign="top">
|
<td width="50%" valign="top">
|
||||||
<strong>React和AWS认证流程</strong><br />
|
<strong>React 加 AWS 的认证流程</strong><br />
|
||||||
<p><strong>Prompt:</strong> Generate authentication process using React with **AWS**. Use Serverless architecture.</p>
|
<p><strong>Prompt:</strong> Generate authentication process using React with <strong>AWS</strong>. Use Serverless architecture.</p>
|
||||||
<img src="../../public/auth.svg" alt="认证架构图" width="480" />
|
<img src="../../public/auth.svg" alt="认证架构图" width="400" />
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
<tr>
|
<tr>
|
||||||
<td width="50%" valign="top">
|
<td width="50%" valign="top">
|
||||||
<strong>开放式创新</strong><br />
|
<strong>开放式创新模型</strong><br />
|
||||||
<p><strong>Prompt:</strong> Create visualization of Henry Chesbrough's Open Innovation model.</p>
|
<p><strong>Prompt:</strong> Create visualization of Henry Chesbrough's Open Innovation model.</p>
|
||||||
<img src="../../public/inno.svg" alt="开放式创新图" width="480" />
|
<img src="../../public/inno.svg" alt="开放式创新图" width="400" />
|
||||||
</td>
|
</td>
|
||||||
<td width="50%" valign="top">
|
<td width="50%" valign="top">
|
||||||
<strong>猫咪素描</strong><br />
|
<strong>猫咪素描</strong><br />
|
||||||
<p><strong>Prompt:</strong> Draw a cute cat for me.</p>
|
<p><strong>Prompt:</strong> Draw a cute cat for me.</p>
|
||||||
<img src="../../public/cat_demo.svg" alt="猫咪绘图" width="240" />
|
<img src="../../public/cat_demo.svg" alt="猫咪绘图" width="200" />
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
## 功能特性
|
## 使用方式
|
||||||
|
|
||||||
- **LLM驱动的图表创建**:利用大语言模型通过自然语言命令直接创建和操作draw.io图表
|
### 在线体验
|
||||||
- **基于图像的图表复制**:上传现有图表或图像,让AI自动复制和增强
|
|
||||||
- **PDF和文本文件上传**:上传PDF文档和文本文件,提取内容并从现有文档生成图表
|
|
||||||
- **AI推理过程显示**:查看支持模型的AI思考过程(OpenAI o1/o3、Gemini、Claude等)
|
|
||||||
- **版本与撤销**:AI 每改一次图,聊天里就多一张带缩略图的版本卡片,可以和画布对比、恢复,也可以撤销这次修改;在画布上按 Ctrl+Z 也能一步撤回 AI 的修改。AI 刚改过的图形会在画布上标出来
|
|
||||||
- **针对选中图形提问**:在画布上选中图形,让 AI 只改这几个
|
|
||||||
- **交互式聊天界面**:与AI实时对话来完善您的图表
|
|
||||||
- **云架构图支持**:专门支持生成云架构图(AWS、GCP、Azure)
|
|
||||||
- **动画连接器**:在图表元素之间创建动态动画连接器,实现更好的可视化效果
|
|
||||||
|
|
||||||
## MCP服务器
|
打开 [next-ai-drawio.jiang.jp](https://next-ai-drawio.jiang.jp/) 即可使用,无需安装。演示站有用量限制;点击聊天面板里的设置图标,填入自己的服务商和 API Key 就不受限制。Key 只保存在浏览器本地,不会上传到服务器。
|
||||||
|
|
||||||
通过MCP(模型上下文协议)在Claude Desktop、Cursor和VS Code等AI代理中使用Next AI Draw.io。
|
### 桌面应用
|
||||||
|
|
||||||
|
在 [Releases 页面](https://github.com/DayuanJiang/next-ai-draw-io/releases) 下载 Windows、macOS 或 Linux 安装包。
|
||||||
|
|
||||||
|
### 在 AI 代理里使用(MCP)
|
||||||
|
|
||||||
|
通过 MCP(Model Context Protocol,让 AI 代理调用外部工具的协议),Claude Desktop、Cursor、VS Code 等都能直接画 draw.io 图。在客户端的 MCP 配置里加上:
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
@@ -114,157 +105,64 @@ https://github.com/user-attachments/assets/b2eef5f3-b335-4e71-a755-dc2e80931979
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
### Claude Code CLI
|
Claude Code 用一行命令:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
claude mcp add drawio -- npx @next-ai-drawio/mcp-server@latest
|
claude mcp add drawio -- npx @next-ai-drawio/mcp-server@latest
|
||||||
```
|
```
|
||||||
|
|
||||||
然后让Claude创建图表:
|
然后对 AI 说"画一个用户认证流程图,包含登录、MFA 和会话管理",图会实时出现在浏览器里。MCP 服务器包含网页版的大部分画图能力:
|
||||||
> "创建一个展示用户认证流程的流程图,包含登录、MFA和会话管理"
|
|
||||||
|
|
||||||
图表会实时显示在浏览器中!
|
- 同一套画图规则和图标库(AWS、Azure、GCP、Kubernetes 等)
|
||||||
|
- 预览页就是网页版的画布:AI 的改动会被标出,按一次 Ctrl+Z 就能撤销,AI 还能读到你选中的图形
|
||||||
|
- draw.io 编辑器随包一起安装,装好后预览不需要联网(也可以用 `DRAWIO_BASE_URL` 改用外部的 draw.io)
|
||||||
|
- 截图工具,AI 可以看一眼画好的图并自行修正
|
||||||
|
- 版本历史、多页图表,下载为 `.drawio`、`.png`、`.svg` 或 `.drawio.svg`
|
||||||
|
- 自动保存到 `~/.next-ai-drawio/`,重启后接着画
|
||||||
|
- 可以让 AI 撤销修改,也可以在 `~/.next-ai-drawio/instructions.md` 里写下自己的画图规则
|
||||||
|
|
||||||
MCP服务器包含网页版的大部分画图功能:
|
VS Code、Cursor 等客户端的配置见 [MCP 服务器 README](../../packages/mcp-server/README.md)。
|
||||||
|
|
||||||
- 与网页版相同的画图规则和形状库(AWS、Azure、GCP、Kubernetes等)
|
## 自己部署
|
||||||
- 截图工具,AI可以查看画好的图并自行修正
|
|
||||||
- 版本历史、多页图表,以及下载为`.drawio`、`.png`、`.svg`或`.drawio.svg`格式
|
|
||||||
- 自动保存到`~/.next-ai-drawio/`,重启后可以接着画
|
|
||||||
|
|
||||||
详情请参阅[MCP服务器README](../../packages/mcp-server/README.md),了解VS Code、Cursor等客户端配置。
|
### 本地运行
|
||||||
|
|
||||||
## 快速开始
|
|
||||||
|
|
||||||
### 在线试用
|
|
||||||
|
|
||||||
无需安装!直接在我们的演示站点试用:
|
|
||||||
|
|
||||||
[](https://next-ai-drawio.jiang.jp/)
|
|
||||||
|
|
||||||
> **使用自己的 API Key**:您可以使用自己的 API Key 来绕过演示站点的用量限制。点击聊天面板中的设置图标即可配置您的 Provider 和 API Key。您的 Key 仅保存在浏览器本地,不会被存储在服务器上。
|
|
||||||
|
|
||||||
### 桌面应用
|
|
||||||
|
|
||||||
从 [Releases 页面](https://github.com/DayuanJiang/next-ai-draw-io/releases) 下载适用于您平台的原生桌面应用:
|
|
||||||
|
|
||||||
支持的平台:Windows、macOS、Linux。
|
|
||||||
|
|
||||||
### 使用Docker运行
|
|
||||||
|
|
||||||
[查看 Docker 指南](./docker.md)
|
|
||||||
|
|
||||||
### 安装
|
|
||||||
|
|
||||||
1. 克隆仓库:
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
git clone https://github.com/DayuanJiang/next-ai-draw-io
|
git clone https://github.com/DayuanJiang/next-ai-draw-io
|
||||||
cd next-ai-draw-io
|
cd next-ai-draw-io
|
||||||
npm install
|
npm install
|
||||||
cp env.example .env.local
|
cp env.example .env.local # 填入服务商和 API Key,见下文"模型与服务商"
|
||||||
```
|
|
||||||
|
|
||||||
详细设置说明请参阅[提供商配置指南](./ai-providers.md)。
|
|
||||||
|
|
||||||
2. 运行开发服务器:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
npm run dev
|
npm run dev
|
||||||
```
|
```
|
||||||
|
|
||||||
3. 在浏览器中打开 [http://localhost:6002](http://localhost:6002) 查看应用。
|
打开 [http://localhost:6002](http://localhost:6002)。
|
||||||
|
|
||||||
## 部署
|
### 一键部署
|
||||||
|
|
||||||
### 部署到腾讯云EdgeOne Pages
|
| 平台 | 方式 |
|
||||||
|
| --- | --- |
|
||||||
|
| 腾讯云 EdgeOne Pages | [](https://console.cloud.tencent.com/edgeone/pages/new?repository-url=https%3A%2F%2Fgithub.com%2FDayuanJiang%2Fnext-ai-draw-io) 部署后还能获得[每日免费的 DeepSeek 模型额度](https://edgeone.cloud.tencent.com/pages/document/169925463311781888) |
|
||||||
|
| Vercel | [](https://vercel.com/new/clone?repository-url=https%3A%2F%2Fgithub.com%2FDayuanJiang%2Fnext-ai-draw-io) 记得在 Vercel 控制台里设置和 `.env.local` 相同的环境变量 |
|
||||||
|
| Cloudflare Workers | [Cloudflare 部署指南](./cloudflare-deploy.md) |
|
||||||
|
| Docker | [Docker 指南](./docker.md) |
|
||||||
|
| 离线或内网 | [离线部署说明](./offline-deployment.md) |
|
||||||
|
|
||||||
您可以通过[腾讯云EdgeOne Pages](https://pages.edgeone.ai/zh)一键部署。
|
### 模型与服务商
|
||||||
|
|
||||||
直接点击此按钮一键部署:
|
支持 AWS Bedrock(默认)、OpenAI、Anthropic、Google AI、Google Vertex AI、Azure OpenAI、Ollama、OpenRouter、AIHubMix、DeepSeek、SiliconFlow、SGLang、Vercel AI Gateway、腾讯云 EdgeOne、字节跳动豆包、ModelScope、智谱 GLM、通义千问、七牛云、Kimi、MiniMax、Novita、小米 MiMo、Atlas Cloud 共 24 家。各家的环境变量和注意事项见[服务商配置指南](./ai-providers.md)。
|
||||||
[](https://console.cloud.tencent.com/edgeone/pages/new?repository-url=https%3A%2F%2Fgithub.com%2FDayuanJiang%2Fnext-ai-draw-io)
|
|
||||||
|
|
||||||
查看[腾讯云EdgeOne Pages文档](https://pages.edgeone.ai/zh/document/product-introduction)了解更多详情。
|
**选哪个模型**:这项任务要生成格式严格的长文本(draw.io XML),请选能力较强的模型,小模型容易画出错误的图。
|
||||||
|
|
||||||
同时,通过腾讯云EdgeOne Pages部署,也会获得[每日免费的DeepSeek模型额度](https://edgeone.cloud.tencent.com/pages/document/169925463311781888)。
|
**多模型与管理面板**:在 `AI_MODEL` 里用逗号列出多个模型 ID,或用 `AI_MODELS_CONFIG` 环境变量 / `ai-models.json` 文件配置多家服务商的模型,所有用户无需自带 Key 即可使用。设置 `ADMIN_PASSWORD` 后访问 `/admin`,可以在网页里管理模型、访问码、功能开关、可观测性和配额,见[管理面板指南](./admin-panel.md)。
|
||||||
|
|
||||||
### 部署到Vercel
|
## 支持
|
||||||
|
|
||||||
[](https://vercel.com/new/clone?repository-url=https%3A%2F%2Fgithub.com%2FDayuanJiang%2Fnext-ai-draw-io)
|
- 问题和建议:提交 [GitHub Issue](https://github.com/DayuanJiang/next-ai-draw-io/issues),或发邮件到 me[at]jiang.jp
|
||||||
|
- 常见问题:[FAQ](./FAQ.md)
|
||||||
|
- 如果这个项目对你有用,欢迎[赞助](https://github.com/sponsors/DayuanJiang),帮助我维持演示站点的运行
|
||||||
|
|
||||||
部署Next.js应用最简单的方式是使用Next.js创建者提供的[Vercel平台](https://vercel.com/new)。请确保在Vercel控制台中**设置环境变量**,就像您在本地 `.env.local` 文件中所做的那样。
|
<div align="center">
|
||||||
|
|
||||||
查看[Next.js部署文档](https://nextjs.org/docs/app/building-your-application/deploying)了解更多详情。
|
|
||||||
|
|
||||||
### 部署到Cloudflare Workers
|
|
||||||
|
|
||||||
[查看 Cloudflare 部署指南](./cloudflare-deploy.md)
|
|
||||||
|
|
||||||
|
|
||||||
## 多提供商支持
|
|
||||||
|
|
||||||
- [字节跳动豆包](https://www.volcengine.com/activity/codingplan?ac=MMAP8JTTCAQ2&rc=Z9Z3LDTJ&utm_campaign=drawio&utm_content=drawio&utm_medium=devrel&utm_source=OWO&utm_term=drawio)
|
|
||||||
- AWS Bedrock(默认)
|
|
||||||
- OpenAI
|
|
||||||
- Anthropic
|
|
||||||
- Google AI
|
|
||||||
- Google Vertex AI
|
|
||||||
- Azure OpenAI
|
|
||||||
- Ollama
|
|
||||||
- OpenRouter
|
|
||||||
- AIHubMix
|
|
||||||
- DeepSeek
|
|
||||||
- SiliconFlow
|
|
||||||
- ModelScope
|
|
||||||
- SGLang
|
|
||||||
- Vercel AI Gateway
|
|
||||||
|
|
||||||
除AWS Bedrock和OpenRouter外,所有提供商都支持自定义端点。
|
|
||||||
|
|
||||||
📖 **[详细的提供商配置指南](./ai-providers.md)** - 查看各提供商的设置说明。
|
|
||||||
|
|
||||||
### 服务端多模型配置
|
|
||||||
|
|
||||||
管理员可以配置多个服务端模型,让所有用户无需提供个人 API Key 即可使用。通过 `AI_MODELS_CONFIG` 环境变量(JSON 字符串)或 `ai-models.json` 文件配置。如果只需要单 provider 下的多个模型,也可以直接在 `AI_MODEL` 中用逗号分隔模型 ID。
|
|
||||||
|
|
||||||
**模型要求**:此任务需要强大的模型能力,因为它涉及生成具有严格格式约束的长文本(draw.io XML)。推荐使用 Claude Sonnet 4.5、GPT-5.1、Gemini 3 Pro 和 DeepSeek V3.2/R1。
|
|
||||||
|
|
||||||
注意:`claude` 系列已在带有 AWS、Azure、GCP 等云架构 Logo 的 draw.io 图表上进行训练,因此如果您想创建云架构图,这是最佳选择。
|
|
||||||
|
|
||||||
### 管理面板
|
|
||||||
|
|
||||||
设置 `ADMIN_PASSWORD` 环境变量并访问 `/admin`,即可在 Web 面板中管理服务端设置(模型、访问码、功能开关、可观测性、配额),无需手动编辑 `.env`。
|
|
||||||
|
|
||||||
📖 **[管理面板指南](./admin-panel.md)** — 启用方法、优先级规则和注意事项。
|
|
||||||
|
|
||||||
|
|
||||||
## 工作原理
|
|
||||||
|
|
||||||
本应用使用以下技术:
|
|
||||||
|
|
||||||
- **Next.js**:用于前端框架和路由
|
|
||||||
- **Vercel AI SDK**(`ai` + `@ai-sdk/*`):用于流式AI响应和多提供商支持
|
|
||||||
- **draw.io**:编辑器使用打包在 `public/drawio` 里的副本(由 `npm run dev` / `npm run build` 自动下载),应用可以直接控制它
|
|
||||||
|
|
||||||
图表以XML格式表示,可在draw.io中渲染。AI处理您的命令并相应地生成或修改此XML。
|
|
||||||
|
|
||||||
|
|
||||||
## 支持与联系
|
|
||||||
|
|
||||||
**特别感谢[字节跳动豆包](https://www.volcengine.com/activity/codingplan?ac=MMAP8JTTCAQ2&rc=Z9Z3LDTJ&utm_campaign=drawio&utm_content=drawio&utm_medium=devrel&utm_source=OWO&utm_term=drawio)赞助演示站点的 API Token 使用!** 注册火山引擎 ARK 平台即可获得50万免费Token!
|
|
||||||
|
|
||||||
如果您觉得这个项目有用,请考虑[赞助](https://github.com/sponsors/DayuanJiang)来帮助我托管在线演示站点!
|
|
||||||
|
|
||||||
如需支持或咨询,请在GitHub仓库上提交issue或联系维护者:
|
|
||||||
|
|
||||||
- 邮箱:me[at]jiang.jp
|
|
||||||
|
|
||||||
## 常见问题
|
|
||||||
|
|
||||||
请参阅 [FAQ](./FAQ.md) 了解常见问题和解决方案。
|
|
||||||
|
|
||||||
## Star历史
|
|
||||||
|
|
||||||
[](https://www.star-history.com/#DayuanJiang/next-ai-draw-io&type=date&legend=top-left)
|
[](https://www.star-history.com/#DayuanJiang/next-ai-draw-io&type=date&legend=top-left)
|
||||||
|
|
||||||
---
|
</div>
|
||||||
|
|||||||
@@ -13,8 +13,6 @@
|
|||||||
|
|
||||||
### 豆包 (字节跳动火山引擎)
|
### 豆包 (字节跳动火山引擎)
|
||||||
|
|
||||||
> **免费 Token**:在 [火山引擎 ARK 平台](https://www.volcengine.com/activity/codingplan?ac=MMAP8JTTCAQ2&rc=Z9Z3LDTJ&utm_campaign=drawio&utm_content=drawio&utm_medium=devrel&utm_source=OWO&utm_term=drawio) 注册,即可获得所有模型 50 万免费 Token!
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
DOUBAO_API_KEY=your_api_key
|
DOUBAO_API_KEY=your_api_key
|
||||||
AI_MODEL=doubao-seed-1-8-251215 # 或其他豆包模型
|
AI_MODEL=doubao-seed-1-8-251215 # 或其他豆包模型
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ Next AI Draw.io 自带一份 draw.io。`npm run build`(包括 Docker 构建)
|
|||||||
|
|
||||||
构建会从 [draw.io 发布页](https://github.com/jgraph/drawio/releases) 下载 `draw.war`(约 50 MB)。如果构建机器访问不了 GitHub:
|
构建会从 [draw.io 发布页](https://github.com/jgraph/drawio/releases) 下载 `draw.war`(约 50 MB)。如果构建机器访问不了 GitHub:
|
||||||
|
|
||||||
- 在另一台机器上下载 `scripts/fetch-drawio.mjs` 里写明的那个版本的 `draw.war`,解压到 `public/drawio`(删掉 `WEB-INF` 和 `META-INF`),再把版本号(例如 `v32.0.2`)写进 `public/drawio/.version`。构建时就会使用这份副本。
|
- 在另一台机器上下载 `packages/mcp-server/src/drawio-version.json` 里写明的那个版本的 `draw.war`,解压到 `public/drawio`(删掉 `WEB-INF` 和 `META-INF`),再把版本号(例如 `v32.0.2`)写进 `public/drawio/.version`。构建时就会使用这份副本。
|
||||||
- 或者在能联网的机器上构建镜像,再拷到离线网络里。
|
- 或者在能联网的机器上构建镜像,再拷到离线网络里。
|
||||||
|
|
||||||
## 来自其他网站的图片
|
## 来自其他网站的图片
|
||||||
|
|||||||
@@ -13,8 +13,6 @@ This guide explains how to configure different AI model providers for next-ai-dr
|
|||||||
|
|
||||||
### Doubao (ByteDance Volcengine)
|
### Doubao (ByteDance Volcengine)
|
||||||
|
|
||||||
> **Free tokens**: Register on the [Volcengine ARK platform](https://www.volcengine.com/activity/codingplan?ac=MMAP8JTTCAQ2&rc=Z9Z3LDTJ&utm_campaign=drawio&utm_content=drawio&utm_medium=devrel&utm_source=OWO&utm_term=drawio) to get 500K free tokens for all models!
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
DOUBAO_API_KEY=your_api_key
|
DOUBAO_API_KEY=your_api_key
|
||||||
AI_MODEL=doubao-seed-1-8-251215 # or other Doubao model
|
AI_MODEL=doubao-seed-1-8-251215 # or other Doubao model
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ No separate draw.io container is needed.
|
|||||||
|
|
||||||
The build downloads `draw.war` (about 50 MB) from the [draw.io releases](https://github.com/jgraph/drawio/releases). If the build machine cannot reach GitHub:
|
The build downloads `draw.war` (about 50 MB) from the [draw.io releases](https://github.com/jgraph/drawio/releases). If the build machine cannot reach GitHub:
|
||||||
|
|
||||||
- On another machine, download `draw.war` for the version in `scripts/fetch-drawio.mjs`, unzip it into `public/drawio` (delete `WEB-INF` and `META-INF`), and write the version, for example `v32.0.2`, into `public/drawio/.version`. The build then uses this copy.
|
- On another machine, download `draw.war` for the version in `packages/mcp-server/src/drawio-version.json`, unzip it into `public/drawio` (delete `WEB-INF` and `META-INF`), and write the version, for example `v32.0.2`, into `public/drawio/.version`. The build then uses this copy.
|
||||||
- Or build the image on a machine with internet access and transfer it to the offline network.
|
- Or build the image on a machine with internet access and transfer it to the offline network.
|
||||||
|
|
||||||
## Images From Other Websites
|
## Images From Other Websites
|
||||||
|
|||||||
+81
-182
@@ -1,105 +1,98 @@
|
|||||||
# Next AI Draw.io
|
|
||||||
|
|
||||||
<div align="center">
|
<div align="center">
|
||||||
|
|
||||||
**AI搭載のダイアグラム作成ツール - チャット、描画、可視化**
|
# Next AI Draw.io
|
||||||
|
|
||||||
|
**AI とチャットしながら draw.io のダイアグラムを描き、直す。**
|
||||||
|
|
||||||
[English](../../README.md) | [中文](../cn/README_CN.md) | 日本語
|
[English](../../README.md) | [中文](../cn/README_CN.md) | 日本語
|
||||||
|
|
||||||
[](https://next-ai-drawio.jiang.jp/)
|
[](https://next-ai-drawio.jiang.jp/)
|
||||||
|
|
||||||
[](https://opensource.org/licenses/Apache-2.0)
|
[](https://opensource.org/licenses/Apache-2.0)
|
||||||
[](https://nextjs.org/)
|
|
||||||
[](https://react.dev/)
|
|
||||||
[](https://github.com/sponsors/DayuanJiang)
|
[](https://github.com/sponsors/DayuanJiang)
|
||||||
|
|
||||||
[](https://next-ai-drawio.jiang.jp/)
|
[**オンラインデモ**](https://next-ai-drawio.jiang.jp/) · [**デスクトップアプリ**](https://github.com/DayuanJiang/next-ai-draw-io/releases) · [**MCP サーバー**](#ai-エージェントから使うmcp)
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
AI機能とdraw.ioダイアグラムを統合したNext.jsウェブアプリケーションです。自然言語コマンドとAI支援の可視化により、ダイアグラムを作成、修正、強化できます。
|
https://github.com/user-attachments/assets/66b9f12f-219f-4d62-acc0-0725e6850eec
|
||||||
|
|
||||||
> 注:<img src="https://raw.githubusercontent.com/DayuanJiang/next-ai-draw-io/main/public/doubao-color.png" alt="" height="20" /> [ByteDance Doubao](https://www.volcengine.com/activity/codingplan?ac=MMAP8JTTCAQ2&rc=Z9Z3LDTJ&utm_campaign=drawio&utm_content=drawio&utm_medium=devrel&utm_source=OWO&utm_term=drawio) のご支援により、デモサイトに強力な glm-4.7 モデルを導入しました!
|
欲しいダイアグラムを一文で伝えると、AI が本物の draw.io キャンバスに描きます。普通の draw.io ファイルと同じように手で直すことも、図形をいくつか選んで AI に変更を頼むこともできます。AI の変更はすべてバージョンとして残り、比較・復元・取り消しができます。結果は `.drawio`、`.png`、`.svg` で書き出せます。
|
||||||
|
|
||||||
https://github.com/user-attachments/assets/b2eef5f3-b335-4e71-a755-dc2e80931979
|
Web アプリ、Windows / macOS / Linux 向けデスクトップアプリ、そして Claude Code、Cursor、VS Code などの AI エージェントから呼び出せる MCP サーバーとして使えます。
|
||||||
|
|
||||||
## 目次
|
## 特長
|
||||||
- [Next AI Draw.io](#next-ai-drawio)
|
|
||||||
- [目次](#目次)
|
**描く**
|
||||||
- [例](#例)
|
|
||||||
- [機能](#機能)
|
- アーキテクチャ図、フローチャート、シーケンス図などを一文から生成。AWS、Azure、GCP、Kubernetes のアイコンライブラリを内蔵
|
||||||
- [MCPサーバー](#mcpサーバー)
|
- コネクタに流れるアニメーションを付けられる
|
||||||
- [Claude Code CLI](#claude-code-cli)
|
- スクリーンショットや手描きの図をアップロードすると AI が描き直す。PDF、Markdown、コードなどのテキストファイルからも内容を図にできる
|
||||||
- [はじめに](#はじめに)
|
|
||||||
- [オンラインで試す](#オンラインで試す)
|
**直す**
|
||||||
- [デスクトップアプリケーション](#デスクトップアプリケーション)
|
|
||||||
- [Dockerで実行](#dockerで実行)
|
- チャットで修正:変更はストリーミングでキャンバスに反映され、AI が変更した図形はハイライト表示
|
||||||
- [インストール](#インストール)
|
- 選択して頼む:キャンバスで図形を選ぶと、AI はその図形だけを変更
|
||||||
- [デプロイ](#デプロイ)
|
- バージョンと取り消し:AI の変更ごとにサムネイル付きのバージョンカードが残り、キャンバスと比較・復元・取り消しができる。キャンバス上の Ctrl+Z でも AI の変更を一度で戻せる
|
||||||
- [EdgeOne Pagesへのデプロイ](#edgeone-pagesへのデプロイ)
|
- ただの draw.io ダイアグラム:ダブルクリックで名前を変え、ドラッグし、スタイルを変え、複数ページを使い、いつでも `.drawio`、`.png`、`.svg`、`.drawio.svg` に書き出せる
|
||||||
- [Vercelへのデプロイ](#vercelへのデプロイ)
|
|
||||||
- [Cloudflare Workersへのデプロイ](#cloudflare-workersへのデプロイ)
|
**使う**
|
||||||
- [マルチプロバイダーサポート](#マルチプロバイダーサポート)
|
|
||||||
- [仕組み](#仕組み)
|
- 24 のモデルプロバイダーに対応。自分の API キーをブラウザに入力でき、キーは端末内にだけ保存される
|
||||||
- [サポート&お問い合わせ](#サポートお問い合わせ)
|
- 推論するモデルは思考過程を表示
|
||||||
- [よくある質問](#よくある質問)
|
- ダークモード。UI は英語、簡体字中国語、繁体字中国語、日本語に対応
|
||||||
- [スター履歴](#スター履歴)
|
|
||||||
|
|
||||||
## 例
|
## 例
|
||||||
|
|
||||||
以下はいくつかのプロンプト例と生成されたダイアグラムです:
|
|
||||||
|
|
||||||
<div align="center">
|
<div align="center">
|
||||||
<table width="100%">
|
<table width="100%">
|
||||||
<tr>
|
<tr>
|
||||||
<td colspan="2" valign="top" align="center">
|
<td colspan="2" valign="top" align="center">
|
||||||
<strong>アニメーションTransformerコネクタ</strong><br />
|
<strong>アニメーションコネクタ付き Transformer アーキテクチャ</strong><br />
|
||||||
<p><strong>Prompt:</strong> Give me a **animated connector** diagram of transformer's architecture.</p>
|
<p><strong>Prompt:</strong> Give me a <strong>animated connector</strong> diagram of transformer's architecture.</p>
|
||||||
<img src="../../public/animated_connectors.svg" alt="アニメーションコネクタ付きTransformerアーキテクチャ" width="480" />
|
<img src="../../public/animated_connectors.svg" alt="アニメーションコネクタ付き Transformer アーキテクチャ" width="440" />
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
<tr>
|
<tr>
|
||||||
<td width="50%" valign="top">
|
<td width="50%" valign="top">
|
||||||
<strong>RAG技術ダイアグラム</strong><br />
|
<strong>RAG アーキテクチャ</strong><br />
|
||||||
<p><strong>Prompt:</strong> Generate a RAG architecture diagram for **chat application**. Use connected diagram for data ingestion</p>
|
<p><strong>Prompt:</strong> Generate a RAG architecture diagram for <strong>chat application</strong>. Use connected diagram for data ingestion</p>
|
||||||
<img src="../../public/rag_prod.svg" alt="RAGアーキテクチャ図" width="480" />
|
<img src="../../public/rag_prod.svg" alt="RAG アーキテクチャ図" width="400" />
|
||||||
</td>
|
</td>
|
||||||
<td width="50%" valign="top">
|
<td width="50%" valign="top">
|
||||||
<strong>ReactとAWSによる認証</strong><br />
|
<strong>React と AWS による認証フロー</strong><br />
|
||||||
<p><strong>Prompt:</strong> Generate authentication process using React with **AWS**. Use Serverless architecture.</p>
|
<p><strong>Prompt:</strong> Generate authentication process using React with <strong>AWS</strong>. Use Serverless architecture.</p>
|
||||||
<img src="../../public/auth.svg" alt="認証アーキテクチャ図" width="480" />
|
<img src="../../public/auth.svg" alt="認証アーキテクチャ図" width="400" />
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
<tr>
|
<tr>
|
||||||
<td width="50%" valign="top">
|
<td width="50%" valign="top">
|
||||||
<strong>オープンイノベーション</strong><br />
|
<strong>オープンイノベーションモデル</strong><br />
|
||||||
<p><strong>Prompt:</strong> Create visualization of Henry Chesbrough's Open Innovation model.</p>
|
<p><strong>Prompt:</strong> Create visualization of Henry Chesbrough's Open Innovation model.</p>
|
||||||
<img src="../../public/inno.svg" alt="オープンイノベーション図" width="480" />
|
<img src="../../public/inno.svg" alt="オープンイノベーション図" width="400" />
|
||||||
</td>
|
</td>
|
||||||
<td width="50%" valign="top">
|
<td width="50%" valign="top">
|
||||||
<strong>猫のスケッチ</strong><br />
|
<strong>猫のスケッチ</strong><br />
|
||||||
<p><strong>Prompt:</strong> Draw a cute cat for me.</p>
|
<p><strong>Prompt:</strong> Draw a cute cat for me.</p>
|
||||||
<img src="../../public/cat_demo.svg" alt="猫の絵" width="240" />
|
<img src="../../public/cat_demo.svg" alt="猫の絵" width="200" />
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
## 機能
|
## 使い方
|
||||||
|
|
||||||
- **LLM搭載のダイアグラム作成**:大規模言語モデルを活用して、自然言語コマンドで直接draw.ioダイアグラムを作成・操作
|
### オンラインデモ
|
||||||
- **画像ベースのダイアグラム複製**:既存のダイアグラムや画像をアップロードし、AIが自動的に複製・強化
|
|
||||||
- **PDFとテキストファイルのアップロード**:PDFドキュメントやテキストファイルをアップロードして、既存のドキュメントからコンテンツを抽出し、ダイアグラムを生成
|
|
||||||
- **AI推論プロセス表示**:サポートされているモデル(OpenAI o1/o3、Gemini、Claudeなど)のAIの思考プロセスを表示
|
|
||||||
- **バージョンと取り消し**:AI が図を変更するたびに、サムネイル付きのバージョンカードがチャットに表示されます。キャンバスと比較したり、復元したり、その変更を取り消したりできます。キャンバスで Ctrl+Z を押しても AI の変更を一度に元に戻せます。AI が変更した図形はキャンバス上で強調表示されます
|
|
||||||
- **選択した図形について依頼**:キャンバスで図形を選択し、その図形だけを AI に直してもらえます
|
|
||||||
- **インタラクティブなチャットインターフェース**:AIとリアルタイムでコミュニケーションしてダイアグラムを改善
|
|
||||||
- **クラウドアーキテクチャダイアグラムサポート**:クラウドアーキテクチャダイアグラムの生成を専門的にサポート(AWS、GCP、Azure)
|
|
||||||
- **アニメーションコネクタ**:より良い可視化のためにダイアグラム要素間に動的でアニメーション化されたコネクタを作成
|
|
||||||
|
|
||||||
## MCPサーバー
|
[next-ai-drawio.jiang.jp](https://next-ai-drawio.jiang.jp/) を開けばすぐ使えます。インストールは不要です。デモサイトには利用上限があります。チャットパネルの設定アイコンから自分のプロバイダーと API キーを入力すると上限なしで使えます。キーはブラウザ内にだけ保存され、サーバーには送られません。
|
||||||
|
|
||||||
MCP(Model Context Protocol)を介して、Claude Desktop、Cursor、VS CodeなどのAIエージェントでNext AI Draw.ioを使用できます。
|
### デスクトップアプリ
|
||||||
|
|
||||||
|
[Releases ページ](https://github.com/DayuanJiang/next-ai-draw-io/releases) から Windows、macOS、Linux 向けのインストーラーをダウンロードしてください。
|
||||||
|
|
||||||
|
### AI エージェントから使う(MCP)
|
||||||
|
|
||||||
|
MCP(Model Context Protocol、AI エージェントが外部ツールを呼び出すためのプロトコル)を通して、Claude Desktop、Cursor、VS Code などから直接 draw.io のダイアグラムを描けます。クライアントの MCP 設定に次を追加します。
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
@@ -112,158 +105,64 @@ MCP(Model Context Protocol)を介して、Claude Desktop、Cursor、VS Code
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
### Claude Code CLI
|
Claude Code ならコマンド一つです。
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
claude mcp add drawio -- npx @next-ai-drawio/mcp-server@latest
|
claude mcp add drawio -- npx @next-ai-drawio/mcp-server@latest
|
||||||
```
|
```
|
||||||
|
|
||||||
Claudeにダイアグラムの作成を依頼:
|
あとは AI に「ログイン、MFA、セッション管理を含むユーザー認証のフローチャートを描いて」と頼めば、描かれていく様子がブラウザに表示されます。MCP サーバーには Web アプリの作図機能のほとんどが入っています。
|
||||||
> 「ログイン、MFA、セッション管理を含むユーザー認証のフローチャートを作成してください」
|
|
||||||
|
|
||||||
ダイアグラムがリアルタイムでブラウザに表示されます!
|
- 同じ作図ルールとアイコンライブラリ(AWS、Azure、GCP、Kubernetes など)
|
||||||
|
- プレビューは Web アプリと同じキャンバス。AI の変更には印が付き、Ctrl+Z 一回で元に戻せ、選択中の図形を AI が読み取れる
|
||||||
|
- draw.io エディターはパッケージに同梱されているので、インストール後はオフラインでもプレビューが動く(外部の draw.io を使うなら `DRAWIO_BASE_URL` を設定する)
|
||||||
|
- スクリーンショットツール。AI が描いた結果を確認して自分で直せる
|
||||||
|
- バージョン履歴、複数ページのダイアグラム、`.drawio`、`.png`、`.svg`、`.drawio.svg` でのダウンロード
|
||||||
|
- `~/.next-ai-drawio/` への自動保存。再起動後も続きから描ける
|
||||||
|
- AI に元に戻す操作を頼める。独自の作図ルールは `~/.next-ai-drawio/instructions.md` に書ける
|
||||||
|
|
||||||
MCPサーバーには、Webアプリの主な作図機能が含まれています:
|
VS Code、Cursor などの設定は [MCP サーバーの README](../../packages/mcp-server/README.md) を参照してください。
|
||||||
|
|
||||||
- Webアプリと同じ作図ルールとシェイプライブラリ(AWS、Azure、GCP、Kubernetesなど)
|
## 自分でホストする
|
||||||
- スクリーンショットツール:AIが描画結果を確認して修正できます
|
|
||||||
- バージョン履歴、複数ページのダイアグラム、`.drawio`・`.png`・`.svg`・`.drawio.svg`形式でのダウンロード
|
|
||||||
- `~/.next-ai-drawio/`への自動保存:再起動後も続きから編集できます
|
|
||||||
|
|
||||||
詳細は[MCPサーバーREADME](../../packages/mcp-server/README.md)をご覧ください(VS Code、Cursorなどのクライアント設定も含む)。
|
### ローカルで動かす
|
||||||
|
|
||||||
## はじめに
|
|
||||||
|
|
||||||
### オンラインで試す
|
|
||||||
|
|
||||||
インストール不要!デモサイトで直接お試しください:
|
|
||||||
|
|
||||||
[](https://next-ai-drawio.jiang.jp/)
|
|
||||||
|
|
||||||
> **自分のAPIキーを使用**:自分のAPIキーを使用することで、デモサイトの利用制限を回避できます。チャットパネルの設定アイコンをクリックして、プロバイダーとAPIキーを設定してください。キーはブラウザのローカルに保存され、サーバーには保存されません。
|
|
||||||
|
|
||||||
### デスクトップアプリケーション
|
|
||||||
|
|
||||||
[Releases ページ](https://github.com/DayuanJiang/next-ai-draw-io/releases)からお使いのプラットフォーム用のネイティブデスクトップアプリをダウンロードしてください:
|
|
||||||
|
|
||||||
対応プラットフォーム:Windows、macOS、Linux。
|
|
||||||
|
|
||||||
### Dockerで実行
|
|
||||||
|
|
||||||
[Docker ガイドを参照](./docker.md)
|
|
||||||
|
|
||||||
### インストール
|
|
||||||
|
|
||||||
1. リポジトリをクローン:
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
git clone https://github.com/DayuanJiang/next-ai-draw-io
|
git clone https://github.com/DayuanJiang/next-ai-draw-io
|
||||||
cd next-ai-draw-io
|
cd next-ai-draw-io
|
||||||
npm install
|
npm install
|
||||||
cp env.example .env.local
|
cp env.example .env.local # プロバイダーと API キーを記入。下の「モデルとプロバイダー」を参照
|
||||||
```
|
|
||||||
|
|
||||||
詳細な設定手順については[プロバイダー設定ガイド](./ai-providers.md)を参照してください。
|
|
||||||
|
|
||||||
2. 開発サーバーを起動:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
npm run dev
|
npm run dev
|
||||||
```
|
```
|
||||||
|
|
||||||
3. ブラウザで[http://localhost:6002](http://localhost:6002)を開いてアプリケーションを確認。
|
[http://localhost:6002](http://localhost:6002) を開きます。
|
||||||
|
|
||||||
## デプロイ
|
### ワンクリックデプロイ
|
||||||
|
|
||||||
### EdgeOne Pagesへのデプロイ
|
| プラットフォーム | 方法 |
|
||||||
|
| --- | --- |
|
||||||
|
| Tencent EdgeOne Pages | [](https://edgeone.ai/pages/new?repository-url=https%3A%2F%2Fgithub.com%2FDayuanJiang%2Fnext-ai-draw-io) デプロイすると [DeepSeek モデルの毎日の無料クォータ](https://pages.edgeone.ai/document/edge-ai) も付きます |
|
||||||
|
| Vercel | [](https://vercel.com/new/clone?repository-url=https%3A%2F%2Fgithub.com%2FDayuanJiang%2Fnext-ai-draw-io) `.env.local` と同じ環境変数を Vercel のダッシュボードで設定してください |
|
||||||
|
| Cloudflare Workers | [Cloudflare デプロイガイド](./cloudflare-deploy.md) |
|
||||||
|
| Docker | [Docker ガイド](./docker.md) |
|
||||||
|
| オフラインまたは社内ネットワーク | [オフラインデプロイ](./offline-deployment.md) |
|
||||||
|
|
||||||
[Tencent EdgeOne Pages](https://pages.edgeone.ai/)を使用してワンクリックでデプロイできます。
|
### モデルとプロバイダー
|
||||||
|
|
||||||
このボタンでデプロイ:
|
AWS Bedrock(既定)、OpenAI、Anthropic、Google AI、Google Vertex AI、Azure OpenAI、Ollama、OpenRouter、AIHubMix、DeepSeek、SiliconFlow、SGLang、Vercel AI Gateway、Tencent EdgeOne、ByteDance Doubao、ModelScope、Zhipu GLM、Qwen、Qiniu、Kimi、MiniMax、Novita、Xiaomi MiMo、Atlas Cloud の 24 プロバイダーに対応しています。各プロバイダーの環境変数と注意点は[プロバイダー設定ガイド](./ai-providers.md)にあります。
|
||||||
|
|
||||||
[](https://edgeone.ai/pages/new?repository-url=https%3A%2F%2Fgithub.com%2FDayuanJiang%2Fnext-ai-draw-io)
|
**どのモデルを選ぶか**:厳密な形式の長いテキスト(draw.io の XML)を生成するタスクなので、能力の高いモデルを選んでください。小さなモデルは壊れた図を出しがちです。
|
||||||
|
|
||||||
詳細は[Tencent EdgeOne Pagesドキュメント](https://pages.edgeone.ai/document/deployment-overview)をご覧ください。
|
**複数モデルと管理パネル**:`AI_MODEL` にモデル ID をカンマ区切りで並べるか、`AI_MODELS_CONFIG` 環境変数または `ai-models.json` ファイルで複数プロバイダーのモデルを設定すると、全ユーザーが自分のキーなしで使えます。`ADMIN_PASSWORD` を設定して `/admin` を開くと、モデル、アクセスコード、機能の切り替え、オブザーバビリティ、クォータを Web 画面から管理できます。[管理パネルガイド](./admin-panel.md)を参照してください。
|
||||||
|
|
||||||
また、Tencent EdgeOne Pagesでデプロイすると、[DeepSeekモデルの毎日の無料クォータ](https://pages.edgeone.ai/document/edge-ai)が付与されます。
|
## サポート
|
||||||
|
|
||||||
### Vercelへのデプロイ
|
- 質問や提案:[GitHub Issue](https://github.com/DayuanJiang/next-ai-draw-io/issues) を立てるか、me[at]jiang.jp までメールしてください
|
||||||
|
- よくある問題:[FAQ](./FAQ.md)
|
||||||
|
- このプロジェクトが役に立ったら、デモサイトの運営のために[スポンサー](https://github.com/sponsors/DayuanJiang)をご検討ください
|
||||||
|
|
||||||
[](https://vercel.com/new/clone?repository-url=https%3A%2F%2Fgithub.com%2FDayuanJiang%2Fnext-ai-draw-io)
|
<div align="center">
|
||||||
|
|
||||||
Next.jsアプリをデプロイする最も簡単な方法は、Next.jsの作成者による[Vercelプラットフォーム](https://vercel.com/new)を使用することです。ローカルの`.env.local`ファイルと同様に、Vercelダッシュボードで**環境変数を設定**してください。
|
|
||||||
|
|
||||||
詳細は[Next.jsデプロイメントドキュメント](https://nextjs.org/docs/app/building-your-application/deploying)をご覧ください。
|
|
||||||
|
|
||||||
### Cloudflare Workersへのデプロイ
|
|
||||||
|
|
||||||
[Cloudflare デプロイガイドを参照](./cloudflare-deploy.md)
|
|
||||||
|
|
||||||
|
|
||||||
## マルチプロバイダーサポート
|
|
||||||
|
|
||||||
- [ByteDance Doubao](https://www.volcengine.com/activity/codingplan?ac=MMAP8JTTCAQ2&rc=Z9Z3LDTJ&utm_campaign=drawio&utm_content=drawio&utm_medium=devrel&utm_source=OWO&utm_term=drawio)
|
|
||||||
- AWS Bedrock(デフォルト)
|
|
||||||
- OpenAI
|
|
||||||
- Anthropic
|
|
||||||
- Google AI
|
|
||||||
- Google Vertex AI
|
|
||||||
- Azure OpenAI
|
|
||||||
- Ollama
|
|
||||||
- OpenRouter
|
|
||||||
- AIHubMix
|
|
||||||
- DeepSeek
|
|
||||||
- SiliconFlow
|
|
||||||
- ModelScope
|
|
||||||
- SGLang
|
|
||||||
- Vercel AI Gateway
|
|
||||||
|
|
||||||
AWS BedrockとOpenRouter以外のすべてのプロバイダーはカスタムエンドポイントをサポートしています。
|
|
||||||
|
|
||||||
📖 **[詳細なプロバイダー設定ガイド](./ai-providers.md)** - 各プロバイダーの設定手順をご覧ください。
|
|
||||||
|
|
||||||
### サーバーサイドマルチモデル設定
|
|
||||||
|
|
||||||
管理者は、ユーザーが個人のAPIキーを提供することなく利用できる複数のサーバーサイドモデルを設定できます。`AI_MODELS_CONFIG` 環境変数(JSON文字列)または `ai-models.json` ファイルで設定します。同一プロバイダー内の複数モデルだけが必要な場合は、`AI_MODEL` にカンマ区切りでモデルIDを列挙する簡易設定も使えます。
|
|
||||||
|
|
||||||
**モデル要件**:このタスクは厳密なフォーマット制約(draw.io XML)を持つ長文テキスト生成を伴うため、強力なモデル機能が必要です。Claude Sonnet 4.5、GPT-5.1、Gemini 3 Pro、DeepSeek V3.2/R1を推奨します。
|
|
||||||
|
|
||||||
注:`claude`シリーズはAWS、Azure、GCPなどのクラウドアーキテクチャロゴ付きのdraw.ioダイアグラムで学習されているため、クラウドアーキテクチャダイアグラムを作成したい場合は最適な選択です。
|
|
||||||
|
|
||||||
### 管理パネル
|
|
||||||
|
|
||||||
`ADMIN_PASSWORD` 環境変数を設定して `/admin` にアクセスすると、`.env` を手動で編集する代わりに Web パネルでサーバー設定(モデル、アクセスコード、機能、可観測性、クォータ)を管理できます。
|
|
||||||
|
|
||||||
📖 **[管理パネルガイド](./admin-panel.md)** — 有効化の方法、優先順位ルール、注意事項。
|
|
||||||
|
|
||||||
|
|
||||||
## 仕組み
|
|
||||||
|
|
||||||
本アプリケーションは以下の技術を使用しています:
|
|
||||||
|
|
||||||
- **Next.js**:フロントエンドフレームワークとルーティング
|
|
||||||
- **Vercel AI SDK**(`ai` + `@ai-sdk/*`):ストリーミングAIレスポンスとマルチプロバイダーサポート
|
|
||||||
- **draw.io**:エディターは `public/drawio` に同梱したコピー(`npm run dev` / `npm run build` で自動ダウンロード)で動作し、アプリから直接操作できます
|
|
||||||
|
|
||||||
ダイアグラムはdraw.ioでレンダリングできるXMLとして表現されます。AIがコマンドを処理し、それに応じてこのXMLを生成または変更します。
|
|
||||||
|
|
||||||
|
|
||||||
## サポート&お問い合わせ
|
|
||||||
|
|
||||||
**デモサイトのAPIトークン使用を支援してくださった[ByteDance Doubao](https://www.volcengine.com/activity/codingplan?ac=MMAP8JTTCAQ2&rc=Z9Z3LDTJ&utm_campaign=drawio&utm_content=drawio&utm_medium=devrel&utm_source=OWO&utm_term=drawio)に特別な感謝を申し上げます!** ARKプラットフォームに登録すると、50万トークンが無料でもらえます!
|
|
||||||
|
|
||||||
このプロジェクトが役に立ったら、ライブデモサイトのホスティングを支援するために[スポンサー](https://github.com/sponsors/DayuanJiang)をご検討ください!
|
|
||||||
|
|
||||||
サポートやお問い合わせについては、GitHubリポジトリでissueを開くか、メンテナーにご連絡ください:
|
|
||||||
|
|
||||||
- メール:me[at]jiang.jp
|
|
||||||
|
|
||||||
## よくある質問
|
|
||||||
|
|
||||||
一般的な問題と解決策については [FAQ](./FAQ.md) をご覧ください。
|
|
||||||
|
|
||||||
## スター履歴
|
|
||||||
|
|
||||||
[](https://www.star-history.com/#DayuanJiang/next-ai-draw-io&type=date&legend=top-left)
|
[](https://www.star-history.com/#DayuanJiang/next-ai-draw-io&type=date&legend=top-left)
|
||||||
|
|
||||||
---
|
</div>
|
||||||
|
|||||||
@@ -13,8 +13,6 @@
|
|||||||
|
|
||||||
### Doubao (ByteDance Volcengine)
|
### Doubao (ByteDance Volcengine)
|
||||||
|
|
||||||
> **無料トークン**: [Volcengine ARK プラットフォーム](https://www.volcengine.com/activity/codingplan?ac=MMAP8JTTCAQ2&rc=Z9Z3LDTJ&utm_campaign=drawio&utm_content=drawio&utm_medium=devrel&utm_source=OWO&utm_term=drawio)に登録すると、すべてのモデルで使える50万トークンが無料で入手できます!
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
DOUBAO_API_KEY=your_api_key
|
DOUBAO_API_KEY=your_api_key
|
||||||
AI_MODEL=doubao-seed-1-8-251215 # または他の Doubao モデル
|
AI_MODEL=doubao-seed-1-8-251215 # または他の Doubao モデル
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ draw.io 用の別コンテナは不要です。
|
|||||||
|
|
||||||
ビルドは [draw.io のリリース](https://github.com/jgraph/drawio/releases) から `draw.war`(約 50 MB)をダウンロードします。ビルド環境から GitHub にアクセスできない場合は、次のどちらかを行ってください。
|
ビルドは [draw.io のリリース](https://github.com/jgraph/drawio/releases) から `draw.war`(約 50 MB)をダウンロードします。ビルド環境から GitHub にアクセスできない場合は、次のどちらかを行ってください。
|
||||||
|
|
||||||
- 別のマシンで `scripts/fetch-drawio.mjs` に書かれたバージョンの `draw.war` をダウンロードし、`public/drawio` に展開します(`WEB-INF` と `META-INF` は削除)。さらにバージョン(例:`v32.0.2`)を `public/drawio/.version` に書き込みます。ビルドはこのコピーを使います。
|
- 別のマシンで `packages/mcp-server/src/drawio-version.json` に書かれたバージョンの `draw.war` をダウンロードし、`public/drawio` に展開します(`WEB-INF` と `META-INF` は削除)。さらにバージョン(例:`v32.0.2`)を `public/drawio/.version` に書き込みます。ビルドはこのコピーを使います。
|
||||||
- インターネットに接続できるマシンでイメージをビルドし、オフライン環境に転送します。
|
- インターネットに接続できるマシンでイメージをビルドし、オフライン環境に転送します。
|
||||||
|
|
||||||
## ほかのサイトの画像
|
## ほかのサイトの画像
|
||||||
|
|||||||
@@ -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>`,
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
|
|||||||
+29
-22
@@ -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[] = []
|
||||||
|
|
||||||
@@ -284,7 +285,13 @@ export function sameFileVars(a: string | null, b: string | null): boolean {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Whether two documents hold the same pages (names, cells, their order),
|
* Whether two documents hold the same pages (names, cells, their order),
|
||||||
* page settings and file variables
|
* page settings and file variables. This is the web app's one rule for "is
|
||||||
|
* this version on the canvas" (versions, compare, one-step commits). The
|
||||||
|
* MCP server's edit gate has its own (contentFingerprint): it compares the
|
||||||
|
* cells as written, and leaves page settings and file variables out. The
|
||||||
|
* two disagree both ways (a background change is a difference only here;
|
||||||
|
* a geometry written "40.0" and "40" is one only there), so neither is a
|
||||||
|
* subset of the other.
|
||||||
*/
|
*/
|
||||||
export function isSameDocument(a: string, b: string): boolean {
|
export function isSameDocument(a: string, b: string): boolean {
|
||||||
const pagesOf = (doc: Document) => {
|
const pagesOf = (doc: Document) => {
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
+48
-23
@@ -6,41 +6,66 @@ import { BLANK_MXFILE } from "@/packages/mcp-server/src/pages.ts"
|
|||||||
const EXTERNAL_DRAWIO_URL = process.env.NEXT_PUBLIC_DRAWIO_BASE_URL || ""
|
const EXTERNAL_DRAWIO_URL = process.env.NEXT_PUBLIC_DRAWIO_BASE_URL || ""
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* URL of the draw.io editor iframe. By default this is the copy bundled in
|
* Where the editor comes from and how it starts. The web app fills it from
|
||||||
* public/drawio, served from our own origin.
|
* its build settings (webDrawioSource); the MCP's canvas shell from the
|
||||||
|
* config its server writes into the page.
|
||||||
*/
|
*/
|
||||||
export function getDrawioSrc({
|
export interface DrawioSource {
|
||||||
lang,
|
/** The editor page: the bundled copy's index.html on this origin, or an
|
||||||
isElectron,
|
* external draw.io (cross-origin, limited features) */
|
||||||
dark,
|
editorUrl: string
|
||||||
}: {
|
/** draw.io theme (its ui parameter); none: draw.io's default and the
|
||||||
lang: Locale
|
* user's own pick */
|
||||||
isElectron: boolean
|
ui?: string
|
||||||
dark: boolean
|
/** Language in draw.io's naming ("zh-tw"); none: draw.io's own pick */
|
||||||
}): string {
|
lang?: string
|
||||||
// index.html: Next.js does not serve directory indexes
|
/** No calls to external services (the desktop app) */
|
||||||
const base =
|
offline?: boolean
|
||||||
EXTERNAL_DRAWIO_URL ||
|
/** draw.io's custom libraries (File > New Library, Open Library from,
|
||||||
`${window.location.origin}${getAssetUrl("/drawio/index.html")}`
|
* and the buttons under the shapes); the web app leaves them out */
|
||||||
const url = new URL(base)
|
libraries?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The web app's editor: the copy in public/drawio, served from our own
|
||||||
|
* origin (index.html, as Next.js serves no directory indexes), or
|
||||||
|
* NEXT_PUBLIC_DRAWIO_BASE_URL; the simple theme in the page's language.
|
||||||
|
*/
|
||||||
|
export function webDrawioSource(lang: Locale): DrawioSource {
|
||||||
|
const isElectron = !!(window as { electronAPI?: unknown }).electronAPI
|
||||||
|
return {
|
||||||
|
editorUrl: EXTERNAL_DRAWIO_URL || getAssetUrl("/drawio/index.html"),
|
||||||
|
ui: "simple",
|
||||||
|
// draw.io names Traditional Chinese "zh-tw"
|
||||||
|
lang: lang === "zh-Hant" ? "zh-tw" : lang,
|
||||||
|
offline: isElectron && !EXTERNAL_DRAWIO_URL,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** URL of the draw.io editor iframe */
|
||||||
|
export function getDrawioSrc(source: DrawioSource, dark: boolean): string {
|
||||||
|
const url = new URL(source.editorUrl, window.location.href)
|
||||||
const params: Record<string, string> = {
|
const params: Record<string, string> = {
|
||||||
embed: "1",
|
embed: "1",
|
||||||
proto: "json",
|
proto: "json",
|
||||||
configure: "1",
|
configure: "1",
|
||||||
ui: "simple",
|
}
|
||||||
|
// Without a fixed theme, embedded draw.io shows Extras > Theme only
|
||||||
|
// with themes=1 (its isThemeMenuVisible)
|
||||||
|
if (source.ui) params.ui = source.ui
|
||||||
|
else params.themes = "1"
|
||||||
|
Object.assign(params, {
|
||||||
spin: "0",
|
spin: "0",
|
||||||
libraries: "0",
|
libraries: source.libraries ? "1" : "0",
|
||||||
noSaveBtn: "1",
|
noSaveBtn: "1",
|
||||||
noExitBtn: "1",
|
noExitBtn: "1",
|
||||||
saveAndExit: "0",
|
saveAndExit: "0",
|
||||||
// draw.io's own dark mode (switched in place later when the editor
|
// draw.io's own dark mode (switched in place later when the editor
|
||||||
// can be driven directly)
|
// can be driven directly)
|
||||||
dark: dark ? "1" : "0",
|
dark: dark ? "1" : "0",
|
||||||
// draw.io names Traditional Chinese "zh-tw"
|
})
|
||||||
lang: lang === "zh-Hant" ? "zh-tw" : lang,
|
if (source.lang) params.lang = source.lang
|
||||||
}
|
if (source.offline) params.offline = "1"
|
||||||
// No calls to external services from the desktop app
|
|
||||||
if (isElectron && !EXTERNAL_DRAWIO_URL) params.offline = "1"
|
|
||||||
for (const [key, value] of Object.entries(params)) {
|
for (const [key, value] of Object.entries(params)) {
|
||||||
url.searchParams.set(key, value)
|
url.searchParams.set(key, value)
|
||||||
}
|
}
|
||||||
|
|||||||
+176
-40
@@ -9,8 +9,9 @@
|
|||||||
* 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 { SelectionAnswer } from "@/packages/mcp-server/src/selection.ts"
|
||||||
import { type SelectedCell, useCanvasStore } from "@/stores/canvas-store"
|
import { type SelectedCell, useCanvasStore } from "@/stores/canvas-store"
|
||||||
|
|
||||||
type EditorUi = any
|
type EditorUi = any
|
||||||
@@ -152,23 +153,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 +175,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 +252,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")
|
||||||
@@ -213,19 +263,19 @@ function hasShapes(xml: string): boolean {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// Page settings draw.io applies on a full load only: replacing the page in
|
/**
|
||||||
// place (Editor.readGraphState) keeps the old ones. (Its adaptive colors and
|
* Page settings draw.io applies on a full load only: replacing the page in
|
||||||
// theme stay too: a diagram the AI writes does not set them.)
|
* place (Editor.readGraphState) keeps the old ones. (Its adaptive colors and
|
||||||
function hasLoadOnlySettings(model: string): boolean {
|
* theme stay too: a diagram the AI writes does not set them.) Takes the
|
||||||
const page = new DOMParser().parseFromString(
|
* page's mxGraphModel element; the MCP shell's sync asks the same question
|
||||||
model,
|
* of a document before it reaches the editor.
|
||||||
"text/xml",
|
*/
|
||||||
).documentElement
|
export function hasLoadOnlySettings(model: Element): boolean {
|
||||||
return (
|
return (
|
||||||
page.hasAttribute("backgroundImage") ||
|
model.hasAttribute("backgroundImage") ||
|
||||||
page.hasAttribute("extFonts") ||
|
model.hasAttribute("extFonts") ||
|
||||||
page.getAttribute("math") === "1" ||
|
model.getAttribute("math") === "1" ||
|
||||||
page.getAttribute("shadow") === "1"
|
model.getAttribute("shadow") === "1"
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -256,8 +306,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,11 +319,13 @@ 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 &&
|
||||||
!hasLoadOnlySettings(model) &&
|
!hasLoadOnlySettings(
|
||||||
|
new DOMParser().parseFromString(model, "text/xml").documentElement,
|
||||||
|
) &&
|
||||||
!canvasHasLoadOnlySettings()
|
!canvasHasLoadOnlySettings()
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -285,13 +338,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 +383,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 +398,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 +429,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
|
||||||
@@ -477,6 +550,19 @@ export function highlightCells(ids: string[], color: string) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Marks the cells an AI change touched in the page's marker color, once
|
||||||
|
* draw.io has drawn them (the web app's versions and the MCP shell share it)
|
||||||
|
*/
|
||||||
|
export function highlightChangedCells(ids: string[]) {
|
||||||
|
setTimeout(() => {
|
||||||
|
const marker = getComputedStyle(document.documentElement)
|
||||||
|
.getPropertyValue("--marker")
|
||||||
|
.trim()
|
||||||
|
highlightCells(ids, marker || "#ffd84d")
|
||||||
|
}, 60)
|
||||||
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Selection
|
// Selection
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -514,6 +600,56 @@ function readSelection(): SelectedCell[] {
|
|||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The selection with what an editing model needs (the MCP's get_selection):
|
||||||
|
* the page it is on, each cell's label, an edge's ends, a shape's geometry
|
||||||
|
* as the XML has it (relative to its container) and that container. Null
|
||||||
|
* without an editor (a cross-origin draw.io).
|
||||||
|
*/
|
||||||
|
export function readSelectionDetails(): SelectionAnswer | null {
|
||||||
|
const g = graph()
|
||||||
|
if (!g) return null
|
||||||
|
const page = ui?.currentPage
|
||||||
|
const cells = (g.getSelectionCells() as any[])
|
||||||
|
.filter((cell) => cell?.id)
|
||||||
|
.map((cell) => {
|
||||||
|
const info: SelectionAnswer["cells"][number] = {
|
||||||
|
id: String(cell.id),
|
||||||
|
label: cellLabel(cell),
|
||||||
|
edge: !!g.model.isEdge(cell),
|
||||||
|
}
|
||||||
|
if (info.edge) {
|
||||||
|
const source = g.model.getTerminal(cell, true)
|
||||||
|
const target = g.model.getTerminal(cell, false)
|
||||||
|
if (source?.id) info.source = String(source.id)
|
||||||
|
if (target?.id) info.target = String(target.id)
|
||||||
|
} else {
|
||||||
|
const geo = g.getCellGeometry(cell)
|
||||||
|
if (geo) {
|
||||||
|
info.geometry = {
|
||||||
|
x: geo.x,
|
||||||
|
y: geo.y,
|
||||||
|
width: geo.width,
|
||||||
|
height: geo.height,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// The container the cell is in; a layer is none (and the
|
||||||
|
// default parent is the group the user entered, if any, so it
|
||||||
|
// cannot tell the two apart)
|
||||||
|
const parent = g.model.getParent(cell)
|
||||||
|
if (parent?.id && !g.model.isLayer(parent)) {
|
||||||
|
info.parent = String(parent.id)
|
||||||
|
}
|
||||||
|
return info
|
||||||
|
})
|
||||||
|
return {
|
||||||
|
pageId: page?.getId ? String(page.getId()) : null,
|
||||||
|
pageName: page?.getName ? String(page.getName()) : null,
|
||||||
|
cells,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function readSelectionRect() {
|
function readSelectionRect() {
|
||||||
const g = graph()
|
const g = graph()
|
||||||
if (!g || g.isSelectionEmpty()) return null
|
if (!g || g.isSelectionEmpty()) return null
|
||||||
|
|||||||
@@ -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",
|
||||||
@@ -211,14 +211,13 @@
|
|||||||
"tpmLimit": "Rate Limit",
|
"tpmLimit": "Rate Limit",
|
||||||
"tpmMessage": "Too many requests. Please wait a moment.",
|
"tpmMessage": "Too many requests. Please wait a moment.",
|
||||||
"tpmMessageDetailed": "Rate limit reached ({limit} tokens/min). Please wait {seconds} seconds before sending another request.",
|
"tpmMessageDetailed": "Rate limit reached ({limit} tokens/min). Please wait {seconds} seconds before sending another request.",
|
||||||
"messageApi": "Looks like you've reached today's demo limit. We're thrilled you're enjoying it, and while ByteDance Doubao generously sponsors this demo, we've had to set a few boundaries to keep things fair for everyone.",
|
"messageApi": "Looks like you've reached today's demo limit. We're glad you're enjoying it; the limit keeps the demo fair for everyone.",
|
||||||
"messageApiSelfHosted": null,
|
"messageApiSelfHosted": null,
|
||||||
"messageToken": "Looks like you've reached today's token limit. We're thrilled you're enjoying it, and while ByteDance Doubao generously sponsors this demo, we've had to set a few boundaries to keep things fair for everyone.",
|
"messageToken": "Looks like you've reached today's token limit. We're glad you're enjoying it; the limit keeps the demo fair for everyone.",
|
||||||
"messageTokenSelfHosted": null,
|
"messageTokenSelfHosted": null,
|
||||||
"tip": "<strong>Tip:</strong> You can use your own API key (click the Settings icon) or self-host the project to bypass these limits.",
|
"tip": "<strong>Tip:</strong> You can use your own API key (click the Settings icon) or self-host the project to bypass these limits.",
|
||||||
"tipSelfHosted": "<strong>Tip:</strong> You can configure your own API key in the settings to continue using the service.",
|
"tipSelfHosted": "<strong>Tip:</strong> You can configure your own API key in the settings to continue using the service.",
|
||||||
"reset": "Your limit resets tomorrow. Thanks for understanding.",
|
"reset": "Your limit resets tomorrow. Thanks for understanding.",
|
||||||
"doubaoSponsorship": "<a href=\"{link}\" target=\"_blank\" rel=\"noopener noreferrer\" class=\"underline hover:text-foreground\">Register here</a> to get 500K free tokens per model (including Doubao, DeepSeek and Kimi), then configure your API key in model settings.",
|
|
||||||
"configModel": "Use Your API Key",
|
"configModel": "Use Your API Key",
|
||||||
"selfHost": "Self-host",
|
"selfHost": "Self-host",
|
||||||
"sponsor": "Sponsor",
|
"sponsor": "Sponsor",
|
||||||
@@ -483,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",
|
||||||
@@ -532,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"
|
||||||
@@ -599,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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -700,5 +699,20 @@
|
|||||||
"recent": "Recent diagrams",
|
"recent": "Recent diagrams",
|
||||||
"drawYourself": "Start with a blank canvas",
|
"drawYourself": "Start with a blank canvas",
|
||||||
"openFileHint": "Keep working on a diagram you have"
|
"openFileHint": "Keep working on a diagram you have"
|
||||||
|
},
|
||||||
|
"shell": {
|
||||||
|
"waiting": "Waiting for the MCP server…",
|
||||||
|
"toggleTheme": "Switch between light and dark",
|
||||||
|
"connected": "Connected to the MCP server",
|
||||||
|
"offline": "Can't reach the MCP server",
|
||||||
|
"tooLarge": "This diagram is too large to save to the MCP server (over 10 MB).",
|
||||||
|
"unreachable": "Can't reach the MCP server. Your changes are only in this tab for now.",
|
||||||
|
"restoredFromFile": "The diagram was restored from its saved file. What this tab showed before is in History.",
|
||||||
|
"aiChanged": "The AI changed the diagram while you were editing. Your last change was saved in History.",
|
||||||
|
"historyChanged": "History changed on the server. Please open it again.",
|
||||||
|
"restoreFailed": "Restore failed. Please try again.",
|
||||||
|
"userEdit": "Changed by hand",
|
||||||
|
"showVersions": "Show the versions",
|
||||||
|
"hideVersions": "Hide the versions"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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": "このアプリについて",
|
||||||
@@ -211,14 +211,13 @@
|
|||||||
"tpmLimit": "レート制限",
|
"tpmLimit": "レート制限",
|
||||||
"tpmMessage": "リクエストが多すぎます。しばらくお待ちください。",
|
"tpmMessage": "リクエストが多すぎます。しばらくお待ちください。",
|
||||||
"tpmMessageDetailed": "レート制限に達しました({limit}トークン/分)。{seconds}秒待ってからもう一度リクエストしてください。",
|
"tpmMessageDetailed": "レート制限に達しました({limit}トークン/分)。{seconds}秒待ってからもう一度リクエストしてください。",
|
||||||
"messageApi": "今日のデモ利用上限に達してしまったようです。楽しんでいただけて本当に嬉しいです。このデモはByteDance Doubaoのご厚意により提供されていますが、皆様に公平にご利用いただくため、少し制限を設けさせていただいております。",
|
"messageApi": "今日のデモ利用上限に達したようです。楽しんでいただけて嬉しいです。皆様に公平にご利用いただくため、利用量に上限を設けています。",
|
||||||
"messageApiSelfHosted": null,
|
"messageApiSelfHosted": null,
|
||||||
"messageToken": "今日のトークン利用上限に達してしまったようです。楽しんでいただけて本当に嬉しいです。このデモはByteDance Doubaoのご厚意により提供されていますが、皆様に公平にご利用いただくため、少し制限を設けさせていただいております。",
|
"messageToken": "今日のトークン利用上限に達したようです。楽しんでいただけて嬉しいです。皆様に公平にご利用いただくため、利用量に上限を設けています。",
|
||||||
"messageTokenSelfHosted": null,
|
"messageTokenSelfHosted": null,
|
||||||
"tip": "<strong>ヒント:</strong>独自の API キーを使用する(設定アイコンをクリック)か、プロジェクトをセルフホストしてこれらの制限を回避できます。",
|
"tip": "<strong>ヒント:</strong>独自の API キーを使用する(設定アイコンをクリック)か、プロジェクトをセルフホストしてこれらの制限を回避できます。",
|
||||||
"tipSelfHosted": "<strong>ヒント:</strong>設定で独自の API キーを設定することで、引き続きサービスをご利用いただけます。",
|
"tipSelfHosted": "<strong>ヒント:</strong>設定で独自の API キーを設定することで、引き続きサービスをご利用いただけます。",
|
||||||
"reset": "制限は明日リセットされます。ご理解ありがとうございます。",
|
"reset": "制限は明日リセットされます。ご理解ありがとうございます。",
|
||||||
"doubaoSponsorship": "<a href=\"{link}\" target=\"_blank\" rel=\"noopener noreferrer\" class=\"underline hover:text-foreground\">こちらから登録</a>すると、各モデル(Doubao、DeepSeek、Kimi含む)で50万トークンを無料で取得できます。モデル設定でAPIキーを設定してください。",
|
|
||||||
"configModel": "APIキーを使用",
|
"configModel": "APIキーを使用",
|
||||||
"selfHost": "セルフホスト",
|
"selfHost": "セルフホスト",
|
||||||
"sponsor": "スポンサー",
|
"sponsor": "スポンサー",
|
||||||
@@ -483,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"
|
||||||
@@ -599,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 時にリセットされます"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -700,5 +699,20 @@
|
|||||||
"recent": "最近の図",
|
"recent": "最近の図",
|
||||||
"drawYourself": "白紙のキャンバスから始める",
|
"drawYourself": "白紙のキャンバスから始める",
|
||||||
"openFileHint": "手元の図の続きを編集"
|
"openFileHint": "手元の図の続きを編集"
|
||||||
|
},
|
||||||
|
"shell": {
|
||||||
|
"waiting": "MCP サーバーを待っています…",
|
||||||
|
"toggleTheme": "ライトとダークを切り替え",
|
||||||
|
"connected": "MCP サーバーに接続しました",
|
||||||
|
"offline": "MCP サーバーに接続できません",
|
||||||
|
"tooLarge": "この図は大きすぎて MCP サーバーに保存できません(10 MB 超)。",
|
||||||
|
"unreachable": "MCP サーバーに接続できません。変更は今のところこのタブにだけあります。",
|
||||||
|
"restoredFromFile": "図は保存済みファイルから復元されました。このタブに表示されていた内容は履歴にあります。",
|
||||||
|
"aiChanged": "編集中に AI が図を変更しました。最後の変更は履歴に保存されました。",
|
||||||
|
"historyChanged": "サーバー上の履歴が変わりました。もう一度開いてください。",
|
||||||
|
"restoreFailed": "復元に失敗しました。もう一度お試しください。",
|
||||||
|
"userEdit": "手で変更しました",
|
||||||
|
"showVersions": "バージョンを表示",
|
||||||
|
"hideVersions": "バージョンを隠す"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,18 +207,17 @@
|
|||||||
},
|
},
|
||||||
"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 用量已達上限。很高興您玩得開心;為了讓大家都能公平使用,示範站對用量做了限制。",
|
||||||
"messageTokenSelfHosted": null,
|
"messageTokenSelfHosted": null,
|
||||||
"tip": "<strong>提示:</strong>您可以使用自己的 API Key(點擊設定圖示)或自行託管專案來繞過這些限制。",
|
"tip": "<strong>提示:</strong>您可以使用自己的 API Key(點擊設定圖示)或自行託管專案來繞過這些限制。",
|
||||||
"tipSelfHosted": "<strong>提示:</strong>您可以在設定中配置自己的 API Key 以繼續使用服務。",
|
"tipSelfHosted": "<strong>提示:</strong>您可以在設定中配置自己的 API Key 以繼續使用服務。",
|
||||||
"reset": "您的限制將在明天重設。感謝您的理解。",
|
"reset": "您的限制將在明天重設。感謝您的理解。",
|
||||||
"doubaoSponsorship": "<a href=\"{link}\" target=\"_blank\" rel=\"noopener noreferrer\" class=\"underline hover:text-foreground\">點此註冊</a>可獲得每個模型 50 萬免費 Token(包括豆包、DeepSeek 和 Kimi),然後在模型設定中配置您的 API Key。",
|
|
||||||
"configModel": "使用自己的 API Key",
|
"configModel": "使用自己的 API Key",
|
||||||
"selfHost": "自行託管",
|
"selfHost": "自行託管",
|
||||||
"sponsor": "贊助",
|
"sponsor": "贊助",
|
||||||
@@ -482,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": "每日用量按這個時區的零點重置"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -700,5 +699,20 @@
|
|||||||
"recent": "最近的圖紙",
|
"recent": "最近的圖紙",
|
||||||
"drawYourself": "從空白畫布開始",
|
"drawYourself": "從空白畫布開始",
|
||||||
"openFileHint": "接著編輯已有的圖"
|
"openFileHint": "接著編輯已有的圖"
|
||||||
|
},
|
||||||
|
"shell": {
|
||||||
|
"waiting": "正在等待 MCP 伺服器…",
|
||||||
|
"toggleTheme": "切換淺色或深色",
|
||||||
|
"connected": "已連接 MCP 伺服器",
|
||||||
|
"offline": "無法連接 MCP 伺服器",
|
||||||
|
"tooLarge": "這張圖太大,無法儲存到 MCP 伺服器(超過 10 MB)。",
|
||||||
|
"unreachable": "無法連接 MCP 伺服器。您的修改暫時只保存在這個分頁裡。",
|
||||||
|
"restoredFromFile": "圖表已從儲存的檔案還原。這個分頁之前顯示的內容已放入歷史記錄。",
|
||||||
|
"aiChanged": "您編輯時 AI 修改了圖表。您的最後一次修改已儲存到歷史記錄。",
|
||||||
|
"historyChanged": "伺服器上的歷史記錄已變化,請重新開啟。",
|
||||||
|
"restoreFailed": "還原失敗,請再試一次。",
|
||||||
|
"userEdit": "手動修改",
|
||||||
|
"showVersions": "顯示版本",
|
||||||
|
"hideVersions": "隱藏版本"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,18 +207,17 @@
|
|||||||
},
|
},
|
||||||
"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 用量已达上限。很高兴您玩得开心;为了让大家都能公平使用,演示站对用量做了限制。",
|
||||||
"messageTokenSelfHosted": null,
|
"messageTokenSelfHosted": null,
|
||||||
"tip": "<strong>提示:</strong>您可以使用自己的 API Key(点击设置图标)或自托管项目来绕过这些限制。",
|
"tip": "<strong>提示:</strong>您可以使用自己的 API Key(点击设置图标)或自托管项目来绕过这些限制。",
|
||||||
"tipSelfHosted": "<strong>提示:</strong>您可以在设置中配置自己的 API Key 以继续使用服务。",
|
"tipSelfHosted": "<strong>提示:</strong>您可以在设置中配置自己的 API Key 以继续使用服务。",
|
||||||
"reset": "您的限制将在明天重置。感谢您的理解。",
|
"reset": "您的限制将在明天重置。感谢您的理解。",
|
||||||
"doubaoSponsorship": "<a href=\"{link}\" target=\"_blank\" rel=\"noopener noreferrer\" class=\"underline hover:text-foreground\">点击此处注册</a>可获得每个模型 50 万免费 Token(包括豆包、DeepSeek 和 Kimi),然后在模型设置中配置您的 API Key。",
|
|
||||||
"configModel": "使用自己的 API Key",
|
"configModel": "使用自己的 API Key",
|
||||||
"selfHost": "自托管",
|
"selfHost": "自托管",
|
||||||
"sponsor": "赞助",
|
"sponsor": "赞助",
|
||||||
@@ -482,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": "每日用量按这个时区的零点重置"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -700,5 +699,20 @@
|
|||||||
"recent": "最近的图纸",
|
"recent": "最近的图纸",
|
||||||
"drawYourself": "从空白画布开始",
|
"drawYourself": "从空白画布开始",
|
||||||
"openFileHint": "接着编辑已有的图"
|
"openFileHint": "接着编辑已有的图"
|
||||||
|
},
|
||||||
|
"shell": {
|
||||||
|
"waiting": "正在等待 MCP 服务器…",
|
||||||
|
"toggleTheme": "切换浅色或深色",
|
||||||
|
"connected": "已连接 MCP 服务器",
|
||||||
|
"offline": "无法连接 MCP 服务器",
|
||||||
|
"tooLarge": "这张图太大,无法保存到 MCP 服务器(超过 10 MB)。",
|
||||||
|
"unreachable": "无法连接 MCP 服务器。您的修改暂时只保存在这个标签页里。",
|
||||||
|
"restoredFromFile": "图表已从保存的文件恢复。这个标签页之前显示的内容已放入历史记录。",
|
||||||
|
"aiChanged": "您编辑时 AI 修改了图表。您的最后一次修改已保存到历史记录。",
|
||||||
|
"historyChanged": "服务器上的历史记录已变化,请重新打开。",
|
||||||
|
"restoreFailed": "恢复失败,请重试。",
|
||||||
|
"userEdit": "手动修改",
|
||||||
|
"showVersions": "显示版本",
|
||||||
|
"hideVersions": "隐藏版本"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+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"/>
|
||||||
|
|||||||
+37
-55
@@ -32,50 +32,7 @@ export function isRealDiagram(xml: string | undefined | null): boolean {
|
|||||||
// mxCell XML Helpers
|
// mxCell XML Helpers
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
|
|
||||||
/**
|
export { isMxCellXmlComplete } from "@/packages/mcp-server/src/new-diagram.ts"
|
||||||
* Check if mxCell XML output is complete (not truncated).
|
|
||||||
* Complete XML ends with a self-closing tag (/>) or closing mxCell tag.
|
|
||||||
* Uses a robust approach that handles any LLM provider's wrapper tags
|
|
||||||
* by finding the last valid mxCell ending and checking if suffix is just closing tags.
|
|
||||||
* @param xml - The XML string to check (can be undefined/null)
|
|
||||||
* @returns true if XML appears complete, false if truncated or empty
|
|
||||||
*/
|
|
||||||
export function isMxCellXmlComplete(xml: string | undefined | null): boolean {
|
|
||||||
const trimmed = xml?.trim() || ""
|
|
||||||
if (!trimmed) return false
|
|
||||||
|
|
||||||
// Find position of last complete mxCell ending (either /> or </mxCell>)
|
|
||||||
const lastSelfClose = trimmed.lastIndexOf("/>")
|
|
||||||
const lastMxCellClose = trimmed.lastIndexOf("</mxCell>")
|
|
||||||
|
|
||||||
const lastValidEnd = Math.max(lastSelfClose, lastMxCellClose)
|
|
||||||
|
|
||||||
// No valid ending found at all
|
|
||||||
if (lastValidEnd === -1) return false
|
|
||||||
|
|
||||||
// If the last mxCell has no </mxCell> after it, it must be self-closing.
|
|
||||||
// Otherwise the trailing "/>" belongs to a child such as <mxGeometry .../>
|
|
||||||
// and the output was cut off before the cell was closed.
|
|
||||||
const lastCellStart = trimmed.lastIndexOf("<mxCell")
|
|
||||||
if (
|
|
||||||
lastCellStart > lastMxCellClose &&
|
|
||||||
// (quoted values may hold a raw "<", which the auto-fix escapes)
|
|
||||||
!/^<mxCell\b(?:[^<>"']|"[^"]*"|'[^']*')*\/>/.test(
|
|
||||||
trimmed.slice(lastCellStart),
|
|
||||||
)
|
|
||||||
) {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check what comes after the last valid ending
|
|
||||||
// For />: add 2 chars, for </mxCell>: add 9 chars
|
|
||||||
const endOffset = lastMxCellClose > lastSelfClose ? 9 : 2
|
|
||||||
const suffix = trimmed.slice(lastValidEnd + endOffset)
|
|
||||||
|
|
||||||
// If suffix is empty or only contains closing tags (any provider's wrapper) or whitespace, it's complete
|
|
||||||
// This regex matches any sequence of closing XML tags like </foo>, </bar>, </|DSML|xyz>
|
|
||||||
return /^(\s*<\/[^>]+>)*\s*$/.test(suffix)
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Extract only complete mxCell elements from partial/streaming XML.
|
* Extract only complete mxCell elements from partial/streaming XML.
|
||||||
@@ -89,7 +46,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 +111,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 +177,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 +207,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 +293,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 +323,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")
|
||||||
|
|||||||
+1
-1
@@ -17,7 +17,7 @@ const PARTS: [keyof ChangeSummary, string][] = [
|
|||||||
* singular (...One) and plural (...Other) form; languages without plurals
|
* singular (...One) and plural (...Other) form; languages without plurals
|
||||||
* use the same text for both.
|
* use the same text for both.
|
||||||
*/
|
*/
|
||||||
export function describeChanges(
|
export function describeChangeSummary(
|
||||||
summary: ChangeSummary,
|
summary: ChangeSummary,
|
||||||
t: VersionDict,
|
t: VersionDict,
|
||||||
): string {
|
): string {
|
||||||
|
|||||||
Generated
+147
-1327
File diff suppressed because it is too large
Load Diff
+4
-3
@@ -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",
|
||||||
@@ -110,7 +110,7 @@
|
|||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@anthropic-ai/tokenizer": "^0.0.4",
|
"@anthropic-ai/tokenizer": "^0.0.4",
|
||||||
"@biomejs/biome": "2.5.7",
|
"@biomejs/biome": "2.5.15",
|
||||||
"@playwright/test": "^1.57.0",
|
"@playwright/test": "^1.57.0",
|
||||||
"@tailwindcss/postcss": "^4",
|
"@tailwindcss/postcss": "^4",
|
||||||
"@tailwindcss/typography": "^0.5.19",
|
"@tailwindcss/typography": "^0.5.19",
|
||||||
@@ -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",
|
||||||
@@ -135,6 +135,7 @@
|
|||||||
"husky": "^9.1.7",
|
"husky": "^9.1.7",
|
||||||
"jsdom": "^27.4.0",
|
"jsdom": "^27.4.0",
|
||||||
"lint-staged": "^16.2.7",
|
"lint-staged": "^16.2.7",
|
||||||
|
"postcss": "^8.5.6",
|
||||||
"shx": "^0.4.0",
|
"shx": "^0.4.0",
|
||||||
"tailwindcss": "^4",
|
"tailwindcss": "^4",
|
||||||
"typescript": "^5",
|
"typescript": "^5",
|
||||||
|
|||||||
@@ -24,15 +24,18 @@ claude mcp add drawio -- npx @next-ai-drawio/mcp-server@latest
|
|||||||
|
|
||||||
## Features
|
## Features
|
||||||
|
|
||||||
- **Real-time Preview**: Diagrams appear and update in your browser as Claude creates them
|
- **Real-time Preview**: Diagrams appear and update in your browser as Claude creates them. The preview is the web app's canvas: a change Claude makes to the page on screen is outlined, and one Ctrl+Z takes it back
|
||||||
|
- **Selection**: Select shapes in the preview and say "move these"; Claude reads what you selected with `get_selection`
|
||||||
- **Drawing Rules and Shape Libraries**: Claude gets the web app's layout and style rules and the icon docs for AWS, Azure, GCP, Kubernetes and more
|
- **Drawing Rules and Shape Libraries**: Claude gets the web app's layout and style rules and the icon docs for AWS, Azure, GCP, Kubernetes and more
|
||||||
- **Self-check**: Claude can take a screenshot of the rendered diagram and fix what looks wrong
|
- **Self-check**: Claude can take a screenshot of the rendered diagram and fix what looks wrong
|
||||||
- **Version History**: Restore one of the last 20 versions from the **History** button, shown as thumbnails
|
- **Draw from Your Files**: ask Claude to draw from a document, image or web page; it reads the source with its own tools and draws. Existing .drawio files open with load_diagram
|
||||||
|
- **Version History**: The last 20 versions appear as cards next to the canvas, with thumbnails; restore one, undo the latest, or compare a version with the canvas
|
||||||
- **Natural Language**: Describe diagrams in plain text - flowcharts, architecture diagrams, etc.
|
- **Natural Language**: Describe diagrams in plain text - flowcharts, architecture diagrams, etc.
|
||||||
- **Edit Support**: Modify existing diagrams with natural language instructions, including your own edits in the browser
|
- **Edit Support**: Modify existing diagrams with natural language instructions, including your own edits in the browser
|
||||||
- **Export**: Save diagrams as `.drawio`, `.png`, `.svg`, or `.drawio.svg` files
|
- **Export**: Save diagrams as `.drawio`, `.png`, `.svg`, or `.drawio.svg` files
|
||||||
- **Auto-save**: Each diagram is saved to `~/.next-ai-drawio/`, so `claude --resume` can pick it up again
|
- **Auto-save**: Each diagram is saved to `~/.next-ai-drawio/`, so `claude --resume` can pick it up again
|
||||||
- **Self-contained**: Embedded server, no external dependencies required
|
- **Custom Instructions**: keep your own drawing rules in `~/.next-ai-drawio/instructions.md` (for example "Always draw in minimal style"); they are appended to the drawing guide on every call
|
||||||
|
- **Self-contained**: Embedded server and the draw.io editor in one package; the preview works offline after install
|
||||||
|
|
||||||
## Use Case Examples
|
## Use Case Examples
|
||||||
|
|
||||||
@@ -71,20 +74,35 @@ Create a sequence diagram showing OAuth 2.0 authorization code flow
|
|||||||
between user, client app, auth server, and resource server
|
between user, client app, auth server, and resource server
|
||||||
```
|
```
|
||||||
|
|
||||||
|
### 6. Draw From a Document
|
||||||
|
|
||||||
|
```
|
||||||
|
Read docs/architecture.md and draw the system as a diagram
|
||||||
|
```
|
||||||
|
|
||||||
|
### 7. Recreate a Sketch
|
||||||
|
|
||||||
|
```
|
||||||
|
Recreate the whiteboard photo at ~/Desktop/sketch.jpg as a clean draw.io diagram
|
||||||
|
```
|
||||||
|
|
||||||
## Available Tools
|
## Available Tools
|
||||||
|
|
||||||
| Tool | Description |
|
| Tool | Description |
|
||||||
|------|-------------|
|
|------|-------------|
|
||||||
| `start_session` | Opens browser with real-time diagram preview; the result includes the drawing rules |
|
| `start_session` | Opens browser with real-time diagram preview; the result includes the drawing rules. Pass `session_id` to continue a saved diagram |
|
||||||
|
| `list_saved_diagrams` | List the auto-saved diagrams of earlier sessions, newest first, with their pages |
|
||||||
| `get_drawing_guide` | Return the drawing rules again |
|
| `get_drawing_guide` | Return the drawing rules again |
|
||||||
| `get_shape_library` | Return the shapes and icon styles of a library such as `aws4` |
|
| `get_shape_library` | Return the shapes and icon styles of a library such as `aws4` |
|
||||||
| `create_new_diagram` | Create a new diagram from XML |
|
| `create_new_diagram` | Create a new diagram from XML |
|
||||||
| `load_diagram` | Load a `.drawio` file from disk |
|
| `load_diagram` | Load a `.drawio` or `.drawio.svg` file from disk |
|
||||||
| `edit_diagram` | Edit diagram by ID-based operations; all or nothing |
|
| `edit_diagram` | Edit diagram by ID-based operations; all or nothing |
|
||||||
| `get_diagram` | Get the current diagram XML |
|
| `get_diagram` | Get the current diagram XML |
|
||||||
|
| `get_selection` | Return the shapes and edges you selected in the preview, so Claude can act on "these" |
|
||||||
| `screenshot_diagram` | Return a PNG of a page so Claude can check the result |
|
| `screenshot_diagram` | Return a PNG of a page so Claude can check the result |
|
||||||
| `export_diagram` | Save diagram to a `.drawio`, `.png`, `.svg`, or `.drawio.svg` file |
|
| `export_diagram` | Save diagram to a `.drawio`, `.png`, `.svg`, or `.drawio.svg` file |
|
||||||
| `list_pages`, `add_page`, `rename_page`, `delete_page` | Work with multi-page diagrams |
|
| `list_pages`, `add_page`, `rename_page`, `delete_page` | Work with multi-page diagrams |
|
||||||
|
| `restore_version` | Undo: go back to an earlier version from History (redo is possible too) |
|
||||||
|
|
||||||
## How It Works
|
## How It Works
|
||||||
|
|
||||||
@@ -102,8 +120,13 @@ Claude Code <--stdio--> MCP Server <--http--> Browser (draw.io)
|
|||||||
| Variable | Default | Description |
|
| Variable | Default | Description |
|
||||||
|----------|---------|-------------|
|
|----------|---------|-------------|
|
||||||
| `PORT` | `6002` | Port for the embedded HTTP server |
|
| `PORT` | `6002` | Port for the embedded HTTP server |
|
||||||
| `DRAWIO_BASE_URL` | `https://embed.diagrams.net` | Base URL for draw.io (for self-hosted deployments) |
|
| `DRAWIO_BASE_URL` | unset (the bundled draw.io) | An external draw.io, such as a self-hosted instance. With it Claude's changes are not outlined on the canvas, Ctrl+Z does not take them back and `get_selection` cannot read the selection |
|
||||||
| `DRAWIO_DATA_DIR` | `~/.next-ai-drawio` | Folder for auto-saved diagrams; `off` turns auto-save off |
|
| `DRAWIO_DATA_DIR` | `~/.next-ai-drawio` | Folder for auto-saved diagrams and your `instructions.md`; `off` turns auto-save off (`instructions.md` is then read from the default folder) |
|
||||||
|
| `DRAWIO_LANG` | unset | Language of the draw.io editor, such as `en`, `zh`, `zh-tw`, `ja` or `de`. Unset, draw.io chooses (the browser language on `embed.diagrams.net`, English on a self-hosted draw.io) and the user can change it under **Extras > Language** |
|
||||||
|
| `DRAWIO_UI` | unset | draw.io theme: `kennedy`, `atlas`, `dark`, `min`, `sketch` or `simple`. Unset, the user picks one under **Extras > Theme** |
|
||||||
|
| `DRAWIO_DARK` | `auto` | Dark mode of the preview and the editor until the user switches it with the button at the top right: `auto` follows the system, `1` starts dark, `0` starts light |
|
||||||
|
| `DRAWIO_PREVIEW_UI` | `shell` | The preview page; `classic` opens the previous preview page for one more release |
|
||||||
|
| `DRAWIO_AUTO_SCREENSHOT` | unset | Set to `true` to attach a screenshot to every `create_new_diagram` and `edit_diagram` result (costs 2 to 10 s per call) |
|
||||||
| `DEBUG` | unset | Set to `true` to log debug messages |
|
| `DEBUG` | unset | Set to `true` to log debug messages |
|
||||||
|
|
||||||
## Links
|
## Links
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
MCP (Model Context Protocol) server that enables AI agents like Claude Desktop and Cursor to generate and edit draw.io diagrams with **real-time browser preview**.
|
MCP (Model Context Protocol) server that enables AI agents like Claude Desktop and Cursor to generate and edit draw.io diagrams with **real-time browser preview**.
|
||||||
|
|
||||||
**Self-contained** - includes an embedded HTTP server, no external dependencies required.
|
**Self-contained**: the embedded HTTP server and the draw.io editor are in the package, so after install the preview works offline.
|
||||||
|
|
||||||
## Quick Start
|
## Quick Start
|
||||||
|
|
||||||
@@ -99,45 +99,63 @@ Use the standard MCP configuration with:
|
|||||||
1. Restart your MCP client after updating config
|
1. Restart your MCP client after updating config
|
||||||
2. Ask the AI to create a diagram:
|
2. Ask the AI to create a diagram:
|
||||||
> "Create a flowchart showing user authentication with login, MFA, and session management"
|
> "Create a flowchart showing user authentication with login, MFA, and session management"
|
||||||
|
|
||||||
|
> "Read docs/architecture.md and draw the system as a diagram"
|
||||||
3. The diagram appears in your browser in real-time!
|
3. The diagram appears in your browser in real-time!
|
||||||
|
|
||||||
## Features
|
## Features
|
||||||
|
|
||||||
- **Real-time Preview**: Diagrams appear and update in your browser as the AI creates them
|
- **Real-time Preview**: Diagrams appear and update in your browser as the AI creates them. The preview is the web app's canvas: a change the AI makes to the page on screen is outlined, and one Ctrl+Z takes it back
|
||||||
|
- **Selection**: Select shapes in the preview and say "move these" or "connect this box to that one"; the AI reads what you selected with `get_selection`
|
||||||
- **Drawing Rules**: The AI gets the same layout, edge and style rules as the web app, plus the shape library docs (AWS, Azure, GCP, Kubernetes, Cisco and more), so it uses real icon names instead of guessing
|
- **Drawing Rules**: The AI gets the same layout, edge and style rules as the web app, plus the shape library docs (AWS, Azure, GCP, Kubernetes, Cisco and more), so it uses real icon names instead of guessing
|
||||||
- **Self-check**: The AI can take a screenshot of the rendered diagram and fix overlapping shapes or edges that cross shapes
|
- **Self-check**: The AI can take a screenshot of the rendered diagram (`screenshot: true` on `create_new_diagram` or `edit_diagram`, or `screenshot_diagram`) and fix overlapping shapes or edges that cross shapes
|
||||||
|
- **Draw from Your Files**: ask the AI to draw from a document, image or web page; it reads the source with the host's own tools and draws. Existing .drawio files open with load_diagram
|
||||||
- **Edit Support**: Modify existing diagrams with natural language instructions. If any change in an edit fails, nothing is written and the AI gets the reason and the current page XML
|
- **Edit Support**: Modify existing diagrams with natural language instructions. If any change in an edit fails, nothing is written and the AI gets the reason and the current page XML
|
||||||
- **Your Edits Are Kept**: Changes you make in the browser are read before the AI edits again. If the AI overwrites a change you were still making, your version is saved in History
|
- **Your Edits Are Kept**: Changes you make in the browser are read before the AI edits again. If the AI overwrites a change you were still making, your version is saved in History
|
||||||
- **Version History**: Click **History** at the top right of the preview page to restore one of the last 20 versions, shown as thumbnails
|
- **Version History**: The last 20 versions appear as cards next to the canvas, with a thumbnail and what changed. Restore one, undo and redo the latest, or compare a version with the canvas; or ask the AI to undo (`restore_version`)
|
||||||
- **Download and Export**: Save as `.drawio`, `.png`, `.svg`, or `.drawio.svg` (an SVG with the diagram embedded, which draw.io can open and edit again), from the **Download** button or through `export_diagram`
|
- **Export**: Save as `.drawio`, `.png`, `.svg`, or `.drawio.svg` (an SVG with the diagram embedded, which draw.io and `load_diagram` can open again) through `export_diagram`, or from the download button in the preview's header
|
||||||
- **Multi-page**: List, add, rename, and delete pages, and edit any page
|
- **Multi-page**: List, add, rename, and delete pages, and edit any page
|
||||||
- **Auto-save**: Each session's diagram is saved to `~/.next-ai-drawio/<session-id>.drawio`, so it survives a restart of the MCP client
|
- **Auto-save**: Each session's diagram is saved to `~/.next-ai-drawio/<session-id>.drawio`, and its last 20 versions to `<session-id>.history.json`, so the diagram, History and undo survive a restart of the MCP client
|
||||||
- **Themes and Dark Mode**: Pick a draw.io theme under **Extras > Theme**; the page follows the system dark mode
|
- **Custom Instructions**: keep your own drawing rules in `~/.next-ai-drawio/instructions.md` (for example "Always draw in minimal style"); they are appended to the drawing guide on every call
|
||||||
- **Self-contained**: Embedded server, works offline (except draw.io UI which loads from `embed.diagrams.net` by default, configurable via `DRAWIO_BASE_URL`)
|
- **Themes and Dark Mode**: Pick a draw.io theme under **Extras > Theme**; the button at the top right switches the page and the editor between light and dark (the system setting until you choose). `DRAWIO_UI`, `DRAWIO_DARK` and `DRAWIO_LANG` fix them
|
||||||
|
- **Self-contained**: The draw.io editor ships inside the package and is served from the preview's own origin, so after install the preview works offline. `DRAWIO_BASE_URL` points it at another draw.io instead
|
||||||
|
|
||||||
## Available Tools
|
## Available Tools
|
||||||
|
|
||||||
| Tool | Description |
|
| Tool | Description |
|
||||||
|------|-------------|
|
|------|-------------|
|
||||||
| `start_session` | Opens browser with real-time diagram preview; the result includes the drawing rules |
|
| `start_session` | Opens browser with real-time diagram preview; the result includes the drawing rules. Pass `session_id` to continue a saved diagram |
|
||||||
| `get_drawing_guide` | Return the drawing rules again, for example after a long conversation was compacted |
|
| `list_saved_diagrams` | List the auto-saved diagrams of earlier sessions, newest first, with their pages |
|
||||||
|
| `get_drawing_guide` | Return the drawing rules again, for example after a long conversation was compacted, or, with `topic`, a short XML reference for tables, layers or groups |
|
||||||
| `get_shape_library` | Return the shapes and icon styles of a library such as `aws4`, `azure2`, or `kubernetes` |
|
| `get_shape_library` | Return the shapes and icon styles of a library such as `aws4`, `azure2`, or `kubernetes` |
|
||||||
| `create_new_diagram` | Create a new diagram from XML; a plain list of `mxCell` elements is enough |
|
| `create_new_diagram` | Create a new diagram from XML; a plain list of `mxCell` elements is enough |
|
||||||
| `load_diagram` | Load a `.drawio` file from disk into the session (handles compressed files) |
|
| `load_diagram` | Load a `.drawio` or `.drawio.svg` file into the session, from a `path` on disk or from its `xml` content (handles compressed files) |
|
||||||
| `edit_diagram` | Edit diagram by ID-based operations (update/add/delete cells); all or nothing |
|
| `edit_diagram` | Edit diagram by ID-based operations (update/add/delete cells); all or nothing |
|
||||||
| `get_diagram` | Get the current diagram XML, including your edits in the browser |
|
| `get_diagram` | Get the current diagram XML, including your edits in the browser |
|
||||||
|
| `get_selection` | Return the shapes and edges you selected in the preview (ids, labels, positions), so the AI can act on "these" |
|
||||||
| `screenshot_diagram` | Return a PNG of a page so the AI can check the rendered diagram |
|
| `screenshot_diagram` | Return a PNG of a page so the AI can check the rendered diagram |
|
||||||
| `export_diagram` | Save diagram to a `.drawio`, `.png`, `.svg`, or `.drawio.svg` file |
|
| `export_diagram` | Save diagram to a `.drawio`, `.png`, `.svg`, or `.drawio.svg` file |
|
||||||
| `list_pages` | List every page (tab) with id, name, index, and cell count |
|
| `list_pages` | List every page (tab) with id, name, index, and cell count |
|
||||||
| `add_page` | Append a new page without touching existing ones |
|
| `add_page` | Append a new page without touching existing ones |
|
||||||
| `rename_page` | Rename a page |
|
| `rename_page` | Rename a page |
|
||||||
| `delete_page` | Delete a page (refuses to delete the last one) |
|
| `delete_page` | Delete a page (refuses to delete the last one) |
|
||||||
|
| `restore_version` | Undo: put an earlier version from History back on the canvas; the current one is kept, so a redo is possible |
|
||||||
|
|
||||||
## Continue a Diagram Later
|
## Continue a Diagram Later
|
||||||
|
|
||||||
After every change, the diagram is saved as a normal `.drawio` file in `~/.next-ai-drawio/`, and `start_session` tells the AI the file path. When you resume a conversation after restarting your MCP client (for example `claude --resume`), the AI calls `start_session` and then `load_diagram` with that path. You can also open the file in draw.io yourself.
|
After every change, the diagram is saved as a normal `.drawio` file in `~/.next-ai-drawio/`, named after the session id (for example `mcp-mgd0a1b2-x7k2p1.drawio`). `start_session` tells the AI both the id and the file path.
|
||||||
|
|
||||||
The newest 50 files are kept. Set `DRAWIO_DATA_DIR` to use another folder, or to `off` to turn auto-save off.
|
To continue a diagram in a later conversation (for example after `claude --resume`, or in a new chat), the AI calls `start_session` with `session_id` set to that id: the same preview URL opens, the saved diagram is shown, and auto-save keeps writing to the same file. If the id is no longer in the conversation, `list_saved_diagrams` returns every saved diagram, newest first, with the names and cell counts of its pages, so you can ask for "the architecture diagram from yesterday" and let the AI pick it. `load_diagram` with the file path still works too, and you can open the file in draw.io yourself.
|
||||||
|
|
||||||
|
History comes back with the diagram: the last 20 versions are saved next to it in `<session-id>.history.json` (without thumbnails). When the AI continues the session with `start_session` and its `session_id`, or a preview tab is still open after a restart, the version cards show them again and `restore_version` can undo to them.
|
||||||
|
|
||||||
|
The newest 50 files are kept, each with its History file. Set `DRAWIO_DATA_DIR` to use another folder, or to `off` to turn auto-save off.
|
||||||
|
|
||||||
|
## Custom Instructions
|
||||||
|
|
||||||
|
To give the AI your own drawing rules, write them in `~/.next-ai-drawio/instructions.md` as Markdown, for example "Always draw in minimal style" or "Label every edge". The file is appended to the drawing guide under a `## Custom Instructions` heading each time the guide is returned (`start_session`, `get_drawing_guide` and the `diagram-workflow` prompt), so edits apply without restarting your MCP client. Only the first 5000 characters are used.
|
||||||
|
|
||||||
|
`DRAWIO_DATA_DIR` changes the folder the file is read from; with `DRAWIO_DATA_DIR=off` the default folder is still used, since the file is only read. The text reaches the model as it is, so keep it to drawing rules you trust.
|
||||||
|
|
||||||
## How It Works
|
## How It Works
|
||||||
|
|
||||||
@@ -159,16 +177,24 @@ The newest 50 files are kept. Set `DRAWIO_DATA_DIR` to use another folder, or to
|
|||||||
```
|
```
|
||||||
|
|
||||||
1. **MCP Server** receives tool calls from Claude via stdio
|
1. **MCP Server** receives tool calls from Claude via stdio
|
||||||
2. **Embedded HTTP Server** serves the draw.io UI and handles state
|
2. **Embedded HTTP Server** serves the preview page and the draw.io editor, and handles state
|
||||||
3. **Browser** shows real-time diagram updates via polling
|
3. **Browser** shows real-time diagram updates via polling
|
||||||
|
|
||||||
|
The server listens on `127.0.0.1` only. Every `/api` request must carry a token that the server generates at start and writes into the preview page, so another website open in the same browser cannot read or change the diagram; the preview page also refuses to be embedded in other pages. Opening the preview URL in another browser on the same machine works, since the page comes with the token.
|
||||||
|
|
||||||
## Configuration
|
## Configuration
|
||||||
|
|
||||||
| Variable | Default | Description |
|
| Variable | Default | Description |
|
||||||
|----------|---------|-------------|
|
|----------|---------|-------------|
|
||||||
| `PORT` | `6002` | Port for the embedded HTTP server |
|
| `PORT` | `6002` | Port for the embedded HTTP server |
|
||||||
| `DRAWIO_BASE_URL` | `https://embed.diagrams.net` | Base URL for the draw.io embed. Set this to use a self-hosted draw.io instance for private deployments. |
|
| `DRAWIO_BASE_URL` | unset (the bundled draw.io) | An external draw.io for the preview, such as a self-hosted instance (see below). The page cannot reach into an editor from another origin, so with it the AI's changes are not outlined, Ctrl+Z does not take them back and `get_selection` cannot read the selection. The version cards and everything else keep working. |
|
||||||
| `DRAWIO_DATA_DIR` | `~/.next-ai-drawio` | Folder for the auto-saved `.drawio` files. Set to `off` to turn auto-save off. |
|
| `DRAWIO_DATA_DIR` | `~/.next-ai-drawio` | Folder for the auto-saved `.drawio` files and your `instructions.md`. Set to `off` to turn auto-save off (`instructions.md` is then read from the default folder). |
|
||||||
|
| `DRAWIO_LANG` | unset | Language of the draw.io editor and of the preview's own texts. Unset, the preview's texts follow the browser language, and draw.io chooses: English on the bundled copy until the user picks one under **Extras > Language**, the browser language on `embed.diagrams.net`. A code such as `en`, `zh`, `zh-tw`, `ja` or `de` fixes both and hides that submenu. |
|
||||||
|
| `DRAWIO_UI` | unset | draw.io theme. Unset, the user picks one under **Extras > Theme** and draw.io remembers it. `kennedy`, `atlas`, `dark`, `min`, `sketch` or `simple` fixes the theme and hides that menu. |
|
||||||
|
| `DRAWIO_DARK` | `auto` | Dark mode of the preview and the editor until the user switches it with the button at the top right (the browser remembers that choice): `auto` follows the system, `1` starts dark, `0` starts light. |
|
||||||
|
| `DRAWIO_PREVIEW_UI` | `shell` | The preview page `start_session` opens. `classic` opens the previous preview page (see [Classic preview page](#classic-preview-page)). |
|
||||||
|
| `DRAWIO_AUTO_SCREENSHOT` | unset | Set to `true` to attach a screenshot to every `create_new_diagram` and `edit_diagram` result, so the AI checks each drawing. Costs 2 to 10 s per call; the preview tab must be open and in front. A call can still pass `screenshot: false`. |
|
||||||
|
| `BROWSER` | unset | Set to `none` and `start_session` does not open the preview in the system browser (the convention of Vite and Create React App dev servers); the result still names the URL. |
|
||||||
| `DEBUG` | unset | Set to `true` to log debug messages to stderr. |
|
| `DEBUG` | unset | Set to `true` to log debug messages to stderr. |
|
||||||
|
|
||||||
### Private Deployment (Self-hosted draw.io)
|
### Private Deployment (Self-hosted draw.io)
|
||||||
@@ -197,6 +223,16 @@ docker run -d -p 8080:8080 jgraph/drawio
|
|||||||
|
|
||||||
Then set `DRAWIO_BASE_URL=http://localhost:8080` (or your server's URL). The preview page loads nothing else from the internet, so with a local draw.io it works offline. One exception: shapes from the Material Design library show icons from `fonts.gstatic.com`.
|
Then set `DRAWIO_BASE_URL=http://localhost:8080` (or your server's URL). The preview page loads nothing else from the internet, so with a local draw.io it works offline. One exception: shapes from the Material Design library show icons from `fonts.gstatic.com`.
|
||||||
|
|
||||||
|
With an external draw.io the preview cannot reach into the editor (browsers keep pages and frames from other origins apart), so the AI's changes are not outlined on the canvas, Ctrl+Z does not take them back (use the version cards or `restore_version`), and `get_selection` cannot read what you selected. `start_session` tells the AI about this mode.
|
||||||
|
|
||||||
|
Without `DRAWIO_BASE_URL` the preview uses the trimmed draw.io copy bundled with this package. It has the editor, the shape libraries, the templates, PlantUML and Mermaid; features that load more code when used (the org chart layout, for example) are not included. Set `DRAWIO_BASE_URL` to a full draw.io if you need them.
|
||||||
|
|
||||||
|
Like the web app's bundled copy, it has no image proxy (`/drawio/proxy`): images from other websites, including the avatars in the Org Chart and Mind Map templates and the Arista icons in the network templates, show on the canvas only when online and are left out of PNG and SVG exports and version thumbnails. Insert such images from a file instead, or set `DRAWIO_BASE_URL` to a full draw.io (the `jgraph/drawio` Docker image includes the proxy). See [Offline deployment](https://github.com/DayuanJiang/next-ai-draw-io/blob/main/docs/en/offline-deployment.md).
|
||||||
|
|
||||||
|
### Classic preview page
|
||||||
|
|
||||||
|
`DRAWIO_PREVIEW_UI=classic` opens the preview page of the previous releases: draw.io with a **History** dialog and a **Download** button, without the outline of the AI's changes, Ctrl+Z for them, the version cards, Compare and `get_selection`. It stays for one more release and is then removed.
|
||||||
|
|
||||||
## Troubleshooting
|
## Troubleshooting
|
||||||
|
|
||||||
### Port already in use
|
### Port already in use
|
||||||
@@ -228,6 +264,10 @@ Check that the browser URL has the `?mcp=` query parameter. The MCP session ID c
|
|||||||
|
|
||||||
PNG and SVG files are rendered by draw.io in the preview tab. Browsers slow down tabs that stay in the background, so the tab may not answer in time. Bring the preview tab to the front and try again.
|
PNG and SVG files are rendered by draw.io in the preview tab. Browsers slow down tabs that stay in the background, so the tab may not answer in time. Bring the preview tab to the front and try again.
|
||||||
|
|
||||||
|
### Preview is blank inside VS Code Simple Browser
|
||||||
|
|
||||||
|
The preview page refuses to be embedded in other pages (it sends `Content-Security-Policy: frame-ancestors 'self'`), so in-editor browsers such as VS Code Simple Browser, Cursor's built-in browser or a port forward's "Preview in Editor" show a blank page. Open the Browser URL from the `start_session` result in a regular browser (Chrome, Edge, Firefox, Safari) instead.
|
||||||
|
|
||||||
## License
|
## License
|
||||||
|
|
||||||
Apache-2.0
|
Apache-2.0
|
||||||
|
|||||||
@@ -0,0 +1,40 @@
|
|||||||
|
# Files copied from draw.war into dist/drawio by scripts/fetch-drawio.mjs
|
||||||
|
# (the preview page embeds this copy from its own origin). `*` matches within
|
||||||
|
# a path segment, `**` across segments.
|
||||||
|
#
|
||||||
|
# Always included: the language files, the image icon libraries (loaded one
|
||||||
|
# icon at a time, as diagrams use them), the stylesheets, the licenses of the
|
||||||
|
# icon sets, the templates of Insert > Template (the guard only sees the
|
||||||
|
# previews it scrolled to) and the PlantUML parser of Insert > Advanced.
|
||||||
|
images/osa_drive-harddisk.png
|
||||||
|
img/LICENSE
|
||||||
|
img/lib/**
|
||||||
|
js/plantuml/drawio-plantuml.min.js
|
||||||
|
mxgraph/css/**
|
||||||
|
resources/dia*.txt
|
||||||
|
shapes/LICENSE
|
||||||
|
stencils/LICENSE
|
||||||
|
styles/**
|
||||||
|
templates/**
|
||||||
|
|
||||||
|
# Requested by the editor; rewritten by scripts/check-drawio-files.mjs --update
|
||||||
|
images/droptarget.png
|
||||||
|
images/github-logo.svg
|
||||||
|
images/logo-flat-small.png
|
||||||
|
images/sidebar-general.png
|
||||||
|
images/spin.gif
|
||||||
|
img/clipart/Gear_128x128.png
|
||||||
|
index.html
|
||||||
|
js/PostConfig.js
|
||||||
|
js/PreConfig.js
|
||||||
|
js/app.min.js
|
||||||
|
js/bootstrap.js
|
||||||
|
js/extensions.min.js
|
||||||
|
js/main.js
|
||||||
|
js/open.js
|
||||||
|
js/shapes-14-6-5.min.js
|
||||||
|
js/stencils.min.js
|
||||||
|
math4/es5/drawio-mathjax.min.js
|
||||||
|
mxgraph/images/maximize.gif
|
||||||
|
open.html
|
||||||
|
shortcuts.svg
|
||||||
Generated
+9
-5
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "@next-ai-drawio/mcp-server",
|
"name": "@next-ai-drawio/mcp-server",
|
||||||
"version": "0.3.0",
|
"version": "0.4.0",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "@next-ai-drawio/mcp-server",
|
"name": "@next-ai-drawio/mcp-server",
|
||||||
"version": "0.3.0",
|
"version": "0.4.0",
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@modelcontextprotocol/sdk": "^1.31.0",
|
"@modelcontextprotocol/sdk": "^1.31.0",
|
||||||
@@ -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": {
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@next-ai-drawio/mcp-server",
|
"name": "@next-ai-drawio/mcp-server",
|
||||||
"version": "0.3.0",
|
"version": "0.4.0",
|
||||||
"description": "MCP server for Next AI Draw.io - AI-powered diagram generation with real-time browser preview",
|
"description": "MCP server for Next AI Draw.io - AI-powered diagram generation with real-time browser preview",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"main": "dist/index.js",
|
"main": "dist/index.js",
|
||||||
@@ -8,12 +8,14 @@
|
|||||||
"next-ai-drawio-mcp": "./dist/index.js"
|
"next-ai-drawio-mcp": "./dist/index.js"
|
||||||
},
|
},
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"build": "tsc && node scripts/copy-assets.mjs",
|
"build": "tsc && tsc -p shell && node scripts/copy-assets.mjs && node scripts/fetch-drawio.mjs && node scripts/build-shell.mjs",
|
||||||
|
"check-drawio": "node scripts/check-drawio-files.mjs",
|
||||||
"check-package": "node scripts/check-package.mjs",
|
"check-package": "node scripts/check-package.mjs",
|
||||||
"dev": "tsx watch src/index.ts",
|
"dev": "tsx watch src/index.ts",
|
||||||
"start": "node dist/index.js",
|
"start": "node dist/index.js",
|
||||||
"test": "vitest run",
|
"test": "vitest run",
|
||||||
"test:watch": "vitest",
|
"test:watch": "vitest",
|
||||||
|
"test:e2e": "playwright test",
|
||||||
"prepublishOnly": "npm run build"
|
"prepublishOnly": "npm run build"
|
||||||
},
|
},
|
||||||
"keywords": [
|
"keywords": [
|
||||||
|
|||||||
@@ -0,0 +1,16 @@
|
|||||||
|
import { defineConfig } from "@playwright/test"
|
||||||
|
|
||||||
|
// Browser tests of the built package (npm run build first): each spec
|
||||||
|
// starts the MCP server itself over stdio, so there is no web server here.
|
||||||
|
// Playwright comes from the repository root (npm ci there).
|
||||||
|
export default defineConfig({
|
||||||
|
testDir: "./tests/e2e",
|
||||||
|
timeout: 90_000,
|
||||||
|
forbidOnly: !!process.env.CI,
|
||||||
|
retries: process.env.CI ? 1 : 0,
|
||||||
|
reporter: "list",
|
||||||
|
use: {
|
||||||
|
browserName: "chromium",
|
||||||
|
trace: "retain-on-failure",
|
||||||
|
},
|
||||||
|
})
|
||||||
@@ -0,0 +1,79 @@
|
|||||||
|
// Build the canvas shell (shell/, the web app's canvas without the chat)
|
||||||
|
// into dist/shell: shell.js with esbuild, shell.css with Tailwind, and the
|
||||||
|
// page template. Needs the repository checked out with its root
|
||||||
|
// devDependencies installed (esbuild, Tailwind, React and the shared
|
||||||
|
// components come from there), like copy-assets.mjs needs ../../docs.
|
||||||
|
import { cpSync, mkdirSync, readFileSync, writeFileSync } from "node:fs"
|
||||||
|
import { createRequire } from "node:module"
|
||||||
|
import { dirname, join } from "node:path"
|
||||||
|
import { fileURLToPath } from "node:url"
|
||||||
|
|
||||||
|
const pkg = join(dirname(fileURLToPath(import.meta.url)), "..")
|
||||||
|
const root = join(pkg, "../..")
|
||||||
|
const shell = join(pkg, "shell")
|
||||||
|
const out = join(pkg, "dist/shell")
|
||||||
|
|
||||||
|
// The root's copies: the shell's React must be the one the shared
|
||||||
|
// components import, and the MCP core's pako and saxes should not be
|
||||||
|
// bundled twice (packages/mcp-server has its own node_modules)
|
||||||
|
const rootRequire = createRequire(join(root, "package.json"))
|
||||||
|
const rootPackage = (name) =>
|
||||||
|
dirname(rootRequire.resolve(`${name}/package.json`))
|
||||||
|
const { build } = await import(rootRequire.resolve("esbuild"))
|
||||||
|
const postcss = (await import(rootRequire.resolve("postcss"))).default
|
||||||
|
const tailwindcss = (await import(rootRequire.resolve("@tailwindcss/postcss")))
|
||||||
|
.default
|
||||||
|
|
||||||
|
mkdirSync(out, { recursive: true })
|
||||||
|
|
||||||
|
const result = await build({
|
||||||
|
entryPoints: [join(shell, "main.tsx")],
|
||||||
|
outfile: join(out, "shell.js"),
|
||||||
|
bundle: true,
|
||||||
|
minify: true,
|
||||||
|
format: "esm",
|
||||||
|
target: "es2022",
|
||||||
|
platform: "browser",
|
||||||
|
jsx: "automatic",
|
||||||
|
metafile: true,
|
||||||
|
absWorkingDir: root,
|
||||||
|
alias: {
|
||||||
|
"@": root,
|
||||||
|
pako: rootPackage("pako"),
|
||||||
|
saxes: rootPackage("saxes"),
|
||||||
|
},
|
||||||
|
define: {
|
||||||
|
"process.env.NODE_ENV": '"production"',
|
||||||
|
// The shell has no base path and takes its draw.io from the config
|
||||||
|
// the server writes into the page, not from the web app's build
|
||||||
|
// settings
|
||||||
|
"process.env.NEXT_PUBLIC_BASE_PATH": '""',
|
||||||
|
"process.env.NEXT_PUBLIC_DRAWIO_BASE_URL": '""',
|
||||||
|
},
|
||||||
|
})
|
||||||
|
const js = readFileSync(join(out, "shell.js"), "utf8")
|
||||||
|
// A reference that the defines above missed would throw in the browser
|
||||||
|
// ("process is not defined")
|
||||||
|
const leftover = js.match(/process\.env\.[A-Z_]+/)
|
||||||
|
if (leftover) {
|
||||||
|
throw new Error(`shell.js still reads ${leftover[0]}; define it above`)
|
||||||
|
}
|
||||||
|
|
||||||
|
const cssFile = join(shell, "shell.css")
|
||||||
|
const css = await postcss([
|
||||||
|
// Tailwind scans the shell directory on its own; shell.css names the
|
||||||
|
// shared component directories with @source
|
||||||
|
tailwindcss({ base: shell, optimize: { minify: true } }),
|
||||||
|
]).process(readFileSync(cssFile, "utf8"), {
|
||||||
|
from: cssFile,
|
||||||
|
to: join(out, "shell.css"),
|
||||||
|
})
|
||||||
|
writeFileSync(join(out, "shell.css"), css.css)
|
||||||
|
|
||||||
|
cpSync(join(shell, "index.html"), join(out, "index.html"))
|
||||||
|
|
||||||
|
const kb = (n) => `${(n / 1024).toFixed(0)} KB`
|
||||||
|
const bundled = Object.values(result.metafile.outputs).find((o) => o.entryPoint)
|
||||||
|
console.log(
|
||||||
|
`shell: shell.js ${kb(bundled.bytes)}, shell.css ${kb(Buffer.byteLength(css.css))} -> ${out}`,
|
||||||
|
)
|
||||||
@@ -0,0 +1,533 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
import {
|
||||||
|
existsSync,
|
||||||
|
readdirSync,
|
||||||
|
readFileSync,
|
||||||
|
statSync,
|
||||||
|
writeFileSync,
|
||||||
|
} from "node:fs"
|
||||||
|
/**
|
||||||
|
* Guard for drawio-files.txt: opens the embedded editor from a FULL draw.io
|
||||||
|
* copy in headless Chromium, uses it the way the preview page does (every
|
||||||
|
* theme the user can switch to, diagrams with shapes from the icon libraries,
|
||||||
|
* the format panel, the shape sidebar, the menus, the page tabs, the export
|
||||||
|
* and edit dialogs, exports) and records every file the editor requests.
|
||||||
|
*
|
||||||
|
* node scripts/check-drawio-files.mjs [--drawio <dir>] [--update]
|
||||||
|
*
|
||||||
|
* The full copy is --drawio, else the web app's public/drawio. Without
|
||||||
|
* --update it fails when the editor requested a file the list does not
|
||||||
|
* have; with --update it rewrites the requested section of the list.
|
||||||
|
* Needs Playwright from the repository root (npm ci at the root).
|
||||||
|
*/
|
||||||
|
import http from "node:http"
|
||||||
|
import path from "node:path"
|
||||||
|
import { fileURLToPath, pathToFileURL } from "node:url"
|
||||||
|
import { readDrawioVersion } from "../../../scripts/drawio-zip.mjs"
|
||||||
|
import {
|
||||||
|
formatFileList,
|
||||||
|
parseFileList,
|
||||||
|
readFileList,
|
||||||
|
} from "./drawio-file-list.mjs"
|
||||||
|
|
||||||
|
const PKG = path.join(path.dirname(fileURLToPath(import.meta.url)), "..")
|
||||||
|
const LIST = path.join(PKG, "drawio-files.txt")
|
||||||
|
const args = process.argv.slice(2)
|
||||||
|
const update = args.includes("--update")
|
||||||
|
const drawioDir = path.resolve(
|
||||||
|
args.includes("--drawio")
|
||||||
|
? args[args.indexOf("--drawio") + 1]
|
||||||
|
: path.join(PKG, "../../public/drawio"),
|
||||||
|
)
|
||||||
|
if (!existsSync(path.join(drawioDir, "index.html"))) {
|
||||||
|
console.error(
|
||||||
|
`No draw.io copy at ${drawioDir}. Run "npm run dev" once at the repository root (it downloads one into public/drawio), or pass --drawio <dir>.`,
|
||||||
|
)
|
||||||
|
process.exit(2)
|
||||||
|
}
|
||||||
|
// The list is for the pinned release; the fetch scripts stamp the copy
|
||||||
|
const { version } = readDrawioVersion()
|
||||||
|
const stampFile = path.join(drawioDir, ".version")
|
||||||
|
const stamp = existsSync(stampFile)
|
||||||
|
? readFileSync(stampFile, "utf8").trim()
|
||||||
|
: null
|
||||||
|
if (stamp !== version) {
|
||||||
|
console.error(
|
||||||
|
`${drawioDir} is draw.io ${stamp ?? "(no .version stamp)"}; the package pins ${version}. Delete it and run "npm run dev" at the repository root again, or pass --drawio <dir>.`,
|
||||||
|
)
|
||||||
|
process.exit(2)
|
||||||
|
}
|
||||||
|
const playwrightEntry = path.join(
|
||||||
|
PKG,
|
||||||
|
"../../node_modules/playwright/index.mjs",
|
||||||
|
)
|
||||||
|
if (!existsSync(playwrightEntry)) {
|
||||||
|
console.error("Playwright is missing: run npm ci at the repository root.")
|
||||||
|
process.exit(2)
|
||||||
|
}
|
||||||
|
const { chromium } = await import(pathToFileURL(playwrightEntry).href)
|
||||||
|
|
||||||
|
// The themes a user can switch to under Extras > Theme (themes=1)
|
||||||
|
const THEMES = ["kennedy", "min", "atlas", "dark", "sketch", "simple"]
|
||||||
|
|
||||||
|
const MIME = {
|
||||||
|
".html": "text/html",
|
||||||
|
".js": "text/javascript",
|
||||||
|
".css": "text/css",
|
||||||
|
".svg": "image/svg+xml",
|
||||||
|
".png": "image/png",
|
||||||
|
".gif": "image/gif",
|
||||||
|
".json": "application/json",
|
||||||
|
".xml": "text/xml",
|
||||||
|
".txt": "text/plain",
|
||||||
|
".woff": "font/woff",
|
||||||
|
".woff2": "font/woff2",
|
||||||
|
".ttf": "font/ttf",
|
||||||
|
".ico": "image/x-icon",
|
||||||
|
}
|
||||||
|
|
||||||
|
// The page that embeds the editor, with the preview page's iframe query
|
||||||
|
const parentPage = (ui) => `<!doctype html><html><body style="margin:0">
|
||||||
|
<iframe id="f" style="width:1280px;height:860px;border:0" src="/drawio/index.html?embed=1&proto=json&configure=1&spin=1&libraries=1&noSaveBtn=1&noExitBtn=1&saveAndExit=0&themes=1&dark=0&ui=${ui}"></iframe>
|
||||||
|
<script>
|
||||||
|
const f = document.getElementById('f')
|
||||||
|
window.exports = 0
|
||||||
|
window.addEventListener('message', (e) => {
|
||||||
|
let m; try { m = JSON.parse(e.data) } catch { return }
|
||||||
|
if (m.event === 'configure') f.contentWindow.postMessage(JSON.stringify({ action: 'configure', config: {} }), '*')
|
||||||
|
if (m.event === 'init') window.ready = true
|
||||||
|
if (m.event === 'export') window.exports++
|
||||||
|
})
|
||||||
|
window.send = (msg) => f.contentWindow.postMessage(JSON.stringify(msg), '*')
|
||||||
|
</script></body></html>`
|
||||||
|
|
||||||
|
// Every path the editor asked for, in order; and the ones the full copy lacks
|
||||||
|
const requested = []
|
||||||
|
const notFound = new Set()
|
||||||
|
const server = http.createServer((req, res) => {
|
||||||
|
const url = new URL(req.url, "http://localhost")
|
||||||
|
if (url.pathname === "/") {
|
||||||
|
res.writeHead(200, { "Content-Type": "text/html" })
|
||||||
|
res.end(parentPage(url.searchParams.get("ui") || "kennedy"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const rel = path.posix.normalize(
|
||||||
|
decodeURIComponent(url.pathname.replace(/^\/drawio\//, "")),
|
||||||
|
)
|
||||||
|
const file = path.join(drawioDir, rel)
|
||||||
|
let stat
|
||||||
|
try {
|
||||||
|
stat = statSync(file)
|
||||||
|
} catch {
|
||||||
|
notFound.add(rel)
|
||||||
|
res.writeHead(404)
|
||||||
|
res.end()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (!requested.includes(rel)) requested.push(rel)
|
||||||
|
res.writeHead(200, {
|
||||||
|
"Content-Type": MIME[path.extname(file)] || "application/octet-stream",
|
||||||
|
"Content-Length": stat.size,
|
||||||
|
})
|
||||||
|
res.end(readFileSync(file))
|
||||||
|
})
|
||||||
|
await new Promise((r) => server.listen(0, "127.0.0.1", r))
|
||||||
|
const port = server.address().port
|
||||||
|
|
||||||
|
const mb = (n) => (n / 1048576).toFixed(2)
|
||||||
|
let seen = 0
|
||||||
|
function report(step) {
|
||||||
|
const added = requested.slice(seen)
|
||||||
|
seen = requested.length
|
||||||
|
if (added.length === 0) return
|
||||||
|
const bytes = added.reduce(
|
||||||
|
(n, f) => n + statSync(path.join(drawioDir, f)).size,
|
||||||
|
0,
|
||||||
|
)
|
||||||
|
const names =
|
||||||
|
added.slice(0, 5).join(", ") + (added.length > 5 ? ", ..." : "")
|
||||||
|
console.log(
|
||||||
|
` ${step}: +${added.length} files, +${mb(bytes)} MB [${names}]`,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Diagrams: a plain shape, one shape from each icon library, and the styles
|
||||||
|
// that make draw.io load code on demand (hand-drawn look, math)
|
||||||
|
const cell = (id, style, x) =>
|
||||||
|
`<mxCell id="${id}" value="${id}" style="${style}" vertex="1" parent="1"><mxGeometry x="${x}" y="40" width="60" height="60" as="geometry"/></mxCell>`
|
||||||
|
const doc = (cells, attrs = "") =>
|
||||||
|
`<mxfile><diagram id="p" name="P"><mxGraphModel ${attrs}><root><mxCell id="0"/><mxCell id="1" parent="0"/>${cells}</root></mxGraphModel></diagram></mxfile>`
|
||||||
|
const LIBS = {
|
||||||
|
plain: "rounded=1;whiteSpace=wrap;html=1;",
|
||||||
|
aws4: "shape=mxgraph.aws4.resourceIcon;resIcon=mxgraph.aws4.a1_instance;fillColor=#ED7100;strokeColor=#ffffff;",
|
||||||
|
gcp2: "shape=mxgraph.gcp2.a7_power;fillColor=#4285F4;strokeColor=none;",
|
||||||
|
kubernetes:
|
||||||
|
"shape=mxgraph.kubernetes.icon;prIcon=api;fillColor=#326CE5;strokeColor=none;",
|
||||||
|
cisco19:
|
||||||
|
"shape=mxgraph.cisco19.rect;prIcon=aci;fillColor=#00bceb;strokeColor=none;",
|
||||||
|
flowchart:
|
||||||
|
"shape=mxgraph.flowchart.annotation_1;fillColor=#d5e8d4;strokeColor=#82b366;",
|
||||||
|
webicons:
|
||||||
|
"shape=mxgraph.webicons.adfty;fillColor=#3b5998;strokeColor=none;",
|
||||||
|
bpmn: "shape=mxgraph.bpmn.shape;symbol=message;outline=throwing;",
|
||||||
|
networks:
|
||||||
|
"shape=mxgraph.networks.server;fillColor=#29AAE1;strokeColor=none;",
|
||||||
|
azure2_image:
|
||||||
|
"image;aspect=fixed;image=img/lib/azure2/compute/Virtual_Machine.svg;",
|
||||||
|
sketch: "rounded=1;whiteSpace=wrap;html=1;sketch=1;",
|
||||||
|
}
|
||||||
|
const MATH_DOC = doc(
|
||||||
|
cell("math", "rounded=1;whiteSpace=wrap;html=1;", 40).replace(
|
||||||
|
'value="math"',
|
||||||
|
'value="$$x^2$$"',
|
||||||
|
),
|
||||||
|
'math="1"',
|
||||||
|
)
|
||||||
|
|
||||||
|
// Dialogs and toggles, by draw.io action name (Actions.js); each is closed
|
||||||
|
// again right after it opened. Insert > Template and Insert > Advanced >
|
||||||
|
// PlantUML, which load files when used, are driven below. Mermaid needs no
|
||||||
|
// extra files: its converter is in js/extensions.min.js, which the editor
|
||||||
|
// loads at startup. Left out on purpose, to keep the package small: the org
|
||||||
|
// chart layout (it loads js/orgchart.min.js). Not covered either: images
|
||||||
|
// from other websites in some templates (Org Chart avatars, Arista icons)
|
||||||
|
// and the /drawio/proxy requests draw.io makes for them when exporting;
|
||||||
|
// the copy has no proxy, a known limitation (see the README).
|
||||||
|
const ACTIONS = [
|
||||||
|
"editData",
|
||||||
|
"editDiagram",
|
||||||
|
"editStyle",
|
||||||
|
"editGeometry",
|
||||||
|
"editTooltip",
|
||||||
|
"editLink",
|
||||||
|
"insertImage",
|
||||||
|
"exportPng",
|
||||||
|
"exportSvg",
|
||||||
|
"exportJpg",
|
||||||
|
"exportWebp",
|
||||||
|
"exportPdf",
|
||||||
|
"exportXml",
|
||||||
|
"exportHtml",
|
||||||
|
"exportVsdx",
|
||||||
|
"embedHtml",
|
||||||
|
"embedImage",
|
||||||
|
"embedSvg",
|
||||||
|
"embedIframe",
|
||||||
|
"about",
|
||||||
|
"pageSetup",
|
||||||
|
"print",
|
||||||
|
"findReplace",
|
||||||
|
"tags",
|
||||||
|
"layers",
|
||||||
|
"outline",
|
||||||
|
"shapes",
|
||||||
|
"scratchpad",
|
||||||
|
"plugins",
|
||||||
|
"properties",
|
||||||
|
"fromText",
|
||||||
|
"csv",
|
||||||
|
"runLayout",
|
||||||
|
"insertFreehand",
|
||||||
|
"comments",
|
||||||
|
"customZoom",
|
||||||
|
"renamePage",
|
||||||
|
"format",
|
||||||
|
"format",
|
||||||
|
"toggleShapes",
|
||||||
|
"toggleShapes",
|
||||||
|
"darkMode",
|
||||||
|
"lightMode",
|
||||||
|
"autoMode",
|
||||||
|
"highContrast",
|
||||||
|
"highContrast",
|
||||||
|
"mathematicalTypesetting",
|
||||||
|
"mathematicalTypesetting",
|
||||||
|
"keyboardShortcuts",
|
||||||
|
]
|
||||||
|
// Enough for the other themes, whose dialogs are the same code
|
||||||
|
const LIGHT_ACTIONS = ["editData", "exportPng", "shapes", "format", "format"]
|
||||||
|
|
||||||
|
const wait = (page, ms) => page.waitForTimeout(ms)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Open the editor in one theme and use it. The default theme gets the full
|
||||||
|
* tour; the others the parts that differ by theme (chrome, menus, panels).
|
||||||
|
*/
|
||||||
|
async function drive(browser, ui, full) {
|
||||||
|
console.log(`theme ${ui}${full ? "" : " (light)"}`)
|
||||||
|
const page = await browser.newPage({
|
||||||
|
viewport: { width: 1300, height: 900 },
|
||||||
|
})
|
||||||
|
page.on("popup", (p) => p.close().catch(() => {}))
|
||||||
|
await page.goto(`http://localhost:${port}/?ui=${ui}`)
|
||||||
|
await page.waitForFunction(() => window.ready === true, null, {
|
||||||
|
timeout: 60000,
|
||||||
|
})
|
||||||
|
await wait(page, 2000)
|
||||||
|
report("editor ready")
|
||||||
|
|
||||||
|
const load = (xml) =>
|
||||||
|
page.evaluate(
|
||||||
|
(x) => window.send({ action: "load", xml: x, autosave: 1 }),
|
||||||
|
xml,
|
||||||
|
)
|
||||||
|
let x = 40
|
||||||
|
for (const [name, style] of Object.entries(LIBS)) {
|
||||||
|
await load(doc(cell(name, style, (x += 100))))
|
||||||
|
await wait(page, full ? 1500 : 500)
|
||||||
|
report(`load ${name}`)
|
||||||
|
if (!full) break
|
||||||
|
}
|
||||||
|
if (full) {
|
||||||
|
await load(MATH_DOC)
|
||||||
|
await wait(page, 2500)
|
||||||
|
report("load math")
|
||||||
|
}
|
||||||
|
|
||||||
|
// The editor instance: in embed mode draw.io calls a plugin callback
|
||||||
|
// registered after startup right away
|
||||||
|
const frame = page.frames().find((f) => f.url().includes("/drawio/"))
|
||||||
|
await frame.evaluate(() => {
|
||||||
|
window.Draw.loadPlugin((u) => {
|
||||||
|
window.__ui = u
|
||||||
|
})
|
||||||
|
window.__ui.editor.graph.selectAll()
|
||||||
|
})
|
||||||
|
const closeAll = async () => {
|
||||||
|
await frame.evaluate(() => {
|
||||||
|
const ui = window.__ui
|
||||||
|
while (ui.dialogs && ui.dialogs.length > 0) ui.hideDialog()
|
||||||
|
ui.hideCurrentMenu?.()
|
||||||
|
})
|
||||||
|
await page.keyboard.press("Escape")
|
||||||
|
await wait(page, 100)
|
||||||
|
}
|
||||||
|
|
||||||
|
let opened = 0
|
||||||
|
const actions = full ? ACTIONS : LIGHT_ACTIONS
|
||||||
|
for (const name of actions) {
|
||||||
|
const result = await frame.evaluate((n) => {
|
||||||
|
const ui = window.__ui
|
||||||
|
const action = ui.actions.get(n)
|
||||||
|
if (!action) return "missing"
|
||||||
|
try {
|
||||||
|
action.funct()
|
||||||
|
} catch (e) {
|
||||||
|
return `failed: ${e.message}`
|
||||||
|
}
|
||||||
|
return ui.dialog || ui.dialogs?.length ? "dialog" : "ok"
|
||||||
|
}, name)
|
||||||
|
if (result === "dialog") opened++
|
||||||
|
if (result !== "ok" && result !== "dialog") {
|
||||||
|
console.log(` action ${name}: ${result}`)
|
||||||
|
}
|
||||||
|
await wait(page, 400)
|
||||||
|
report(`action ${name}`)
|
||||||
|
await closeAll()
|
||||||
|
}
|
||||||
|
console.log(` ${opened} of ${actions.length} actions opened a dialog`)
|
||||||
|
|
||||||
|
// File > Open Library from > Browser (libraries=1): the picker is an
|
||||||
|
// iframe on open.html, which loads js/open.js
|
||||||
|
await frame.evaluate(() => window.__ui.pickLibrary(window.App.MODE_BROWSER))
|
||||||
|
await wait(page, 1000)
|
||||||
|
report("library picker")
|
||||||
|
await closeAll()
|
||||||
|
|
||||||
|
// Menus: the visible menubar items, the toolbar buttons that open a menu
|
||||||
|
// in the other themes, and every menu rendered with its submenus
|
||||||
|
const menubarItems = frame.locator(".geMenubar .geItem:visible")
|
||||||
|
const menubarCount = await menubarItems.count()
|
||||||
|
for (let i = 0; i < menubarCount; i++) {
|
||||||
|
await menubarItems
|
||||||
|
.nth(i)
|
||||||
|
.click({ timeout: 2000 })
|
||||||
|
.catch(() => {})
|
||||||
|
await wait(page, 300)
|
||||||
|
await closeAll()
|
||||||
|
}
|
||||||
|
let toolbarCount = 0
|
||||||
|
for (const title of [
|
||||||
|
"Diagram",
|
||||||
|
"Insert",
|
||||||
|
"Shapes",
|
||||||
|
"Pages",
|
||||||
|
"View",
|
||||||
|
"Table",
|
||||||
|
"Layout",
|
||||||
|
]) {
|
||||||
|
const button = frame.locator(`a[title="${title}"]:visible`).first()
|
||||||
|
if ((await button.count()) === 0) continue
|
||||||
|
toolbarCount++
|
||||||
|
await button.click({ timeout: 2000 }).catch(() => {})
|
||||||
|
await wait(page, 300)
|
||||||
|
await closeAll()
|
||||||
|
}
|
||||||
|
const failed = await frame.evaluate(() => {
|
||||||
|
const ui = window.__ui
|
||||||
|
const failed = []
|
||||||
|
for (const name of Object.keys(ui.menus.menus)) {
|
||||||
|
const menu = new window.mxPopupMenu()
|
||||||
|
menu.init()
|
||||||
|
document.body.appendChild(menu.div)
|
||||||
|
try {
|
||||||
|
ui.menus.get(name).funct(menu, null)
|
||||||
|
} catch {
|
||||||
|
failed.push(name)
|
||||||
|
}
|
||||||
|
menu.destroy()
|
||||||
|
}
|
||||||
|
return failed
|
||||||
|
})
|
||||||
|
await wait(page, 500)
|
||||||
|
console.log(
|
||||||
|
` menus: ${menubarCount} menubar items, ${toolbarCount} toolbar menus clicked` +
|
||||||
|
(failed.length ? `; not rendered: ${failed.join(", ")}` : ""),
|
||||||
|
)
|
||||||
|
report("menus")
|
||||||
|
|
||||||
|
// Format panel tabs, with a shape selected
|
||||||
|
await frame.evaluate(() => window.__ui.editor.graph.selectAll())
|
||||||
|
for (const tab of ["Style", "Text", "Arrange", "Diagram"]) {
|
||||||
|
const el = frame
|
||||||
|
.locator(`.geFormatContainer div:text-is("${tab}")`)
|
||||||
|
.first()
|
||||||
|
if ((await el.count()) > 0)
|
||||||
|
await el.click({ timeout: 2000 }).catch(() => {})
|
||||||
|
await wait(page, 200)
|
||||||
|
}
|
||||||
|
report("format panel tabs")
|
||||||
|
|
||||||
|
// Shape sidebar: open every palette, then search
|
||||||
|
await frame.evaluate(() => {
|
||||||
|
const ui = window.__ui
|
||||||
|
for (const title of document.querySelectorAll(
|
||||||
|
".geSidebarContainer .geTitle",
|
||||||
|
)) {
|
||||||
|
title.click()
|
||||||
|
}
|
||||||
|
ui.sidebar.searchShapes?.("server")
|
||||||
|
})
|
||||||
|
await wait(page, full ? 2500 : 1000)
|
||||||
|
report("sidebar palettes and search")
|
||||||
|
|
||||||
|
// Pages: insert, switch, duplicate, remove, and the tab bar
|
||||||
|
for (const name of [
|
||||||
|
"insertPage",
|
||||||
|
"nextPage",
|
||||||
|
"previousPage",
|
||||||
|
"duplicatePage",
|
||||||
|
"removePage",
|
||||||
|
]) {
|
||||||
|
await frame.evaluate((n) => window.__ui.actions.get(n)?.funct(), name)
|
||||||
|
await wait(page, 150)
|
||||||
|
await closeAll()
|
||||||
|
}
|
||||||
|
for (const title of ["Insert Page", "Pages"]) {
|
||||||
|
const el = frame.locator(`a[title="${title}"]:visible`).first()
|
||||||
|
if ((await el.count()) > 0)
|
||||||
|
await el.click({ timeout: 2000 }).catch(() => {})
|
||||||
|
await wait(page, 300)
|
||||||
|
await closeAll()
|
||||||
|
}
|
||||||
|
report("page tabs")
|
||||||
|
|
||||||
|
if (full) {
|
||||||
|
// Insert > Advanced > PlantUML: the parser is loaded on first use
|
||||||
|
await frame.evaluate(() => window.__ui.actions.get("plantUml").funct())
|
||||||
|
await frame
|
||||||
|
.locator(".geDialog textarea")
|
||||||
|
.first()
|
||||||
|
.fill("@startuml\nA -> B\n@enduml")
|
||||||
|
await frame
|
||||||
|
.locator('.geDialog button:text-is("Insert")')
|
||||||
|
.first()
|
||||||
|
.click()
|
||||||
|
await wait(page, 3000)
|
||||||
|
report("plantUml insert")
|
||||||
|
await closeAll()
|
||||||
|
|
||||||
|
// Insert > Template: the index and the previews on screen
|
||||||
|
await frame.evaluate(() =>
|
||||||
|
window.__ui.actions.get("insertTemplate").funct(),
|
||||||
|
)
|
||||||
|
await wait(page, 2000)
|
||||||
|
report("template dialog")
|
||||||
|
await closeAll()
|
||||||
|
|
||||||
|
const formats = ["png", "svg", "xmlsvg", "xml"]
|
||||||
|
for (const [i, format] of formats.entries()) {
|
||||||
|
await page.evaluate(
|
||||||
|
(f) => window.send({ action: "export", format: f, scale: 2 }),
|
||||||
|
format,
|
||||||
|
)
|
||||||
|
await page
|
||||||
|
.waitForFunction((n) => window.exports >= n, i + 1, {
|
||||||
|
timeout: 10000,
|
||||||
|
})
|
||||||
|
.catch(() => {})
|
||||||
|
report(`export ${format}`)
|
||||||
|
}
|
||||||
|
const exports = await page.evaluate(() => window.exports)
|
||||||
|
if (exports < formats.length) {
|
||||||
|
console.error(
|
||||||
|
` only ${exports} of ${formats.length} exports answered`,
|
||||||
|
)
|
||||||
|
process.exitCode = 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
await page.close()
|
||||||
|
}
|
||||||
|
|
||||||
|
const browser = await chromium.launch()
|
||||||
|
try {
|
||||||
|
for (const ui of THEMES) await drive(browser, ui, ui === THEMES[0])
|
||||||
|
} finally {
|
||||||
|
await browser.close()
|
||||||
|
server.close()
|
||||||
|
}
|
||||||
|
|
||||||
|
// A full copy has every file the editor asks for; 404s mean this is not one
|
||||||
|
if (notFound.size > 0) {
|
||||||
|
console.error(
|
||||||
|
`\nThe editor requested ${notFound.size} files that ${drawioDir} does not have (not a full draw.io copy?):\n ${[...notFound].join("\n ")}`,
|
||||||
|
)
|
||||||
|
process.exit(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Compare with the list
|
||||||
|
const list = readFileList(LIST)
|
||||||
|
const missing = requested.filter((f) => !list.matches(f)).sort()
|
||||||
|
|
||||||
|
function* walk(dir) {
|
||||||
|
for (const entry of readdirSync(dir, { withFileTypes: true })) {
|
||||||
|
const full = path.join(dir, entry.name)
|
||||||
|
if (entry.isDirectory()) yield* walk(full)
|
||||||
|
else yield path.relative(drawioDir, full).split(path.sep).join("/")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const summarize = (fileList) => {
|
||||||
|
let count = 0
|
||||||
|
let bytes = 0
|
||||||
|
for (const rel of walk(drawioDir)) {
|
||||||
|
if (!fileList.matches(rel)) continue
|
||||||
|
count++
|
||||||
|
bytes += statSync(path.join(drawioDir, rel)).size
|
||||||
|
}
|
||||||
|
return `${count} files, ${mb(bytes)} MB unpacked`
|
||||||
|
}
|
||||||
|
|
||||||
|
if (update) {
|
||||||
|
// Only the head decides what is already covered by "always include"
|
||||||
|
const always = parseFileList(list.head)
|
||||||
|
const kept = requested.filter((f) => !always.matches(f))
|
||||||
|
writeFileSync(LIST, formatFileList(list.head, kept))
|
||||||
|
console.log(`\nWrote ${LIST}: ${summarize(readFileList(LIST))}`)
|
||||||
|
} else if (missing.length > 0) {
|
||||||
|
console.error(
|
||||||
|
`\nThe editor requested ${missing.length} files that drawio-files.txt does not list:\n ${missing.join("\n ")}\nRun with --update to add them.`,
|
||||||
|
)
|
||||||
|
process.exit(1)
|
||||||
|
} else {
|
||||||
|
console.log(`\ndrawio-files.txt is complete: ${summarize(list)}`)
|
||||||
|
}
|
||||||
@@ -9,7 +9,26 @@ const REQUIRED = [
|
|||||||
"dist/preview/index.html",
|
"dist/preview/index.html",
|
||||||
"dist/preview/preview.css",
|
"dist/preview/preview.css",
|
||||||
"dist/preview/preview.js",
|
"dist/preview/preview.js",
|
||||||
|
"dist/drawio/index.html",
|
||||||
|
"dist/drawio/js/app.min.js",
|
||||||
|
"dist/drawio/LICENSE",
|
||||||
|
// The draw.io version stamp (part of every file's ETag); a dotfile, so
|
||||||
|
// make sure npm keeps it
|
||||||
|
"dist/drawio/.version",
|
||||||
|
"dist/shell/index.html",
|
||||||
|
"dist/shell/shell.js",
|
||||||
|
"dist/shell/shell.css",
|
||||||
]
|
]
|
||||||
|
// The canvas shell (750 KB and 50 KB at version 0.4.0); a jump means a
|
||||||
|
// dependency of the shared components grew the bundle
|
||||||
|
const MAX_FILE_BYTES = {
|
||||||
|
"dist/shell/shell.js": 1.5 * 1024 * 1024,
|
||||||
|
"dist/shell/shell.css": 300 * 1024,
|
||||||
|
}
|
||||||
|
// The bundled draw.io is most of the package (15.3 MB packed with draw.io
|
||||||
|
// v32.0.2 at version 0.4.0); a jump means the file list grew more than
|
||||||
|
// intended
|
||||||
|
const MAX_TARBALL_BYTES = 20 * 1024 * 1024
|
||||||
|
|
||||||
const output = JSON.parse(
|
const output = JSON.parse(
|
||||||
execSync("npm pack --dry-run --json", { encoding: "utf8" }),
|
execSync("npm pack --dry-run --json", { encoding: "utf8" }),
|
||||||
@@ -22,4 +41,23 @@ if (missing.length > 0) {
|
|||||||
console.error(`npm package is missing: ${missing.join(", ")}`)
|
console.error(`npm package is missing: ${missing.join(", ")}`)
|
||||||
process.exit(1)
|
process.exit(1)
|
||||||
}
|
}
|
||||||
console.log(`npm package OK (${files.size} files)`)
|
const mb = (n) => (n / 1048576).toFixed(1)
|
||||||
|
const kb = (n) => (n / 1024).toFixed(0)
|
||||||
|
for (const [path, limit] of Object.entries(MAX_FILE_BYTES)) {
|
||||||
|
const size = pack.files.find((f) => f.path === path).size
|
||||||
|
if (size > limit) {
|
||||||
|
console.error(
|
||||||
|
`${path} is ${kb(size)} KB, over the ${kb(limit)} KB limit`,
|
||||||
|
)
|
||||||
|
process.exit(1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (pack.size > MAX_TARBALL_BYTES) {
|
||||||
|
console.error(
|
||||||
|
`npm package is ${mb(pack.size)} MB packed, over the ${mb(MAX_TARBALL_BYTES)} MB limit`,
|
||||||
|
)
|
||||||
|
process.exit(1)
|
||||||
|
}
|
||||||
|
console.log(
|
||||||
|
`npm package OK (${files.size} files, ${mb(pack.size)} MB packed, ${mb(pack.unpackedSize)} MB unpacked)`,
|
||||||
|
)
|
||||||
|
|||||||
@@ -0,0 +1,78 @@
|
|||||||
|
/**
|
||||||
|
* drawio-files.txt: the files copied from draw.war into dist/drawio.
|
||||||
|
*
|
||||||
|
* One path per line; `*` matches within a path segment and `**` across
|
||||||
|
* segments; `#` starts a comment. Everything above the marker line is kept
|
||||||
|
* as it is ("always include"); the paths below it are what the embedded
|
||||||
|
* editor requested in a guard run, rewritten by check-drawio-files.mjs
|
||||||
|
* --update.
|
||||||
|
*/
|
||||||
|
import fs from "node:fs"
|
||||||
|
|
||||||
|
export const MARKER =
|
||||||
|
"# Requested by the editor; rewritten by scripts/check-drawio-files.mjs --update"
|
||||||
|
|
||||||
|
function toRegExp(pattern) {
|
||||||
|
const escaped = pattern.replace(/[.+^${}()|[\]\\]/g, "\\$&")
|
||||||
|
const source = escaped
|
||||||
|
.replace(/\*\*/g, "\0")
|
||||||
|
.replace(/\*/g, "[^/]*")
|
||||||
|
.replace(/\0/g, ".*")
|
||||||
|
return new RegExp(`^${source}$`)
|
||||||
|
}
|
||||||
|
|
||||||
|
const entries = (text) =>
|
||||||
|
text
|
||||||
|
.split("\n")
|
||||||
|
.map((line) => line.trim())
|
||||||
|
.filter((line) => line && !line.startsWith("#"))
|
||||||
|
|
||||||
|
export function parseFileList(text) {
|
||||||
|
const at = text.indexOf(MARKER)
|
||||||
|
if (at < 0) throw new Error(`drawio-files.txt has no "${MARKER}" line`)
|
||||||
|
const head = text.slice(0, at + MARKER.length)
|
||||||
|
const always = entries(head)
|
||||||
|
const requested = entries(text.slice(at + MARKER.length))
|
||||||
|
|
||||||
|
const exact = new Set()
|
||||||
|
const patterns = []
|
||||||
|
for (const entry of [...always, ...requested]) {
|
||||||
|
if (entry.includes("*")) {
|
||||||
|
patterns.push({ text: entry, re: toRegExp(entry), hit: false })
|
||||||
|
} else {
|
||||||
|
exact.add(entry)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
head,
|
||||||
|
always,
|
||||||
|
requested,
|
||||||
|
/** Whether the list names this file (remembers which patterns hit) */
|
||||||
|
matches(name) {
|
||||||
|
if (exact.has(name)) return true
|
||||||
|
let found = false
|
||||||
|
for (const p of patterns) {
|
||||||
|
if (p.re.test(name)) p.hit = found = true
|
||||||
|
}
|
||||||
|
return found
|
||||||
|
},
|
||||||
|
/** Entries that matched none of `seen` after matches() ran on them */
|
||||||
|
unmatched(seen) {
|
||||||
|
return [
|
||||||
|
...[...exact].filter((name) => !seen.has(name)),
|
||||||
|
...patterns.filter((p) => !p.hit).map((p) => p.text),
|
||||||
|
]
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function readFileList(file) {
|
||||||
|
return parseFileList(fs.readFileSync(file, "utf8"))
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The list text with a new requested section (sorted, unique) */
|
||||||
|
export function formatFileList(head, requested) {
|
||||||
|
const body = [...new Set(requested)].sort().join("\n")
|
||||||
|
return `${head}\n${body}\n`
|
||||||
|
}
|
||||||
@@ -0,0 +1,132 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
/**
|
||||||
|
* Build step: put a trimmed copy of draw.io into dist/drawio, so the preview
|
||||||
|
* page can embed the editor from its own origin (same-origin editor access,
|
||||||
|
* and no internet needed at runtime).
|
||||||
|
*
|
||||||
|
* Downloads the pinned draw.war (see src/drawio-version.json) into a cache
|
||||||
|
* directory (DRAWIO_WAR_CACHE, default <tmpdir>/next-ai-drawio), checks its
|
||||||
|
* SHA-256, and extracts only the files listed in drawio-files.txt. Set
|
||||||
|
* DRAWIO_WAR=<path> to use a local war without downloading. The list is kept
|
||||||
|
* up to date by scripts/check-drawio-files.mjs.
|
||||||
|
*/
|
||||||
|
import fs from "node:fs"
|
||||||
|
import os from "node:os"
|
||||||
|
import path from "node:path"
|
||||||
|
import { fileURLToPath } from "node:url"
|
||||||
|
import {
|
||||||
|
readDrawioVersion,
|
||||||
|
readZipEntries,
|
||||||
|
sha256,
|
||||||
|
} from "../../../scripts/drawio-zip.mjs"
|
||||||
|
import { readFileList } from "./drawio-file-list.mjs"
|
||||||
|
|
||||||
|
const PKG = path.join(path.dirname(fileURLToPath(import.meta.url)), "..")
|
||||||
|
const DEST = path.join(PKG, "dist/drawio")
|
||||||
|
const { version, sha256: expectedSha, downloadUrl } = readDrawioVersion()
|
||||||
|
|
||||||
|
function log(message) {
|
||||||
|
console.log(`[fetch-drawio] ${message}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The draw.war as a buffer, from DRAWIO_WAR, the cache, or GitHub */
|
||||||
|
async function readWar() {
|
||||||
|
const verified = (buf, source) => {
|
||||||
|
const actual = sha256(buf)
|
||||||
|
if (actual !== expectedSha) {
|
||||||
|
throw new Error(
|
||||||
|
`${source} is not draw.io ${version} (SHA-256 ${actual})`,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return buf
|
||||||
|
}
|
||||||
|
|
||||||
|
if (process.env.DRAWIO_WAR) {
|
||||||
|
log(`Using ${process.env.DRAWIO_WAR}`)
|
||||||
|
return verified(fs.readFileSync(process.env.DRAWIO_WAR), "DRAWIO_WAR")
|
||||||
|
}
|
||||||
|
|
||||||
|
const cacheDir =
|
||||||
|
process.env.DRAWIO_WAR_CACHE || path.join(os.tmpdir(), "next-ai-drawio")
|
||||||
|
const cached = path.join(cacheDir, `draw-${version}.war`)
|
||||||
|
if (fs.existsSync(cached)) {
|
||||||
|
try {
|
||||||
|
return verified(fs.readFileSync(cached), cached)
|
||||||
|
} catch (error) {
|
||||||
|
log(`${error.message}; downloading again`)
|
||||||
|
fs.rmSync(cached, { force: true })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
log(`Downloading draw.io ${version} ...`)
|
||||||
|
const res = await fetch(downloadUrl)
|
||||||
|
if (!res.ok) throw new Error(`HTTP ${res.status} for ${downloadUrl}`)
|
||||||
|
const buf = verified(Buffer.from(await res.arrayBuffer()), downloadUrl)
|
||||||
|
fs.mkdirSync(cacheDir, { recursive: true })
|
||||||
|
const tmp = `${cached}.tmp-${process.pid}`
|
||||||
|
fs.writeFileSync(tmp, buf)
|
||||||
|
fs.renameSync(tmp, cached)
|
||||||
|
log(`Cached at ${cached}`)
|
||||||
|
return buf
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Apache-2.0 text with a notice naming draw.io, for dist/drawio/LICENSE */
|
||||||
|
function licenseText(warLicense) {
|
||||||
|
const notice =
|
||||||
|
`The files in this directory are an unmodified subset of draw.io ${version}\n` +
|
||||||
|
"(https://github.com/jgraph/drawio), Copyright (c) JGraph Ltd, redistributed\n" +
|
||||||
|
"under the Apache License, Version 2.0 reproduced below. The icon sets under\n" +
|
||||||
|
"img/lib and the stencils built into the scripts have additional terms, see\n" +
|
||||||
|
"img/LICENSE, shapes/LICENSE and stencils/LICENSE.\n\n"
|
||||||
|
if (warLicense) return notice + warLicense
|
||||||
|
// The standard terms, without this project's own copyright notice
|
||||||
|
const apache = fs.readFileSync(path.join(PKG, "../../LICENSE"), "utf8")
|
||||||
|
const end = "END OF TERMS AND CONDITIONS"
|
||||||
|
return notice + apache.slice(0, apache.indexOf(end) + end.length) + "\n"
|
||||||
|
}
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
const list = readFileList(path.join(PKG, "drawio-files.txt"))
|
||||||
|
const buf = await readWar()
|
||||||
|
|
||||||
|
const tmp = `${DEST}.tmp-${process.pid}`
|
||||||
|
fs.rmSync(tmp, { recursive: true, force: true })
|
||||||
|
const written = new Set()
|
||||||
|
let bytes = 0
|
||||||
|
let warLicense = null
|
||||||
|
for (const { name, data } of readZipEntries(buf)) {
|
||||||
|
// Server-side parts of the war; never shipped, whatever the list says
|
||||||
|
if (/^(WEB-INF|META-INF)\//.test(name)) continue
|
||||||
|
if (name === "LICENSE") warLicense = data.toString("utf8")
|
||||||
|
if (!list.matches(name)) continue
|
||||||
|
const target = path.join(tmp, name)
|
||||||
|
if (!target.startsWith(tmp + path.sep)) {
|
||||||
|
throw new Error(`Unsafe path in archive: ${name}`)
|
||||||
|
}
|
||||||
|
fs.mkdirSync(path.dirname(target), { recursive: true })
|
||||||
|
fs.writeFileSync(target, data)
|
||||||
|
written.add(name)
|
||||||
|
bytes += data.length
|
||||||
|
}
|
||||||
|
|
||||||
|
const missing = list.unmatched(written)
|
||||||
|
if (missing.length > 0) {
|
||||||
|
throw new Error(
|
||||||
|
`drawio-files.txt names files that draw.io ${version} does not have:\n ${missing.join("\n ")}\n` +
|
||||||
|
"Run scripts/check-drawio-files.mjs --update to refresh the list.",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fs.writeFileSync(path.join(tmp, "LICENSE"), licenseText(warLicense))
|
||||||
|
fs.writeFileSync(path.join(tmp, ".version"), `${version}\n`)
|
||||||
|
fs.rmSync(DEST, { recursive: true, force: true })
|
||||||
|
fs.renameSync(tmp, DEST)
|
||||||
|
log(
|
||||||
|
`Installed ${written.size} files (${(bytes / 1048576).toFixed(1)} MB) into dist/drawio`,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
main().catch((error) => {
|
||||||
|
console.error(`[fetch-drawio] ${error.stack || error}`)
|
||||||
|
process.exit(1)
|
||||||
|
})
|
||||||
@@ -0,0 +1,159 @@
|
|||||||
|
import { Download, History, Moon, Sun } from "lucide-react"
|
||||||
|
import { useMemo, useState } from "react"
|
||||||
|
import { CanvasStage } from "@/components/canvas/canvas-stage"
|
||||||
|
import { CompareDialog } from "@/components/canvas/compare-dialog"
|
||||||
|
import { VersionsProvider } from "@/components/canvas/versions-context"
|
||||||
|
import { type ExportFormat, SaveDialog } from "@/components/save-dialog"
|
||||||
|
import { AppToaster } from "@/components/workspace/app-toaster"
|
||||||
|
import { useDiagram } from "@/contexts/diagram-context"
|
||||||
|
import { useDictionary } from "@/hooks/use-dictionary"
|
||||||
|
import type { DrawioSource } from "@/lib/drawio/drawio-config"
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
import { useSettingsStore } from "@/stores/settings-store"
|
||||||
|
import { useNodeVersions } from "./node-versions-source"
|
||||||
|
import type { ShellConfig } from "./runtime-config"
|
||||||
|
import { setDark } from "./theme"
|
||||||
|
import { useMcpSync } from "./use-mcp-sync"
|
||||||
|
import { VersionsPanel } from "./versions-panel"
|
||||||
|
|
||||||
|
const iconButton =
|
||||||
|
"inline-flex size-8 shrink-0 items-center justify-center rounded-lg text-muted-foreground transition-colors hover:bg-accent hover:text-foreground [&_svg]:size-4"
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The MCP preview: the web app's canvas without the chat. A slim bar names
|
||||||
|
* the session and the sync state; the version cards (from the server's
|
||||||
|
* History), Compare and the export dialog come from the shared canvas
|
||||||
|
* components. The cards sit in a side panel, so the web app's versions strip
|
||||||
|
* above the canvas is left out: it would show the same list twice.
|
||||||
|
*/
|
||||||
|
export function CanvasShell({ config }: { config: ShellConfig }) {
|
||||||
|
const dict = useDictionary()
|
||||||
|
const isDark = useSettingsStore((s) => s.isDark)
|
||||||
|
const { saveDiagramToFile, hasPendingLoad } = useDiagram()
|
||||||
|
const { status, sync } = useMcpSync(config)
|
||||||
|
const versions = useNodeVersions(sync)
|
||||||
|
const [panelOpen, setPanelOpen] = useState(true)
|
||||||
|
const [saveOpen, setSaveOpen] = useState(false)
|
||||||
|
const drawioSource = useMemo<DrawioSource>(
|
||||||
|
() => ({
|
||||||
|
editorUrl: config.drawioBaseUrl,
|
||||||
|
ui: config.drawioUi || undefined,
|
||||||
|
lang: config.drawioLang || undefined,
|
||||||
|
// As the classic preview page: the user's own shape libraries
|
||||||
|
libraries: true,
|
||||||
|
}),
|
||||||
|
[config],
|
||||||
|
)
|
||||||
|
const hasVersions = versions.versions.length > 0
|
||||||
|
const showPanel = panelOpen && hasVersions
|
||||||
|
|
||||||
|
// draw.io exports what it shows: while a page export's one-page
|
||||||
|
// projection is on the canvas, or a full load has yet to reach the
|
||||||
|
// editor, that is not the document. Both end within seconds (the
|
||||||
|
// export's deadline is 15 s); the save waits for them, up to 20 s
|
||||||
|
const save = (filename: string, format: ExportFormat, tries = 0) => {
|
||||||
|
const canvasBusy = sync?.read().projectionActive || hasPendingLoad()
|
||||||
|
if (canvasBusy && tries < 66) {
|
||||||
|
setTimeout(() => save(filename, format, tries + 1), 300)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
saveDiagramToFile(
|
||||||
|
filename,
|
||||||
|
format,
|
||||||
|
undefined,
|
||||||
|
dict.save.savedSuccessfully,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<VersionsProvider value={versions}>
|
||||||
|
<div className="flex h-dvh flex-col bg-background text-foreground">
|
||||||
|
<header className="flex h-11 shrink-0 items-center gap-3 border-b border-border bg-surface-0 px-3.5">
|
||||||
|
<span className="text-[13px] font-semibold tracking-[-0.01em]">
|
||||||
|
Next AI Draw.io
|
||||||
|
</span>
|
||||||
|
{config.sessionId && (
|
||||||
|
<span
|
||||||
|
className="rounded-md bg-muted px-1.5 py-0.5 font-mono text-[11px] text-muted-foreground tabular-nums"
|
||||||
|
title={config.sessionId}
|
||||||
|
data-testid="shell-session"
|
||||||
|
>
|
||||||
|
{config.sessionId.slice(-8)}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
<span
|
||||||
|
className="truncate text-xs text-muted-foreground"
|
||||||
|
data-testid="shell-status"
|
||||||
|
data-status={status}
|
||||||
|
>
|
||||||
|
{dict.shell[status]}
|
||||||
|
</span>
|
||||||
|
{hasVersions && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setPanelOpen((open) => !open)}
|
||||||
|
aria-label={
|
||||||
|
panelOpen
|
||||||
|
? dict.shell.hideVersions
|
||||||
|
: dict.shell.showVersions
|
||||||
|
}
|
||||||
|
title={
|
||||||
|
panelOpen
|
||||||
|
? dict.shell.hideVersions
|
||||||
|
: dict.shell.showVersions
|
||||||
|
}
|
||||||
|
aria-pressed={panelOpen}
|
||||||
|
className={cn(
|
||||||
|
iconButton,
|
||||||
|
"ml-auto",
|
||||||
|
panelOpen && "bg-accent text-foreground",
|
||||||
|
)}
|
||||||
|
data-testid="versions-toggle"
|
||||||
|
>
|
||||||
|
<History />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setSaveOpen(true)}
|
||||||
|
aria-label={dict.workspace.export}
|
||||||
|
title={dict.workspace.export}
|
||||||
|
className={cn(iconButton, !hasVersions && "ml-auto")}
|
||||||
|
data-testid="download"
|
||||||
|
>
|
||||||
|
<Download />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setDark(!isDark)}
|
||||||
|
aria-label={dict.shell.toggleTheme}
|
||||||
|
title={dict.shell.toggleTheme}
|
||||||
|
className={iconButton}
|
||||||
|
data-testid="theme-toggle"
|
||||||
|
>
|
||||||
|
{isDark ? <Sun /> : <Moon />}
|
||||||
|
</button>
|
||||||
|
</header>
|
||||||
|
<div className="flex min-h-0 flex-1">
|
||||||
|
<div className="relative min-w-0 flex-1">
|
||||||
|
<CanvasStage
|
||||||
|
className="inset-0"
|
||||||
|
drawioSource={drawioSource}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{showPanel && (
|
||||||
|
<VersionsPanel versions={versions.versions} />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<CompareDialog />
|
||||||
|
<SaveDialog
|
||||||
|
open={saveOpen}
|
||||||
|
onOpenChange={setSaveOpen}
|
||||||
|
onSave={save}
|
||||||
|
defaultFilename={`diagram-${new Date().toLocaleDateString("sv-SE")}`}
|
||||||
|
/>
|
||||||
|
<AppToaster />
|
||||||
|
</div>
|
||||||
|
</VersionsProvider>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
import { i18n, type Locale } from "@/lib/i18n/config"
|
||||||
|
import type { Dictionary } from "@/lib/i18n/dictionaries"
|
||||||
|
import en from "@/lib/i18n/dictionaries/en.json"
|
||||||
|
import ja from "@/lib/i18n/dictionaries/ja.json"
|
||||||
|
import zh from "@/lib/i18n/dictionaries/zh.json"
|
||||||
|
import zhHant from "@/lib/i18n/dictionaries/zh-Hant.json"
|
||||||
|
|
||||||
|
// The web app loads one dictionary on the server (lib/i18n/dictionaries.ts
|
||||||
|
// is server-only); the shell ships all four and picks in the browser
|
||||||
|
const DICTIONARIES: Record<Locale, Dictionary> = {
|
||||||
|
en,
|
||||||
|
zh,
|
||||||
|
ja,
|
||||||
|
"zh-Hant": zhHant,
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Our locale for a language tag ("zh-TW", "ja-JP", "en-US"), or null */
|
||||||
|
export function matchLocale(tag: string): Locale | null {
|
||||||
|
const lower = tag.trim().toLowerCase()
|
||||||
|
if (!lower) return null
|
||||||
|
if (/^zh-(hant|tw|hk|mo)\b/.test(lower)) return "zh-Hant"
|
||||||
|
const base = lower.split("-")[0]
|
||||||
|
if (base === "zh") return "zh"
|
||||||
|
if (base === "ja") return "ja"
|
||||||
|
if (base === "en") return "en"
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The shell's language: the server's DRAWIO_LANG when it is one of ours,
|
||||||
|
* else the browser's first language we have, else English.
|
||||||
|
*/
|
||||||
|
export function pickLocale(
|
||||||
|
configLang: string,
|
||||||
|
browserLangs: readonly string[],
|
||||||
|
): Locale {
|
||||||
|
for (const tag of [configLang, ...browserLangs]) {
|
||||||
|
const locale = matchLocale(tag)
|
||||||
|
if (locale) return locale
|
||||||
|
}
|
||||||
|
return i18n.defaultLocale
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getShellDictionary(locale: Locale): Dictionary {
|
||||||
|
return DICTIONARIES[locale]
|
||||||
|
}
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>Next AI Draw.io</title>
|
||||||
|
<link rel="icon" href="data:,">
|
||||||
|
<link rel="stylesheet" href="/shell/shell.css">
|
||||||
|
<script>window.__MCP_CONFIG__ = {{CONFIG_JSON}};</script>
|
||||||
|
<script>
|
||||||
|
// Dark class before the first paint (no white flash). The same rule
|
||||||
|
// as shell/theme.ts: the choice stored under "mcp:dark-mode", else
|
||||||
|
// the server's DRAWIO_DARK, else the system
|
||||||
|
try {
|
||||||
|
const config = window.__MCP_CONFIG__ || {}
|
||||||
|
const stored = localStorage.getItem("mcp:dark-mode")
|
||||||
|
const dark =
|
||||||
|
stored === "true" ||
|
||||||
|
(stored === null &&
|
||||||
|
(config.drawioDark === "dark" ||
|
||||||
|
(config.drawioDark !== "light" &&
|
||||||
|
matchMedia("(prefers-color-scheme: dark)").matches)))
|
||||||
|
if (dark) document.documentElement.classList.add("dark")
|
||||||
|
} catch {}
|
||||||
|
</script>
|
||||||
|
</head>
|
||||||
|
<body class="antialiased">
|
||||||
|
<div id="root"></div>
|
||||||
|
<script type="module" src="/shell/shell.js"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
// Entry of the MCP's canvas shell, bundled by scripts/build-shell.mjs into
|
||||||
|
// dist/shell/shell.js and served at /shell/ by http-server.ts
|
||||||
|
import { createRoot } from "react-dom/client"
|
||||||
|
import { LocaleProvider } from "@/components/canvas/locale-context"
|
||||||
|
import { DiagramProvider } from "@/contexts/diagram-context"
|
||||||
|
import { DictionaryProvider } from "@/hooks/use-dictionary"
|
||||||
|
import { CanvasShell } from "./canvas-shell"
|
||||||
|
import { getShellDictionary, pickLocale } from "./dictionary"
|
||||||
|
import { pageShellConfig } from "./runtime-config"
|
||||||
|
import { initTheme } from "./theme"
|
||||||
|
|
||||||
|
const config = pageShellConfig()
|
||||||
|
const locale = pickLocale(config.lang, navigator.languages)
|
||||||
|
// The stylesheet picks the CJK fonts by the document's language
|
||||||
|
document.documentElement.lang = locale
|
||||||
|
initTheme(config.drawioDark)
|
||||||
|
|
||||||
|
const root = document.getElementById("root")
|
||||||
|
if (!root) throw new Error("The shell page has no #root element")
|
||||||
|
createRoot(root).render(
|
||||||
|
<DictionaryProvider dictionary={getShellDictionary(locale)}>
|
||||||
|
<LocaleProvider locale={locale}>
|
||||||
|
<DiagramProvider>
|
||||||
|
<CanvasShell config={config} />
|
||||||
|
</DiagramProvider>
|
||||||
|
</LocaleProvider>
|
||||||
|
</DictionaryProvider>,
|
||||||
|
)
|
||||||
@@ -0,0 +1,964 @@
|
|||||||
|
/**
|
||||||
|
* The shell's side of the MCP server's state protocol (src/http-server.ts),
|
||||||
|
* ported from the classic preview page (src/preview/preview.js) without the
|
||||||
|
* page: the server is reached through fetch, the canvas through the
|
||||||
|
* SyncCanvas functions the hook (use-mcp-sync.ts) passes in. No React here,
|
||||||
|
* so the state machine can be driven step by step in tests.
|
||||||
|
*
|
||||||
|
* The protocol in short: the tab polls GET /api/state every 2 s and loads a
|
||||||
|
* newer version; a user edit is pushed with the version and state it is
|
||||||
|
* based on, and the server refuses (409) a push based on a version the AI
|
||||||
|
* has since written over, or on a state it has lost (it expired, the
|
||||||
|
* process restarted); the next poll then decides whose diagram wins
|
||||||
|
* (recoverState). get_diagram asks for a sync (the canvas as draw.io has
|
||||||
|
* it) and screenshots and file exports for an image, both through the poll.
|
||||||
|
*/
|
||||||
|
import { isSameDocument, sameFileVars } from "@/lib/diagram-diff"
|
||||||
|
import { hasLoadOnlySettings } from "@/lib/drawio/editor-bridge"
|
||||||
|
import { contentFingerprint } from "@/packages/mcp-server/src/edit-gate.ts"
|
||||||
|
import {
|
||||||
|
normalizeToMxfile,
|
||||||
|
parseMxfile,
|
||||||
|
serializeMxfile,
|
||||||
|
} from "@/packages/mcp-server/src/pages.ts"
|
||||||
|
import type { SelectionAnswer } from "@/packages/mcp-server/src/selection.ts"
|
||||||
|
|
||||||
|
export type ExportFormat = "png" | "svg" | "xmlsvg"
|
||||||
|
|
||||||
|
/** GET /api/state as the server answers it */
|
||||||
|
export interface ServerState {
|
||||||
|
xml: string | null
|
||||||
|
version: number
|
||||||
|
stateId: string | null
|
||||||
|
blank: boolean
|
||||||
|
syncRequested: boolean
|
||||||
|
exportFormat: ExportFormat | null
|
||||||
|
exportXml: string | null
|
||||||
|
exportOptions: { width?: number; pageId?: string } | null
|
||||||
|
exportId: string | null
|
||||||
|
/** A get_selection request waiting for this tab's answer */
|
||||||
|
selectionId?: string | null
|
||||||
|
/** Changes when History or the entry on the canvas does (the version
|
||||||
|
* cards read History again then); older servers send none */
|
||||||
|
historyKey?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
/** An entry of GET /api/history */
|
||||||
|
export interface HistoryEntryInfo {
|
||||||
|
id: number
|
||||||
|
index: number
|
||||||
|
svg: string
|
||||||
|
xml: string
|
||||||
|
/** Who made it: "user", "recover", "restore"; null for the AI's writes */
|
||||||
|
source: string | null
|
||||||
|
/** The oldest entry with the same content (itself when it is the first) */
|
||||||
|
firstId: number
|
||||||
|
}
|
||||||
|
|
||||||
|
/** GET /api/history as the server answers it, for the state the list is of */
|
||||||
|
export interface HistoryList {
|
||||||
|
entries: HistoryEntryInfo[]
|
||||||
|
stateId: string | null
|
||||||
|
/** The entry whose content the canvas shows (the server's rule), or null */
|
||||||
|
currentId: number | null
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* How a server write goes on the canvas: as one undo step on the page the
|
||||||
|
* user is viewing (the editor highlights it and Ctrl+Z takes it back), or
|
||||||
|
* as a full load of the document (see decideLoad)
|
||||||
|
*/
|
||||||
|
export type LoadDecision =
|
||||||
|
| { mode: "commit"; pageId: string | null }
|
||||||
|
| { mode: "load" }
|
||||||
|
|
||||||
|
export interface ExportRequest {
|
||||||
|
format: string
|
||||||
|
[key: string]: unknown
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ExportResult {
|
||||||
|
data?: string
|
||||||
|
xml?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The canvas as the sync drives it (the hook maps it onto DiagramProvider) */
|
||||||
|
export interface SyncCanvas {
|
||||||
|
load(xml: string, decision: LoadDecision): void
|
||||||
|
/** Show a one-page projection for an export only: not recorded, its
|
||||||
|
* autosaves ignored until the next load */
|
||||||
|
showTransient(xml: string): void
|
||||||
|
/** draw.io's export; null when it does not answer in time */
|
||||||
|
export(
|
||||||
|
request: ExportRequest,
|
||||||
|
timeoutMs: number,
|
||||||
|
): Promise<ExportResult | null>
|
||||||
|
/** The document on the canvas now ("" before the first load) */
|
||||||
|
currentXml(): string
|
||||||
|
/** The page on screen; null when unknown (external draw.io) */
|
||||||
|
currentPageId(): string | null
|
||||||
|
/** A full load was sent and draw.io has not reported it yet: the
|
||||||
|
* editor still shows the previous document */
|
||||||
|
loadPending(): boolean
|
||||||
|
/** The cells selected in the editor, for get_selection; `unavailable`
|
||||||
|
* when the page cannot reach the editor (external draw.io) */
|
||||||
|
readSelection(): SelectionAnswer
|
||||||
|
}
|
||||||
|
|
||||||
|
export type SyncStatus = "waiting" | "connected" | "offline"
|
||||||
|
|
||||||
|
/** Texts shown to the user (the shell takes them from its dictionary) */
|
||||||
|
export type SyncNotice =
|
||||||
|
| "tooLarge"
|
||||||
|
| "unreachable"
|
||||||
|
| "restoredFromFile"
|
||||||
|
| "aiChanged"
|
||||||
|
| "historyChanged"
|
||||||
|
| "restoreFailed"
|
||||||
|
|
||||||
|
export interface SyncOptions {
|
||||||
|
sessionId: string
|
||||||
|
/** Prefix of the server's API paths ("/api") */
|
||||||
|
apiBase: string
|
||||||
|
/** Every request carries it in the X-Drawio-Token header */
|
||||||
|
token: string
|
||||||
|
canvas: SyncCanvas
|
||||||
|
onNotice: (notice: SyncNotice) => void
|
||||||
|
onStatus?: (status: SyncStatus) => void
|
||||||
|
/** The tab is hidden; another tab of this session may be in front */
|
||||||
|
isHidden?: () => boolean
|
||||||
|
/** The server made a new state for the session: History entries got
|
||||||
|
* new ids, so a list on screen is stale */
|
||||||
|
onStateRecreated?: () => void
|
||||||
|
/** The token of the process now answering on this port, read from a
|
||||||
|
* fresh copy of this page; default: tokenFromPage of location.href */
|
||||||
|
refreshToken?: () => Promise<string | null>
|
||||||
|
fetch?: typeof fetch
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface McpSync {
|
||||||
|
/** Poll now and every 2 s */
|
||||||
|
start(): void
|
||||||
|
stop(): void
|
||||||
|
poll(): Promise<void>
|
||||||
|
/** draw.io is ready for exports (DiagramProvider's isDrawioReady) */
|
||||||
|
setReady(ready: boolean): void
|
||||||
|
/** draw.io sent an autosave: the canvas changed to this document */
|
||||||
|
onAutoSave(xml: string): void
|
||||||
|
/** draw.io reported a load done */
|
||||||
|
onDrawioLoad(): void
|
||||||
|
/** The server's History, with the state its ids belong to; null when
|
||||||
|
* it could not be read, or the state changed meanwhile (the version
|
||||||
|
* cards, shell/node-versions-source.ts) */
|
||||||
|
fetchHistory(): Promise<HistoryList | null>
|
||||||
|
/** Put a History entry back, naming the state its list belongs to */
|
||||||
|
restoreEntry(
|
||||||
|
id: number,
|
||||||
|
listStateId: string | null,
|
||||||
|
): Promise<"ok" | "stale" | "failed">
|
||||||
|
/** Called when the server's diagram or History changed (a write
|
||||||
|
* loaded, an edit saved, a thumbnail sent, the state recreated): the
|
||||||
|
* version cards read History again. Returns the unsubscribe */
|
||||||
|
onServerChange(listener: () => void): () => void
|
||||||
|
/** For tests and the status bar */
|
||||||
|
read(): {
|
||||||
|
stateId: string | null
|
||||||
|
currentVersion: number
|
||||||
|
lastXml: string | null
|
||||||
|
latestXml: string | null
|
||||||
|
projectionActive: boolean
|
||||||
|
status: SyncStatus
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const POLL_INTERVAL_MS = 2000
|
||||||
|
|
||||||
|
/** The API token the server wrote into a shell page (window.__MCP_CONFIG__) */
|
||||||
|
export function tokenFromPage(html: string): string | null {
|
||||||
|
const match = html.match(/window\.__MCP_CONFIG__ = (\{.*?\});/)
|
||||||
|
if (!match) return null
|
||||||
|
try {
|
||||||
|
const token = JSON.parse(match[1]).token
|
||||||
|
return typeof token === "string" && token ? token : null
|
||||||
|
} catch {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The same diagram, as text or as documents (draw.io re-serializes what it
|
||||||
|
* loads: another attribute order, filled-in defaults). Only documents with
|
||||||
|
* a page or a model are compared as documents: two without any would
|
||||||
|
* otherwise count as the same.
|
||||||
|
*/
|
||||||
|
function sameDiagram(a: string | null, b: string | null): boolean {
|
||||||
|
if (a === b) return true
|
||||||
|
if (a === null || b === null) return false
|
||||||
|
const hasModel = (xml: string) => /<(mxGraphModel|diagram)[\s>]/.test(xml)
|
||||||
|
return hasModel(a) && hasModel(b) && isSameDocument(a, b)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** SVG as a data URL, as the server stores thumbnails */
|
||||||
|
function svgDataUrl(svg: string): string {
|
||||||
|
if (svg.startsWith("data:")) return svg
|
||||||
|
return `data:image/svg+xml;base64,${btoa(unescape(encodeURIComponent(svg)))}`
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether a server write can go on the canvas as one undo step: it has the
|
||||||
|
* same pages as the canvas, changes only the one the user is viewing (the
|
||||||
|
* one with currentPageId; a one-page document needs no id, several pages
|
||||||
|
* with the page unknown load in full), keeps the file variables and needs
|
||||||
|
* no load-only page setting. Pages are compared with the MCP core's
|
||||||
|
* contentFingerprint (names and cells). The editor bridge checks the same
|
||||||
|
* against the live editor and falls back to a full load on its own.
|
||||||
|
*/
|
||||||
|
export function decideLoad(
|
||||||
|
currentXml: string,
|
||||||
|
nextXml: string,
|
||||||
|
currentPageId: string | null,
|
||||||
|
): LoadDecision {
|
||||||
|
const parse = (xml: string) => {
|
||||||
|
const normalized = normalizeToMxfile(xml)
|
||||||
|
return normalized ? parseMxfile(normalized) : null
|
||||||
|
}
|
||||||
|
const full: LoadDecision = { mode: "load" }
|
||||||
|
const current = parse(currentXml)
|
||||||
|
const next = parse(nextXml)
|
||||||
|
if (!current || !next) return full
|
||||||
|
if (
|
||||||
|
!sameFileVars(
|
||||||
|
current.documentElement.getAttribute("vars"),
|
||||||
|
next.documentElement.getAttribute("vars"),
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
return full
|
||||||
|
}
|
||||||
|
const pagesNow = Array.from(current.querySelectorAll("diagram"))
|
||||||
|
const pagesNext = Array.from(next.querySelectorAll("diagram"))
|
||||||
|
if (pagesNow.length === 0 || pagesNow.length !== pagesNext.length) {
|
||||||
|
return full
|
||||||
|
}
|
||||||
|
let index = pagesNow.findIndex(
|
||||||
|
(page) => page.getAttribute("id") === currentPageId,
|
||||||
|
)
|
||||||
|
if (index < 0) {
|
||||||
|
// The page on screen is unknown (an external draw.io cannot tell):
|
||||||
|
// with several pages the target cannot be told, load in full
|
||||||
|
if (pagesNow.length > 1) return full
|
||||||
|
index = 0
|
||||||
|
}
|
||||||
|
const target = pagesNext[index]
|
||||||
|
// The page is found by its id when the document has several
|
||||||
|
if (
|
||||||
|
pagesNow.length > 1 &&
|
||||||
|
target.getAttribute("id") !== pagesNow[index].getAttribute("id")
|
||||||
|
) {
|
||||||
|
return full
|
||||||
|
}
|
||||||
|
const targetModel = target.querySelector("mxGraphModel")
|
||||||
|
if (targetModel && hasLoadOnlySettings(targetModel)) return full
|
||||||
|
// The other pages, as the MCP core compares them (edit-gate.ts)
|
||||||
|
const others = (doc: Document, pages: Element[]) => {
|
||||||
|
pages[index].remove()
|
||||||
|
return contentFingerprint(serializeMxfile(doc))
|
||||||
|
}
|
||||||
|
if (others(current, pagesNow) !== others(next, pagesNext)) return full
|
||||||
|
return { mode: "commit", pageId: target.getAttribute("id") }
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createMcpSync(options: SyncOptions): McpSync {
|
||||||
|
const { sessionId, apiBase, canvas, onNotice, onStatus } = options
|
||||||
|
const fetchFn = options.fetch ?? ((...args) => globalThis.fetch(...args))
|
||||||
|
let token = options.token
|
||||||
|
|
||||||
|
let currentVersion = 0
|
||||||
|
let isReady = false
|
||||||
|
// What the server has (the text it sent or we pushed) and the newest
|
||||||
|
// diagram on the canvas, saved to the server or not
|
||||||
|
let lastXml: string | null = null
|
||||||
|
let latestXml: string | null = null
|
||||||
|
// The server state this tab is in step with; null until the first poll
|
||||||
|
let stateId: string | null = null
|
||||||
|
let pushFailing = false
|
||||||
|
const pushesInFlight: string[] = []
|
||||||
|
// After a recovery replaced the canvas, until draw.io reports the load:
|
||||||
|
// an autosave still on its way belongs to the canvas being replaced
|
||||||
|
let awaitingLoad = false
|
||||||
|
let awaitingLoadTimer: ReturnType<typeof setTimeout> | null = null
|
||||||
|
// A write taken in place (a commit) makes draw.io autosave its own copy
|
||||||
|
// of it, serialized its way: that one autosave is not an edit
|
||||||
|
let awaitingCommitCopy = false
|
||||||
|
let pollSeq = 0
|
||||||
|
let lastHandledPoll = 0 // polls overlap; older answers are dropped
|
||||||
|
// The edit whose SVG export is pending, and what it was based on
|
||||||
|
let pendingSvgExport: string | null = null
|
||||||
|
// The latest thumbnail export of a loaded server write: the state and
|
||||||
|
// version it showed, and the XML loaded
|
||||||
|
let thumbExport: {
|
||||||
|
stateId: string | null
|
||||||
|
version: number
|
||||||
|
xml: string
|
||||||
|
} | null = null
|
||||||
|
// Its picture was not taken because the user had changed the canvas by
|
||||||
|
// then: taken when the canvas shows the write again (an undo)
|
||||||
|
let thumbMissed = false
|
||||||
|
let pendingMcpExport: ExportFormat | null = null
|
||||||
|
let mcpExportSeq = 0
|
||||||
|
let mcpExportId: string | null = null
|
||||||
|
// A one-page projection is on screen for a page-targeted export
|
||||||
|
let projectionExportActive = false
|
||||||
|
// The restore load of the real document was sent; until draw.io reports
|
||||||
|
// it (or 5 s), the projection still counts as on screen
|
||||||
|
let projectionRestoreTimer: ReturnType<typeof setTimeout> | null = null
|
||||||
|
const endProjection = () => {
|
||||||
|
projectionExportActive = false
|
||||||
|
if (projectionRestoreTimer) clearTimeout(projectionRestoreTimer)
|
||||||
|
projectionRestoreTimer = null
|
||||||
|
}
|
||||||
|
// Load the server state on the next poll even at the same version
|
||||||
|
let forceReload = false
|
||||||
|
let pendingSyncExport = false
|
||||||
|
let syncExportSeq = 0
|
||||||
|
// The selection request answered last: the server shows a request until
|
||||||
|
// the answer arrives, and polls overlap
|
||||||
|
let answeredSelectionId: string | null = null
|
||||||
|
let status: SyncStatus = "waiting"
|
||||||
|
let interval: ReturnType<typeof setInterval> | null = null
|
||||||
|
|
||||||
|
const setStatus = (next: SyncStatus) => {
|
||||||
|
if (status === next) return
|
||||||
|
status = next
|
||||||
|
onStatus?.(next)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Listeners of onServerChange, told once per History key seen (the
|
||||||
|
// server sends it with every state and push answer; a write, a restore
|
||||||
|
// and the first hand edit change it, further hand edits do not) and
|
||||||
|
// whenever History changed without one (a rejected edit kept, a
|
||||||
|
// thumbnail stored)
|
||||||
|
const changeListeners = new Set<() => void>()
|
||||||
|
let notedKey: string | null = null
|
||||||
|
let notedStateId: string | null = null
|
||||||
|
const emitChange = () => {
|
||||||
|
for (const listener of changeListeners) listener()
|
||||||
|
}
|
||||||
|
const noteHistory = (key: string, sid: string | null) => {
|
||||||
|
if (key === notedKey && sid === notedStateId) return
|
||||||
|
notedKey = key
|
||||||
|
notedStateId = sid
|
||||||
|
emitChange()
|
||||||
|
}
|
||||||
|
|
||||||
|
// A 403 means another MCP process (with its own token) now answers on
|
||||||
|
// this port: read its token and retry once, so the next poll can recover
|
||||||
|
// the session instead of being refused forever
|
||||||
|
let tokenRefresh: Promise<void> | null = null
|
||||||
|
const refreshToken = () => {
|
||||||
|
if (!tokenRefresh) {
|
||||||
|
const read =
|
||||||
|
options.refreshToken ??
|
||||||
|
(() =>
|
||||||
|
fetchFn(location.href, { cache: "no-store" })
|
||||||
|
.then((r) => r.text())
|
||||||
|
.then(tokenFromPage))
|
||||||
|
tokenRefresh = read()
|
||||||
|
.then((next) => {
|
||||||
|
if (next) token = next
|
||||||
|
})
|
||||||
|
.catch(() => {})
|
||||||
|
.finally(() => {
|
||||||
|
tokenRefresh = null
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return tokenRefresh
|
||||||
|
}
|
||||||
|
|
||||||
|
const api = async (path: string, init: RequestInit = {}) => {
|
||||||
|
const send = () =>
|
||||||
|
fetchFn(`${apiBase}${path}`, {
|
||||||
|
...init,
|
||||||
|
headers: { ...(init.headers ?? {}), "X-Drawio-Token": token },
|
||||||
|
})
|
||||||
|
let r = await send()
|
||||||
|
if (r.status === 403) {
|
||||||
|
await refreshToken()
|
||||||
|
r = await send()
|
||||||
|
}
|
||||||
|
return r
|
||||||
|
}
|
||||||
|
|
||||||
|
const postJson = (path: string, body: unknown) =>
|
||||||
|
api(path, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify(body),
|
||||||
|
})
|
||||||
|
|
||||||
|
/** Put a server write on the canvas and take its thumbnail */
|
||||||
|
const loadFromServer = (xml: string, how: "decide" | "full") => {
|
||||||
|
lastXml = xml
|
||||||
|
latestXml = xml
|
||||||
|
const before = canvas.currentXml()
|
||||||
|
const decision: LoadDecision =
|
||||||
|
how === "decide"
|
||||||
|
? decideLoad(before, xml, canvas.currentPageId())
|
||||||
|
: { mode: "load" }
|
||||||
|
canvas.load(xml, decision)
|
||||||
|
// draw.io autosaves its copy only when the write changed its
|
||||||
|
// document: after one that left the canvas as it was, the next
|
||||||
|
// autosave is the user's
|
||||||
|
awaitingCommitCopy =
|
||||||
|
decision.mode === "commit" && !sameDiagram(before, xml)
|
||||||
|
// currentVersion is the write's version
|
||||||
|
thumbExport = { stateId, version: currentVersion, xml }
|
||||||
|
thumbMissed = false
|
||||||
|
setTimeout(captureThumbnail, 500)
|
||||||
|
}
|
||||||
|
|
||||||
|
// The image of the server write on the canvas, for its History entry:
|
||||||
|
// only for the latest load, once draw.io is ready, and only if the
|
||||||
|
// canvas still shows it when the export answers
|
||||||
|
const captureThumbnail = () => {
|
||||||
|
const t = thumbExport
|
||||||
|
if (!t || !isReady) return
|
||||||
|
canvas.export({ format: "svg" }, 5000).then((result) => {
|
||||||
|
if (!result?.data || thumbExport !== t) return
|
||||||
|
if (!sameDiagram(latestXml, t.xml)) {
|
||||||
|
thumbMissed = true
|
||||||
|
return
|
||||||
|
}
|
||||||
|
thumbExport = null
|
||||||
|
thumbMissed = false
|
||||||
|
postJson("/history-svg", {
|
||||||
|
sessionId,
|
||||||
|
svg: svgDataUrl(result.data),
|
||||||
|
stateId: t.stateId,
|
||||||
|
version: t.version,
|
||||||
|
})
|
||||||
|
// The write's History entry has its picture now
|
||||||
|
.then(emitChange)
|
||||||
|
.catch(() => {})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Until draw.io reports the load (its messages come in order), an
|
||||||
|
// autosave is from the canvas being replaced; in case no report comes,
|
||||||
|
// not for long
|
||||||
|
const expectLoad = () => {
|
||||||
|
awaitingLoad = true
|
||||||
|
if (awaitingLoadTimer) clearTimeout(awaitingLoadTimer)
|
||||||
|
awaitingLoadTimer = setTimeout(() => {
|
||||||
|
awaitingLoad = false
|
||||||
|
}, 5000)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Restore the user's real document after a page-targeted projection
|
||||||
|
// export by reloading the server state (it also has any autosave that
|
||||||
|
// was still in flight when the projection started). projectionExportActive
|
||||||
|
// stays set until the poll loads the document: an edit on the
|
||||||
|
// projection before that must not be pushed.
|
||||||
|
const restoreFromProjection = () => {
|
||||||
|
if (!projectionExportActive) return
|
||||||
|
forceReload = true
|
||||||
|
poll()
|
||||||
|
}
|
||||||
|
|
||||||
|
// source is "sync" for replies to a server sync request, "recover" for
|
||||||
|
// the tab's copy after the server recovered the session, else "edit".
|
||||||
|
// sid is the server state the push is based on.
|
||||||
|
async function pushState(
|
||||||
|
xml: string,
|
||||||
|
svg = "",
|
||||||
|
baseVersion = currentVersion,
|
||||||
|
source: "edit" | "sync" | "recover" = "edit",
|
||||||
|
sid = stateId,
|
||||||
|
): Promise<void> {
|
||||||
|
if (!sessionId) return
|
||||||
|
pushesInFlight.push(xml)
|
||||||
|
try {
|
||||||
|
const r = await postJson("/state", {
|
||||||
|
sessionId,
|
||||||
|
xml,
|
||||||
|
svg,
|
||||||
|
baseVersion,
|
||||||
|
source,
|
||||||
|
stateId: sid,
|
||||||
|
})
|
||||||
|
pushFailing = false
|
||||||
|
if (r.ok) {
|
||||||
|
const d = await r.json()
|
||||||
|
// An answer about a state this tab has left since, or one
|
||||||
|
// that comes after a newer version was loaded or saved
|
||||||
|
if (sid !== stateId || d.version < currentVersion) return
|
||||||
|
currentVersion = d.version
|
||||||
|
lastXml = xml
|
||||||
|
noteHistory(d.historyKey ?? String(d.version), stateId)
|
||||||
|
// The canvas changed while this edit was on its way, to
|
||||||
|
// something no pending autosave will send (an undo back to
|
||||||
|
// the previous version): send it now. A sync reply is
|
||||||
|
// draw.io's export of the canvas, in another format than its
|
||||||
|
// autosave.
|
||||||
|
if (
|
||||||
|
latestXml &&
|
||||||
|
latestXml !== xml &&
|
||||||
|
pendingSvgExport !== latestXml &&
|
||||||
|
source === "edit"
|
||||||
|
) {
|
||||||
|
pushState(latestXml)
|
||||||
|
}
|
||||||
|
} else if (r.status === 413) {
|
||||||
|
// Over the server's size limit: the image is most of it, so
|
||||||
|
// try once without it
|
||||||
|
if (svg) pushState(xml, "", baseVersion, source, sid)
|
||||||
|
else onNotice("tooLarge")
|
||||||
|
} else if (r.status === 409) {
|
||||||
|
// The AI wrote a newer version, or the server lost the state
|
||||||
|
// this push was based on; the next poll sorts it out
|
||||||
|
const d = await r.json().catch(() => ({}))
|
||||||
|
if (d.savedToHistory) {
|
||||||
|
onNotice(
|
||||||
|
source === "recover" ? "restoredFromFile" : "aiChanged",
|
||||||
|
)
|
||||||
|
// History grew without a new version
|
||||||
|
emitChange()
|
||||||
|
}
|
||||||
|
poll()
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Push failed:", error)
|
||||||
|
setStatus("offline")
|
||||||
|
if (!pushFailing) {
|
||||||
|
pushFailing = true
|
||||||
|
onNotice("unreachable")
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
pushesInFlight.splice(pushesInFlight.indexOf(xml), 1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The server made a new state for this session: it expired, or the MCP
|
||||||
|
// process restarted. Decide whose diagram wins.
|
||||||
|
const recoverState = (s: ServerState) => {
|
||||||
|
stateId = s.stateId
|
||||||
|
options.onStateRecreated?.()
|
||||||
|
// The old state's pending work is gone with it
|
||||||
|
const projectionShown = projectionExportActive
|
||||||
|
endProjection()
|
||||||
|
forceReload = false
|
||||||
|
pendingMcpExport = null
|
||||||
|
pendingSyncExport = false
|
||||||
|
const mine = latestXml
|
||||||
|
currentVersion = s.version
|
||||||
|
if (s.blank || s.xml === lastXml) {
|
||||||
|
// The server knows nothing, or exactly what this tab last saved:
|
||||||
|
// the canvas can only be newer, so it wins (edits made while the
|
||||||
|
// server was down are saved now)
|
||||||
|
if (projectionShown && mine) {
|
||||||
|
canvas.load(mine, { mode: "load" })
|
||||||
|
expectLoad()
|
||||||
|
}
|
||||||
|
if (mine && !sameDiagram(mine, s.xml)) {
|
||||||
|
pushState(mine, "", s.version)
|
||||||
|
} else if (s.xml && !s.blank && mine) {
|
||||||
|
// The canvas still shows the diagram the server recovered:
|
||||||
|
// its History entry came back without a picture (the file
|
||||||
|
// keeps none), take one now
|
||||||
|
thumbExport = { stateId, version: currentVersion, xml: s.xml }
|
||||||
|
thumbMissed = false
|
||||||
|
captureThumbnail()
|
||||||
|
}
|
||||||
|
} else if (s.xml) {
|
||||||
|
// The server has a diagram this tab never showed (an AI write it
|
||||||
|
// missed, a saved file): show that, and keep this tab's copy in
|
||||||
|
// History unless it is the same. A full load, as the classic
|
||||||
|
// page does: whether the canvas can take the write in place is
|
||||||
|
// decided inside DiagramProvider, and the sync cannot tell; until
|
||||||
|
// draw.io reports the load, an autosave is from the canvas being
|
||||||
|
// replaced
|
||||||
|
loadFromServer(s.xml, "full")
|
||||||
|
expectLoad()
|
||||||
|
if (mine && !sameDiagram(mine, s.xml)) {
|
||||||
|
pushState(mine, "", s.version, "recover")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const startSyncExport = () => {
|
||||||
|
pendingSyncExport = true
|
||||||
|
// The version and state the export is taken at: a newer AI write may
|
||||||
|
// load meanwhile, and this older XML must not overwrite it
|
||||||
|
const base = currentVersion
|
||||||
|
const sid = stateId
|
||||||
|
const seq = ++syncExportSeq
|
||||||
|
canvas.export({ format: "xml" }, 5000).then((result) => {
|
||||||
|
// A late reply to an earlier request was taken at another version
|
||||||
|
if (seq !== syncExportSeq) return
|
||||||
|
pendingSyncExport = false
|
||||||
|
if (result?.xml) pushState(result.xml, "", base, "sync", sid)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle an export request from the MCP server (png/svg).
|
||||||
|
//
|
||||||
|
// Plain export: capture whatever page is on screen (PNG: pageId picks a
|
||||||
|
// page, width caps the size).
|
||||||
|
//
|
||||||
|
// Page-targeted export: the server sends a single-page <mxfile>
|
||||||
|
// projection in exportXml. It goes on the canvas as a transient
|
||||||
|
// document, draw.io renders it, exports, then the real document is
|
||||||
|
// loaded back from the server. The session state is never changed.
|
||||||
|
const startMcpExport = (s: ServerState, justLoaded: boolean) => {
|
||||||
|
const format = s.exportFormat as ExportFormat
|
||||||
|
pendingMcpExport = format
|
||||||
|
const seq = ++mcpExportSeq
|
||||||
|
mcpExportId = s.exportId
|
||||||
|
const extra = s.exportOptions ?? {}
|
||||||
|
const fire = () => {
|
||||||
|
const request: ExportRequest =
|
||||||
|
format === "png"
|
||||||
|
? {
|
||||||
|
format: "png",
|
||||||
|
scale: 2,
|
||||||
|
currentPage: !extra.pageId,
|
||||||
|
...extra,
|
||||||
|
}
|
||||||
|
: { format }
|
||||||
|
canvas.export(request, 10000).then((result) => {
|
||||||
|
// A later export is running by now, or this one ended
|
||||||
|
if (seq !== mcpExportSeq || pendingMcpExport === null) return
|
||||||
|
const d = result?.data ?? ""
|
||||||
|
const isPng = format === "png" && d.startsWith("data:image/png")
|
||||||
|
const isSvg =
|
||||||
|
format !== "png" &&
|
||||||
|
(d.startsWith("data:image/svg") || d.startsWith("<svg"))
|
||||||
|
const done = () => {
|
||||||
|
if (seq !== mcpExportSeq) return
|
||||||
|
pendingMcpExport = null
|
||||||
|
// Page-targeted export: restore the user's real document
|
||||||
|
restoreFromProjection()
|
||||||
|
}
|
||||||
|
if (!isPng && !isSvg) {
|
||||||
|
done()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// Keep pendingMcpExport set until the server has the result:
|
||||||
|
// a poll answered before that still sees the request and
|
||||||
|
// would start the same export again
|
||||||
|
postJson("/state", {
|
||||||
|
sessionId,
|
||||||
|
exportData: d,
|
||||||
|
exportId: mcpExportId,
|
||||||
|
})
|
||||||
|
.catch(() => {})
|
||||||
|
.finally(done)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
if (s.exportXml) {
|
||||||
|
projectionExportActive = true
|
||||||
|
canvas.showTransient(s.exportXml)
|
||||||
|
// Let draw.io render the loaded page before exporting
|
||||||
|
setTimeout(fire, 600)
|
||||||
|
} else if (justLoaded) {
|
||||||
|
// A write with a screenshot: give the new diagram's external
|
||||||
|
// icon images a moment to load before the PNG is taken
|
||||||
|
setTimeout(fire, 600)
|
||||||
|
} else {
|
||||||
|
fire()
|
||||||
|
}
|
||||||
|
// The whole export, result delivery included, ends in time: a result
|
||||||
|
// POST that never answers must not keep the projection on screen
|
||||||
|
// past the server's deadline (15 s). Only for this export: a later
|
||||||
|
// one may be running by then. The export is over: its result POST
|
||||||
|
// answering after this must not restore the document a second time.
|
||||||
|
setTimeout(() => {
|
||||||
|
if (pendingMcpExport && seq === mcpExportSeq) {
|
||||||
|
mcpExportSeq++
|
||||||
|
pendingMcpExport = null
|
||||||
|
restoreFromProjection()
|
||||||
|
}
|
||||||
|
}, 15000)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function poll(): Promise<void> {
|
||||||
|
if (!sessionId) return
|
||||||
|
const seq = ++pollSeq
|
||||||
|
try {
|
||||||
|
const r = await api(
|
||||||
|
`/state?sessionId=${encodeURIComponent(sessionId)}`,
|
||||||
|
)
|
||||||
|
if (!r.ok) {
|
||||||
|
// Refused (another process answers on this port and its
|
||||||
|
// token could not be read) or failing: edits are not saved
|
||||||
|
setStatus("offline")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const s: ServerState = await r.json()
|
||||||
|
// An older answer than one already handled (the interval, the
|
||||||
|
// 409 handler and the projection restore each poll): it could
|
||||||
|
// name a state that is gone
|
||||||
|
if (seq < lastHandledPoll) return
|
||||||
|
lastHandledPoll = seq
|
||||||
|
setStatus("connected")
|
||||||
|
if (stateId === null) stateId = s.stateId
|
||||||
|
else if (s.stateId && s.stateId !== stateId) recoverState(s)
|
||||||
|
// Load a new version (before an export, so it pictures the
|
||||||
|
// latest). While a projection is on screen, only the restore
|
||||||
|
// (forceReload) replaces it, so a new version doesn't fight the
|
||||||
|
// projection; currentVersion stays unadvanced until then. The
|
||||||
|
// tab's own push still on its way is not loaded back: the canvas
|
||||||
|
// may have moved on since (an undo), and its answer follows.
|
||||||
|
const ownPush = s.xml !== null && pushesInFlight.includes(s.xml)
|
||||||
|
let justLoaded = false
|
||||||
|
if (
|
||||||
|
(forceReload ||
|
||||||
|
(s.version > currentVersion &&
|
||||||
|
!projectionExportActive &&
|
||||||
|
!ownPush)) &&
|
||||||
|
s.xml
|
||||||
|
) {
|
||||||
|
// The restore after a projection is a full load of the
|
||||||
|
// document the user had
|
||||||
|
const how = forceReload ? "full" : "decide"
|
||||||
|
forceReload = false
|
||||||
|
currentVersion = s.version
|
||||||
|
loadFromServer(s.xml, how)
|
||||||
|
// The projection stays on screen until draw.io reports this
|
||||||
|
// load (its messages come in order): an autosave before that
|
||||||
|
// is of the projection, not an edit
|
||||||
|
if (projectionExportActive && !projectionRestoreTimer) {
|
||||||
|
projectionRestoreTimer = setTimeout(endProjection, 5000)
|
||||||
|
}
|
||||||
|
justLoaded = true
|
||||||
|
}
|
||||||
|
// Also the tab's own push, and a version a projection holds back
|
||||||
|
noteHistory(s.historyKey ?? String(s.version), stateId)
|
||||||
|
// A sync request (get_diagram): after the load above, so draw.io
|
||||||
|
// exports what it just loaded; never while a one-page projection
|
||||||
|
// is on screen, which would be sent as the whole document
|
||||||
|
if (
|
||||||
|
s.syncRequested &&
|
||||||
|
!pendingSyncExport &&
|
||||||
|
isReady &&
|
||||||
|
!projectionExportActive
|
||||||
|
) {
|
||||||
|
startSyncExport()
|
||||||
|
}
|
||||||
|
// Nor an export while the projection is still on screen: it
|
||||||
|
// would picture the projection
|
||||||
|
if (
|
||||||
|
s.exportFormat &&
|
||||||
|
!pendingMcpExport &&
|
||||||
|
isReady &&
|
||||||
|
!projectionExportActive
|
||||||
|
) {
|
||||||
|
startMcpExport(s, justLoaded)
|
||||||
|
}
|
||||||
|
// A selection request (get_selection): the editor's selection
|
||||||
|
// on the page the user is viewing, once draw.io is up (the
|
||||||
|
// bridge attaches to it at its first load), not while a
|
||||||
|
// projection replaces that page, and not while a full load (the
|
||||||
|
// one above, or one still on its way) has yet to reach the
|
||||||
|
// editor: it still shows the previous document
|
||||||
|
if (
|
||||||
|
s.selectionId &&
|
||||||
|
s.selectionId !== answeredSelectionId &&
|
||||||
|
isReady &&
|
||||||
|
!projectionExportActive &&
|
||||||
|
!canvas.loadPending()
|
||||||
|
) {
|
||||||
|
const id = s.selectionId
|
||||||
|
answeredSelectionId = id
|
||||||
|
const answer = () => {
|
||||||
|
// A hidden tab answers later: the canvas may have moved
|
||||||
|
// on since the poll (a projection, a full load, draw.io
|
||||||
|
// starting over). The same conditions as above; when
|
||||||
|
// they no longer hold, the next poll decides again
|
||||||
|
if (
|
||||||
|
!isReady ||
|
||||||
|
projectionExportActive ||
|
||||||
|
canvas.loadPending()
|
||||||
|
) {
|
||||||
|
if (answeredSelectionId === id) {
|
||||||
|
answeredSelectionId = null
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
postJson("/state", {
|
||||||
|
sessionId,
|
||||||
|
selectionId: id,
|
||||||
|
selection: canvas.readSelection(),
|
||||||
|
})
|
||||||
|
.then((r) => {
|
||||||
|
if (!r.ok) throw new Error(String(r.status))
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
// Not delivered: the next poll answers it again
|
||||||
|
if (answeredSelectionId === id) {
|
||||||
|
answeredSelectionId = null
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
// A hidden tab lets a tab in front (the same session open
|
||||||
|
// twice) answer first; the server takes the first answer
|
||||||
|
// and ignores the later one
|
||||||
|
if (options.isHidden?.()) {
|
||||||
|
setTimeout(answer, POLL_INTERVAL_MS + 500)
|
||||||
|
} else {
|
||||||
|
answer()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
setStatus("offline")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
start() {
|
||||||
|
if (!sessionId || interval) return
|
||||||
|
poll()
|
||||||
|
interval = setInterval(poll, POLL_INTERVAL_MS)
|
||||||
|
},
|
||||||
|
stop() {
|
||||||
|
if (interval) clearInterval(interval)
|
||||||
|
interval = null
|
||||||
|
},
|
||||||
|
poll,
|
||||||
|
setReady(ready) {
|
||||||
|
isReady = ready
|
||||||
|
// draw.io is starting over (an external editor reloads on a
|
||||||
|
// theme switch): an export in flight gets no answer, and a
|
||||||
|
// projection on the canvas is gone with the old frame. The next
|
||||||
|
// poll puts the document back and starts the server's export
|
||||||
|
// again
|
||||||
|
if (!ready && pendingMcpExport !== null) {
|
||||||
|
mcpExportSeq++
|
||||||
|
pendingMcpExport = null
|
||||||
|
if (projectionExportActive) forceReload = true
|
||||||
|
}
|
||||||
|
// A write loaded before draw.io was ready gets its thumbnail now
|
||||||
|
if (ready && thumbExport) captureThumbnail()
|
||||||
|
},
|
||||||
|
onAutoSave(xml) {
|
||||||
|
// Ignore autosave while a single-page projection is on screen
|
||||||
|
// for a page-targeted export; otherwise the projection would be
|
||||||
|
// pushed as the session's document
|
||||||
|
if (projectionExportActive) return
|
||||||
|
// An edit of the canvas that recovery is replacing: kept in
|
||||||
|
// History, never over the recovered diagram
|
||||||
|
if (awaitingLoad) {
|
||||||
|
pushState(xml, "", currentVersion, "recover")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// Also an edit undone back to what the server has, or draw.io's
|
||||||
|
// own copy of a write it took in place (a commit). Compared as
|
||||||
|
// documents only for that copy: a change of the paper size alone
|
||||||
|
// is the same document to isSameDocument when the server's copy
|
||||||
|
// names none
|
||||||
|
latestXml = xml
|
||||||
|
const commitCopy = awaitingCommitCopy
|
||||||
|
awaitingCommitCopy = false
|
||||||
|
// The canvas is back at a write whose picture was not taken (an
|
||||||
|
// edit came first): take it now
|
||||||
|
if (
|
||||||
|
thumbMissed &&
|
||||||
|
thumbExport &&
|
||||||
|
sameDiagram(xml, thumbExport.xml)
|
||||||
|
) {
|
||||||
|
thumbMissed = false
|
||||||
|
captureThumbnail()
|
||||||
|
}
|
||||||
|
if (xml === lastXml || (commitCopy && sameDiagram(xml, lastXml))) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// Request an SVG export, then push the edit with it; remember
|
||||||
|
// the version and state it is based on. Without an answer in
|
||||||
|
// time, push it without the image.
|
||||||
|
pendingSvgExport = xml
|
||||||
|
const base = currentVersion
|
||||||
|
const sid = stateId
|
||||||
|
canvas.export({ format: "svg" }, 2000).then((result) => {
|
||||||
|
// A later edit took over the slot, or this one was sent
|
||||||
|
if (pendingSvgExport !== xml) return
|
||||||
|
pendingSvgExport = null
|
||||||
|
const svg = result?.data ? svgDataUrl(result.data) : ""
|
||||||
|
pushState(xml, svg, base, "edit", sid)
|
||||||
|
})
|
||||||
|
},
|
||||||
|
onDrawioLoad() {
|
||||||
|
awaitingLoad = false
|
||||||
|
// A commit that fell back to a full load sends no copy
|
||||||
|
awaitingCommitCopy = false
|
||||||
|
// The restore load after a projection export is done
|
||||||
|
if (projectionRestoreTimer) endProjection()
|
||||||
|
},
|
||||||
|
async fetchHistory() {
|
||||||
|
// A list for a state the server recreated meanwhile has old ids
|
||||||
|
const sid = stateId
|
||||||
|
// A failed read is told again at the next poll: no other event
|
||||||
|
// comes for the same History
|
||||||
|
const failed = () => {
|
||||||
|
notedKey = null
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const r = await api(
|
||||||
|
`/history?sessionId=${encodeURIComponent(sessionId)}`,
|
||||||
|
)
|
||||||
|
if (!r.ok) return failed()
|
||||||
|
const d = await r.json()
|
||||||
|
if (sid !== stateId) return null
|
||||||
|
// A list from a state the poll has not seen yet (another
|
||||||
|
// process answers): its poll tells the cards to read again
|
||||||
|
if (typeof d.stateId === "string" && d.stateId !== sid) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
entries: d.entries ?? [],
|
||||||
|
stateId: sid,
|
||||||
|
currentId:
|
||||||
|
typeof d.currentId === "number" ? d.currentId : null,
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
return failed()
|
||||||
|
}
|
||||||
|
},
|
||||||
|
async restoreEntry(id, listStateId) {
|
||||||
|
try {
|
||||||
|
const r = await postJson("/restore", {
|
||||||
|
sessionId,
|
||||||
|
id,
|
||||||
|
stateId: listStateId,
|
||||||
|
})
|
||||||
|
if (r.ok) {
|
||||||
|
await poll()
|
||||||
|
return "ok"
|
||||||
|
}
|
||||||
|
if (r.status === 409) {
|
||||||
|
onNotice("historyChanged")
|
||||||
|
return "stale"
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// reported below
|
||||||
|
}
|
||||||
|
onNotice("restoreFailed")
|
||||||
|
return "failed"
|
||||||
|
},
|
||||||
|
onServerChange(listener) {
|
||||||
|
changeListeners.add(listener)
|
||||||
|
return () => {
|
||||||
|
changeListeners.delete(listener)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
read() {
|
||||||
|
return {
|
||||||
|
stateId,
|
||||||
|
currentVersion,
|
||||||
|
lastXml,
|
||||||
|
latestXml,
|
||||||
|
projectionActive: projectionExportActive,
|
||||||
|
status,
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,308 @@
|
|||||||
|
import { useCallback, useEffect, useMemo, useRef, useState } from "react"
|
||||||
|
import { toast } from "sonner"
|
||||||
|
import type {
|
||||||
|
VersionItem,
|
||||||
|
VersionsSource,
|
||||||
|
} from "@/components/canvas/versions-context"
|
||||||
|
import { useDictionary } from "@/hooks/use-dictionary"
|
||||||
|
import {
|
||||||
|
type ChangeSummary,
|
||||||
|
diffDiagrams,
|
||||||
|
isSameDocument,
|
||||||
|
} from "@/lib/diagram-diff"
|
||||||
|
import { formatMessage } from "@/lib/i18n/utils"
|
||||||
|
import { contentFingerprint } from "@/packages/mcp-server/src/edit-gate.ts"
|
||||||
|
import { BLANK_MXFILE, hasCells } from "@/packages/mcp-server/src/pages.ts"
|
||||||
|
import { useCanvasStore } from "@/stores/canvas-store"
|
||||||
|
import type { HistoryEntryInfo, HistoryList, McpSync } from "./mcp-sync-core"
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A version card of the shell: one per distinct content of the server's
|
||||||
|
* History, which keeps every state the diagram went through (a restore
|
||||||
|
* adds a copy of an older entry; copies fold into their first entry).
|
||||||
|
*/
|
||||||
|
export interface NodeVersion extends VersionItem {
|
||||||
|
/** The History entry to restore: the newest copy, as restore_version
|
||||||
|
* picks it (a later copy may carry page settings the first lacks) */
|
||||||
|
entryId: number
|
||||||
|
/** The version this one replaced, when it has a card (undo goes there) */
|
||||||
|
beforeId: string | null
|
||||||
|
/** Who made it: "user", "recover", "restore"; null for the AI */
|
||||||
|
source: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
// The blank page a tab shows before any drawing: the server keeps it in
|
||||||
|
// History when the first write replaces it, but it is no version to show.
|
||||||
|
// A blank page after a drawing is the canvas cleared, a version of its own
|
||||||
|
// (restore_version offers both; the shell hides only the first)
|
||||||
|
const BLANK_FINGERPRINT = contentFingerprint(BLANK_MXFILE)
|
||||||
|
const isBlankPage = (xml: string) =>
|
||||||
|
!hasCells(xml) && contentFingerprint(xml) === BLANK_FINGERPRINT
|
||||||
|
|
||||||
|
/** The ids of a document's pages; a bare model has one, without an id */
|
||||||
|
function pageIds(xml: string): (string | undefined)[] {
|
||||||
|
const doc = new DOMParser().parseFromString(xml, "text/xml")
|
||||||
|
const ids = Array.from(doc.querySelectorAll("diagram")).map(
|
||||||
|
(page) => page.getAttribute("id") ?? undefined,
|
||||||
|
)
|
||||||
|
return ids.length > 0 ? ids : [undefined]
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The page a change was made on: the first page with a change, else none */
|
||||||
|
function changedPage(beforeXml: string, afterXml: string): string | undefined {
|
||||||
|
for (const id of pageIds(afterXml)) {
|
||||||
|
const { summary } = diffDiagrams(beforeXml, afterXml, id)
|
||||||
|
if (Object.values(summary).some((n) => n > 0)) return id
|
||||||
|
}
|
||||||
|
return undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
/** What a version changed, kept from its first appearance (see buildVersions) */
|
||||||
|
interface Change {
|
||||||
|
beforeXml: string
|
||||||
|
pageId?: string
|
||||||
|
summary: ChangeSummary
|
||||||
|
fromScratch: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* What buildVersions keeps between calls, keyed by content: a version's
|
||||||
|
* number and change never move, also when its first copy drops out of the
|
||||||
|
* server's buffer and a later copy stands in. Content no entry of the
|
||||||
|
* list has any more is dropped (the server keeps 20 entries; the full
|
||||||
|
* documents in here would otherwise grow with every edit of the session)
|
||||||
|
*/
|
||||||
|
export interface VersionCache {
|
||||||
|
numbers: Map<string, number>
|
||||||
|
changes: Map<string, Change>
|
||||||
|
/** The number the next new version gets */
|
||||||
|
next: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export const newVersionCache = (): VersionCache => ({
|
||||||
|
numbers: new Map(),
|
||||||
|
changes: new Map(),
|
||||||
|
next: 1,
|
||||||
|
})
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The version cards for a History list: entries folded by content, in the
|
||||||
|
* order of their first copy, numbered as they first appeared in this tab,
|
||||||
|
* each with what changed since the state it replaced (the entry right
|
||||||
|
* before its first copy; after a restore that is another than the card
|
||||||
|
* before). The thumbnail is the newest copy's that has one, or an older
|
||||||
|
* copy's of the same document (isSameDocument: a copy with other page
|
||||||
|
* settings looks different); the newest version on the canvas waits for
|
||||||
|
* its picture (the sync sends it), older ones without any show none.
|
||||||
|
*/
|
||||||
|
export function buildVersions(
|
||||||
|
list: HistoryList,
|
||||||
|
cache: VersionCache,
|
||||||
|
): { versions: NodeVersion[]; onCanvasId: string | null } {
|
||||||
|
// The server folds entries by content (firstId). A blank page after a
|
||||||
|
// drawing is a clear of the canvas, a version of its own, which the
|
||||||
|
// server folds into the blank page the tab started with: blanks are
|
||||||
|
// grouped here, a restore's copy of a clear with the newest clear
|
||||||
|
const groups = new Map<number, HistoryEntryInfo[]>()
|
||||||
|
const keys = new Map<number, number>() // entry id -> its group
|
||||||
|
const befores = new Map<number, HistoryEntryInfo>() // group -> entry before it
|
||||||
|
const hidden = new Set<number>() // the blank page(s) before any drawing
|
||||||
|
let drawn = false
|
||||||
|
let lastClear: number | null = null
|
||||||
|
list.entries.forEach((entry, i) => {
|
||||||
|
let key = entry.firstId
|
||||||
|
if (!isBlankPage(entry.xml)) {
|
||||||
|
drawn = true
|
||||||
|
} else if (drawn || cache.numbers.has(`clear:${entry.id}`)) {
|
||||||
|
// A clear this tab has shown stays one when the drawing before
|
||||||
|
// it dropped out of the server's buffer
|
||||||
|
drawn = true
|
||||||
|
key =
|
||||||
|
entry.source === "restore" && lastClear !== null
|
||||||
|
? lastClear
|
||||||
|
: entry.id
|
||||||
|
lastClear = key
|
||||||
|
} else {
|
||||||
|
hidden.add(key)
|
||||||
|
}
|
||||||
|
keys.set(entry.id, key)
|
||||||
|
const copies = groups.get(key)
|
||||||
|
if (copies) copies.push(entry)
|
||||||
|
else {
|
||||||
|
groups.set(key, [entry])
|
||||||
|
if (i > 0) befores.set(key, list.entries[i - 1])
|
||||||
|
}
|
||||||
|
})
|
||||||
|
const onCanvasId =
|
||||||
|
list.currentId !== null && keys.has(list.currentId)
|
||||||
|
? String(keys.get(list.currentId))
|
||||||
|
: null
|
||||||
|
const versions: NodeVersion[] = []
|
||||||
|
const seen = new Set<string>() // the content keys of this list
|
||||||
|
for (const [key, copies] of groups) {
|
||||||
|
if (hidden.has(key)) continue
|
||||||
|
const first = copies[0]
|
||||||
|
const newest = copies[copies.length - 1]
|
||||||
|
const previous = befores.get(key) ?? null
|
||||||
|
// Clears have one content: each is its own version
|
||||||
|
const contentKey = isBlankPage(first.xml)
|
||||||
|
? `clear:${key}`
|
||||||
|
: contentFingerprint(first.xml)
|
||||||
|
seen.add(contentKey)
|
||||||
|
let number = cache.numbers.get(contentKey)
|
||||||
|
if (number === undefined) {
|
||||||
|
number = cache.next++
|
||||||
|
cache.numbers.set(contentKey, number)
|
||||||
|
}
|
||||||
|
let change = cache.changes.get(contentKey)
|
||||||
|
if (!change) {
|
||||||
|
const beforeXml = previous?.xml ?? ""
|
||||||
|
const pageId = changedPage(beforeXml, first.xml)
|
||||||
|
const { summary, fromScratch } = diffDiagrams(
|
||||||
|
beforeXml,
|
||||||
|
first.xml,
|
||||||
|
pageId,
|
||||||
|
)
|
||||||
|
change = { beforeXml, pageId, summary, fromScratch }
|
||||||
|
cache.changes.set(contentKey, change)
|
||||||
|
}
|
||||||
|
const svg = [...copies]
|
||||||
|
.reverse()
|
||||||
|
.find((c) => c.svg && isSameDocument(c.xml, newest.xml))?.svg
|
||||||
|
const beforeKey = previous ? keys.get(previous.id) : undefined
|
||||||
|
versions.push({
|
||||||
|
id: String(key),
|
||||||
|
entryId: newest.id,
|
||||||
|
beforeId:
|
||||||
|
beforeKey !== undefined && !hidden.has(beforeKey)
|
||||||
|
? String(beforeKey)
|
||||||
|
: null,
|
||||||
|
number,
|
||||||
|
xml: newest.xml,
|
||||||
|
beforeXml: change.beforeXml,
|
||||||
|
...(change.pageId && { pageId: change.pageId }),
|
||||||
|
summary: change.summary,
|
||||||
|
fromScratch: change.fromScratch,
|
||||||
|
svg: svg || "",
|
||||||
|
source: first.source,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
// The newest version on the canvas waits for its picture
|
||||||
|
const latest = versions.at(-1)
|
||||||
|
if (latest && !latest.svg && latest.id === onCanvasId) {
|
||||||
|
latest.svg = undefined
|
||||||
|
}
|
||||||
|
for (const key of cache.numbers.keys()) {
|
||||||
|
if (!seen.has(key)) cache.numbers.delete(key)
|
||||||
|
}
|
||||||
|
for (const key of cache.changes.keys()) {
|
||||||
|
if (!seen.has(key)) cache.changes.delete(key)
|
||||||
|
}
|
||||||
|
return { versions, onCanvasId }
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The versions source with the shell's own version type */
|
||||||
|
export interface NodeVersionsSource extends Omit<VersionsSource, "versions"> {
|
||||||
|
versions: NodeVersion[]
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The canvas components' versions, from the MCP server's History: read
|
||||||
|
* again whenever the sync reports a change on the server, restored through
|
||||||
|
* it. Undo and redo of the newest version restore the one it replaced, or
|
||||||
|
* it again; the server keeps the state they leave as an entry of its own,
|
||||||
|
* so hand edits are never lost.
|
||||||
|
*/
|
||||||
|
export function useNodeVersions(sync: McpSync | null): NodeVersionsSource {
|
||||||
|
const dict = useDictionary()
|
||||||
|
const [list, setList] = useState<HistoryList | null>(null)
|
||||||
|
const [busy, setBusy] = useState(false)
|
||||||
|
// Stable across refreshes: a version's number and change never move
|
||||||
|
const cacheRef = useRef(newVersionCache())
|
||||||
|
// The server state the cards are of: another one (the session expired,
|
||||||
|
// the process restarted) is a History of its own, numbered anew
|
||||||
|
const cacheStateIdRef = useRef<string | null>(null)
|
||||||
|
// Reads overlap when changes come fast; only the latest one counts
|
||||||
|
const readSeqRef = useRef(0)
|
||||||
|
|
||||||
|
const refresh = useCallback(async () => {
|
||||||
|
if (!sync) return
|
||||||
|
const seq = ++readSeqRef.current
|
||||||
|
const next = await sync.fetchHistory()
|
||||||
|
if (!next || seq !== readSeqRef.current) return
|
||||||
|
if (next.stateId !== cacheStateIdRef.current) {
|
||||||
|
cacheRef.current = newVersionCache()
|
||||||
|
cacheStateIdRef.current = next.stateId
|
||||||
|
}
|
||||||
|
setList(next)
|
||||||
|
}, [sync])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!sync) return
|
||||||
|
refresh()
|
||||||
|
return sync.onServerChange(refresh)
|
||||||
|
}, [sync, refresh])
|
||||||
|
|
||||||
|
const { versions, onCanvasId } = useMemo(
|
||||||
|
() =>
|
||||||
|
list
|
||||||
|
? buildVersions(list, cacheRef.current)
|
||||||
|
: { versions: [], onCanvasId: null },
|
||||||
|
[list],
|
||||||
|
)
|
||||||
|
|
||||||
|
const restore = async (id: string) => {
|
||||||
|
if (!sync || !list || busy) return
|
||||||
|
const version = versions.find((v) => v.id === id)
|
||||||
|
if (!version) return
|
||||||
|
setBusy(true)
|
||||||
|
// The canvas components wait (SelectionAsk, the cards' buttons)
|
||||||
|
useCanvasStore.getState().set({ isBusy: true })
|
||||||
|
try {
|
||||||
|
const result = await sync.restoreEntry(
|
||||||
|
version.entryId,
|
||||||
|
list.stateId,
|
||||||
|
)
|
||||||
|
if (result === "ok") {
|
||||||
|
toast.success(
|
||||||
|
formatMessage(dict.versions.restoredVersion, {
|
||||||
|
n: version.number,
|
||||||
|
}),
|
||||||
|
{ duration: 4000 },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
// The sync's notices tell about a stale list or a failure; the
|
||||||
|
// list on screen is read again either way
|
||||||
|
await refresh()
|
||||||
|
} finally {
|
||||||
|
setBusy(false)
|
||||||
|
useCanvasStore.getState().set({ isBusy: false })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const latest = versions.at(-1)
|
||||||
|
// The version the newest one replaced (restore_version's steps_back=1)
|
||||||
|
const previous = latest?.beforeId
|
||||||
|
? versions.find((v) => v.id === latest.beforeId)
|
||||||
|
: undefined
|
||||||
|
const canUndo = !!latest && !!previous && onCanvasId === latest.id
|
||||||
|
// The newest change is undone when the canvas is at the version before
|
||||||
|
const undoneId =
|
||||||
|
latest && previous && onCanvasId === previous.id ? latest.id : null
|
||||||
|
|
||||||
|
return {
|
||||||
|
versions,
|
||||||
|
onCanvasId,
|
||||||
|
undoneId,
|
||||||
|
isBusy: busy,
|
||||||
|
canUndo,
|
||||||
|
canRedo: undoneId !== null,
|
||||||
|
restore,
|
||||||
|
undo: () => {
|
||||||
|
if (canUndo && previous) restore(previous.id)
|
||||||
|
},
|
||||||
|
redo: () => {
|
||||||
|
if (undoneId && latest) restore(latest.id)
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,64 @@
|
|||||||
|
/**
|
||||||
|
* What the MCP server writes into the shell page as window.__MCP_CONFIG__
|
||||||
|
* (http-server.ts fills the {{CONFIG_JSON}} placeholder of index.html).
|
||||||
|
*/
|
||||||
|
export interface ShellConfig {
|
||||||
|
sessionId: string
|
||||||
|
/** Every /api request carries it in the X-Drawio-Token header */
|
||||||
|
token: string
|
||||||
|
/** Prefix of the server's API paths */
|
||||||
|
apiBase: string
|
||||||
|
/** The editor page: the bundled copy's index.html on this origin, or an
|
||||||
|
* external draw.io */
|
||||||
|
drawioBaseUrl: string
|
||||||
|
/** draw.io theme fixed by DRAWIO_UI; "" leaves the pick to draw.io */
|
||||||
|
drawioUi: string
|
||||||
|
/** Editor language fixed by DRAWIO_LANG, in draw.io's naming; "" leaves
|
||||||
|
* the pick to draw.io */
|
||||||
|
drawioLang: string
|
||||||
|
/** DRAWIO_DARK: the theme when the user has not chosen one here */
|
||||||
|
drawioDark: "dark" | "light" | "auto"
|
||||||
|
/** DRAWIO_LANG as given, for the shell's own texts */
|
||||||
|
lang: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const DEFAULTS: ShellConfig = {
|
||||||
|
sessionId: "",
|
||||||
|
token: "",
|
||||||
|
apiBase: "/api",
|
||||||
|
drawioBaseUrl: "/drawio/index.html",
|
||||||
|
drawioUi: "",
|
||||||
|
drawioLang: "",
|
||||||
|
drawioDark: "auto",
|
||||||
|
lang: "",
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The page's config with every field present: a missing or malformed one
|
||||||
|
* (another server version) takes its default.
|
||||||
|
*/
|
||||||
|
export function readShellConfig(source: unknown): ShellConfig {
|
||||||
|
const given =
|
||||||
|
source && typeof source === "object"
|
||||||
|
? (source as Record<string, unknown>)
|
||||||
|
: {}
|
||||||
|
const config = { ...DEFAULTS }
|
||||||
|
for (const key of Object.keys(DEFAULTS) as (keyof ShellConfig)[]) {
|
||||||
|
const value = given[key]
|
||||||
|
if (key === "drawioDark") {
|
||||||
|
if (value === "dark" || value === "light" || value === "auto") {
|
||||||
|
config.drawioDark = value
|
||||||
|
}
|
||||||
|
} else if (typeof value === "string") {
|
||||||
|
config[key] = value
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return config
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The config the server wrote into this page */
|
||||||
|
export function pageShellConfig(): ShellConfig {
|
||||||
|
return readShellConfig(
|
||||||
|
(window as { __MCP_CONFIG__?: unknown }).__MCP_CONFIG__,
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
/*
|
||||||
|
* Stylesheet of the MCP's canvas shell: the web app's theme and Tailwind,
|
||||||
|
* with the utility classes of the shared components the shell bundles.
|
||||||
|
* Built by scripts/build-shell.mjs; Tailwind scans this directory on its
|
||||||
|
* own, and the shared components through the @source lines.
|
||||||
|
*/
|
||||||
|
@import "../../../app/globals.css";
|
||||||
|
@source "../../../components/canvas";
|
||||||
|
@source "../../../components/ui";
|
||||||
|
@source "../../../components/ai-elements";
|
||||||
|
@source "../../../components/workspace/app-toaster.tsx";
|
||||||
|
@source "../../../components/save-dialog.tsx";
|
||||||
|
|
||||||
|
/* System fonts stand in for the web fonts the Next.js layout loads */
|
||||||
|
:root {
|
||||||
|
--font-latin:
|
||||||
|
ui-sans-serif, system-ui, -apple-system, "Segoe UI", Roboto,
|
||||||
|
"Helvetica Neue", Arial, sans-serif;
|
||||||
|
--font-mono: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
|
||||||
|
}
|
||||||
@@ -0,0 +1,61 @@
|
|||||||
|
import { useSettingsStore } from "@/stores/settings-store"
|
||||||
|
import type { ShellConfig } from "./runtime-config"
|
||||||
|
|
||||||
|
/**
|
||||||
|
* localStorage key of the user's theme choice here: "true" dark, "false"
|
||||||
|
* light, none: the server's DRAWIO_DARK, else the system. Its own prefix,
|
||||||
|
* so a web app dev server on the same port keeps its own settings.
|
||||||
|
* index.html repeats the key in its inline script (run before this bundle).
|
||||||
|
*/
|
||||||
|
export const THEME_STORAGE_KEY = "mcp:dark-mode"
|
||||||
|
|
||||||
|
/** Whether the shell is dark, from the choice stored here, the server's
|
||||||
|
* setting and the system */
|
||||||
|
export function isDarkPreferred(
|
||||||
|
stored: string | null,
|
||||||
|
drawioDark: ShellConfig["drawioDark"],
|
||||||
|
systemDark: boolean,
|
||||||
|
): boolean {
|
||||||
|
if (stored === "true" || stored === "false") return stored === "true"
|
||||||
|
if (drawioDark === "auto") return systemDark
|
||||||
|
return drawioDark === "dark"
|
||||||
|
}
|
||||||
|
|
||||||
|
function read(): string | null {
|
||||||
|
try {
|
||||||
|
return localStorage.getItem(THEME_STORAGE_KEY)
|
||||||
|
} catch {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The canvas components read the settings store's isDark (and the theme
|
||||||
|
// class, set before the first paint by index.html)
|
||||||
|
function apply(isDark: boolean) {
|
||||||
|
document.documentElement.classList.toggle("dark", isDark)
|
||||||
|
useSettingsStore.setState({
|
||||||
|
hydrated: true,
|
||||||
|
isDark,
|
||||||
|
theme: isDark ? "dark" : "light",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Take the theme the inline script chose, and follow the system while
|
||||||
|
* nothing else decides */
|
||||||
|
export function initTheme(drawioDark: ShellConfig["drawioDark"]) {
|
||||||
|
const query = window.matchMedia("(prefers-color-scheme: dark)")
|
||||||
|
apply(isDarkPreferred(read(), drawioDark, query.matches))
|
||||||
|
query.addEventListener("change", (event) => {
|
||||||
|
apply(isDarkPreferred(read(), drawioDark, event.matches))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The user's choice, kept for the next visit */
|
||||||
|
export function setDark(isDark: boolean) {
|
||||||
|
try {
|
||||||
|
localStorage.setItem(THEME_STORAGE_KEY, String(isDark))
|
||||||
|
} catch {
|
||||||
|
// Storage can be unavailable (private mode, quota)
|
||||||
|
}
|
||||||
|
apply(isDark)
|
||||||
|
}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
{
|
||||||
|
"extends": "../../../tsconfig.json",
|
||||||
|
"compilerOptions": {
|
||||||
|
"noEmit": true,
|
||||||
|
"incremental": false,
|
||||||
|
"plugins": []
|
||||||
|
},
|
||||||
|
"include": ["./**/*.ts", "./**/*.tsx"],
|
||||||
|
"exclude": ["node_modules"]
|
||||||
|
}
|
||||||
@@ -0,0 +1,132 @@
|
|||||||
|
import { useEffect, useRef, useState } from "react"
|
||||||
|
import { toast } from "sonner"
|
||||||
|
import { useDiagram } from "@/contexts/diagram-context"
|
||||||
|
import { useDictionary } from "@/hooks/use-dictionary"
|
||||||
|
import { diffDiagrams } from "@/lib/diagram-diff"
|
||||||
|
import {
|
||||||
|
highlightChangedCells,
|
||||||
|
readSelectionDetails,
|
||||||
|
} from "@/lib/drawio/editor-bridge"
|
||||||
|
import { useCanvasStore } from "@/stores/canvas-store"
|
||||||
|
import { createMcpSync, type McpSync, type SyncStatus } from "./mcp-sync-core"
|
||||||
|
import type { ShellConfig } from "./runtime-config"
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Keeps the canvas in step with the MCP server (mcp-sync-core.ts has the
|
||||||
|
* protocol): mounted once inside DiagramProvider. Server writes go on the
|
||||||
|
* canvas through loadDiagram, as one undo step when they can; the user's
|
||||||
|
* edits are pushed; the server's export and sync requests are answered.
|
||||||
|
* Returns the connection state for the status bar, and the sync itself for
|
||||||
|
* the version cards (History and restores go through it).
|
||||||
|
*/
|
||||||
|
export function useMcpSync(config: ShellConfig): {
|
||||||
|
status: SyncStatus
|
||||||
|
sync: McpSync | null
|
||||||
|
} {
|
||||||
|
const dict = useDictionary()
|
||||||
|
const diagram = useDiagram()
|
||||||
|
const [status, setStatus] = useState<SyncStatus>("waiting")
|
||||||
|
const [sync, setSync] = useState<McpSync | null>(null)
|
||||||
|
const syncRef = useRef<McpSync | null>(null)
|
||||||
|
// The provider's functions are new on every render; the sync (created
|
||||||
|
// once) reads the latest through refs
|
||||||
|
const diagramRef = useRef(diagram)
|
||||||
|
diagramRef.current = diagram
|
||||||
|
const dictRef = useRef(dict)
|
||||||
|
dictRef.current = dict
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!config.sessionId) return
|
||||||
|
const sync = createMcpSync({
|
||||||
|
sessionId: config.sessionId,
|
||||||
|
apiBase: config.apiBase,
|
||||||
|
token: config.token,
|
||||||
|
canvas: {
|
||||||
|
load: (xml, decision) =>
|
||||||
|
diagramRef.current.loadDiagram(
|
||||||
|
xml,
|
||||||
|
true,
|
||||||
|
decision.mode,
|
||||||
|
decision.mode === "commit"
|
||||||
|
? { pageId: decision.pageId }
|
||||||
|
: undefined,
|
||||||
|
),
|
||||||
|
showTransient: (xml) => diagramRef.current.showTransient(xml),
|
||||||
|
export: (request, timeoutMs) =>
|
||||||
|
diagramRef.current.requestExport(request, timeoutMs),
|
||||||
|
currentXml: () => diagramRef.current.chartXMLRef.current,
|
||||||
|
currentPageId: () => useCanvasStore.getState().currentPageId,
|
||||||
|
loadPending: () => diagramRef.current.hasPendingLoad(),
|
||||||
|
// The bridge reads the editor directly; without it (an
|
||||||
|
// external draw.io) the selection cannot be read
|
||||||
|
readSelection: () =>
|
||||||
|
readSelectionDetails() ?? {
|
||||||
|
pageId: null,
|
||||||
|
pageName: null,
|
||||||
|
cells: [],
|
||||||
|
unavailable: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
onNotice: (notice) =>
|
||||||
|
toast(dictRef.current.shell[notice], { duration: 8000 }),
|
||||||
|
onStatus: setStatus,
|
||||||
|
isHidden: () => document.visibilityState === "hidden",
|
||||||
|
})
|
||||||
|
syncRef.current = sync
|
||||||
|
setSync(sync)
|
||||||
|
sync.setReady(diagramRef.current.isDrawioReady)
|
||||||
|
sync.start()
|
||||||
|
|
||||||
|
// draw.io's autosave and load events, as the classic page reads
|
||||||
|
// them: the frame component handles them for the canvas, the sync
|
||||||
|
// needs them for the protocol (an edit to push, a load done)
|
||||||
|
const drawioOrigin = new URL(config.drawioBaseUrl, location.href).origin
|
||||||
|
const onMessage = (event: MessageEvent) => {
|
||||||
|
if (event.source === window || event.origin !== drawioOrigin) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (typeof event.data !== "string") return
|
||||||
|
let message: { event?: string; xml?: unknown }
|
||||||
|
try {
|
||||||
|
message = JSON.parse(event.data)
|
||||||
|
} catch {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
message.event === "autosave" &&
|
||||||
|
typeof message.xml === "string"
|
||||||
|
) {
|
||||||
|
sync.onAutoSave(message.xml)
|
||||||
|
} else if (message.event === "load") {
|
||||||
|
sync.onDrawioLoad()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
window.addEventListener("message", onMessage)
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener("message", onMessage)
|
||||||
|
sync.stop()
|
||||||
|
syncRef.current = null
|
||||||
|
setSync(null)
|
||||||
|
}
|
||||||
|
}, [config])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
syncRef.current?.setReady(diagram.isDrawioReady)
|
||||||
|
}, [diagram.isDrawioReady])
|
||||||
|
|
||||||
|
// Mark what a server write changed on the page, as the web app does
|
||||||
|
// for its versions (use-versions.ts); any action on the canvas clears it
|
||||||
|
useEffect(() => {
|
||||||
|
diagram.setCommitHandler(({ beforeXml, afterXml, pageId }) => {
|
||||||
|
const { touchedIds, fromScratch } = diffDiagrams(
|
||||||
|
beforeXml,
|
||||||
|
afterXml,
|
||||||
|
pageId,
|
||||||
|
)
|
||||||
|
if (!fromScratch) highlightChangedCells(touchedIds)
|
||||||
|
})
|
||||||
|
return () => diagram.setCommitHandler(null)
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
return { status, sync }
|
||||||
|
}
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
import { useEffect, useRef } from "react"
|
||||||
|
import { VersionCard } from "@/components/canvas/version-card"
|
||||||
|
import { useDictionary } from "@/hooks/use-dictionary"
|
||||||
|
import type { Dictionary } from "@/lib/i18n/dictionaries"
|
||||||
|
import type { NodeVersion } from "./node-versions-source"
|
||||||
|
|
||||||
|
/** What a card says happened: the web app's chat knows the tool that ran,
|
||||||
|
* the shell tells from who made the entry and what changed */
|
||||||
|
export function versionLabel(version: NodeVersion, dict: Dictionary): string {
|
||||||
|
if (version.source === "user" || version.source === "recover") {
|
||||||
|
return dict.shell.userEdit
|
||||||
|
}
|
||||||
|
return version.fromScratch ? dict.versions.created : dict.versions.edited
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The version cards, oldest first, as the web app's chat shows them */
|
||||||
|
export function VersionsPanel({ versions }: { versions: NodeVersion[] }) {
|
||||||
|
const dict = useDictionary()
|
||||||
|
const listRef = useRef<HTMLDivElement>(null)
|
||||||
|
|
||||||
|
// Keep the newest version in view
|
||||||
|
useEffect(() => {
|
||||||
|
const el = listRef.current
|
||||||
|
if (el) el.scrollTo({ top: el.scrollHeight, behavior: "smooth" })
|
||||||
|
}, [versions.length])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<aside
|
||||||
|
className="flex w-[340px] shrink-0 flex-col border-l border-border bg-surface-0"
|
||||||
|
data-testid="versions-panel"
|
||||||
|
>
|
||||||
|
<div className="flex h-9 shrink-0 items-center px-3.5 text-xs font-medium text-muted-foreground">
|
||||||
|
{dict.versions.strip}
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
ref={listRef}
|
||||||
|
className="flex min-h-0 flex-1 flex-col gap-2 overflow-y-auto px-3 pb-3 scrollbar-thin"
|
||||||
|
>
|
||||||
|
{versions.map((version, i) => (
|
||||||
|
<VersionCard
|
||||||
|
key={version.id}
|
||||||
|
version={version}
|
||||||
|
isLatest={i === versions.length - 1}
|
||||||
|
label={versionLabel(version, dict)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
/**
|
||||||
|
* The user's own drawing rules, kept in a Markdown file and appended to the
|
||||||
|
* drawing guide, like the web app's custom system message
|
||||||
|
* (app/api/chat/route.ts). The file is read on every call, so edits apply
|
||||||
|
* without restarting the MCP host.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { readFileSync } from "node:fs"
|
||||||
|
import { homedir } from "node:os"
|
||||||
|
import { join } from "node:path"
|
||||||
|
import { log } from "./logger.ts"
|
||||||
|
import { expandHome } from "./persistence.ts"
|
||||||
|
|
||||||
|
export const CUSTOM_INSTRUCTIONS_FILE = "instructions.md"
|
||||||
|
|
||||||
|
// Same cap as the web app's custom system message
|
||||||
|
const MAX_CHARS = 5000
|
||||||
|
|
||||||
|
/**
|
||||||
|
* DRAWIO_DATA_DIR, default ~/.next-ai-drawio. "off" only turns auto-save
|
||||||
|
* off; this file is merely read, so the default folder is used then.
|
||||||
|
*/
|
||||||
|
export function customInstructionsDir(): string {
|
||||||
|
const dir = process.env.DRAWIO_DATA_DIR
|
||||||
|
return dir && dir !== "off"
|
||||||
|
? expandHome(dir)
|
||||||
|
: join(homedir(), ".next-ai-drawio")
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Full path of the instructions file. */
|
||||||
|
export function customInstructionsPath(dir = customInstructionsDir()): string {
|
||||||
|
return join(dir, CUSTOM_INSTRUCTIONS_FILE)
|
||||||
|
}
|
||||||
|
|
||||||
|
let warned = false
|
||||||
|
|
||||||
|
/** The file's content, trimmed and capped; "" when there is none. */
|
||||||
|
export function readCustomInstructions(dir = customInstructionsDir()): string {
|
||||||
|
const path = customInstructionsPath(dir)
|
||||||
|
try {
|
||||||
|
return readFileSync(path, "utf-8").trim().slice(0, MAX_CHARS)
|
||||||
|
} catch (error) {
|
||||||
|
if ((error as NodeJS.ErrnoException).code !== "ENOENT" && !warned) {
|
||||||
|
warned = true
|
||||||
|
log.warn(`Could not read the custom instructions ${path}: ${error}`)
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The guide with the user's rules appended under "## Custom Instructions". */
|
||||||
|
export function guideWithCustomInstructions(
|
||||||
|
guide: string,
|
||||||
|
dir?: string,
|
||||||
|
): string {
|
||||||
|
const text = readCustomInstructions(dir)
|
||||||
|
return text ? `${guide}\n\n## Custom Instructions\n${text}` : guide
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
@@ -17,30 +18,32 @@ import {
|
|||||||
export const DRAWING_GUIDE = `# Draw.io drawing guide
|
export const DRAWING_GUIDE = `# Draw.io drawing guide
|
||||||
|
|
||||||
## Workflow
|
## Workflow
|
||||||
- create_new_diagram draws a new diagram and REPLACES the whole document. add_page adds another tab. edit_diagram changes cells of an existing page. load_diagram opens a .drawio file (the server reads the file itself). get_diagram returns the current XML, including the user's manual edits. export_diagram saves to a file.
|
- create_new_diagram draws a new diagram and REPLACES the whole document. add_page adds another tab. edit_diagram changes cells of an existing page. load_diagram opens a .drawio file (the server reads the file itself, or takes the file's content as its 'xml' argument when you already have it in hand). get_diagram returns the current XML, including the user's manual edits. export_diagram saves to a file.
|
||||||
|
- When the user refers to what they selected in the preview ("this box", "these arrows"), call get_selection: it returns the selected cells' ids, labels and page, ready for edit_diagram.
|
||||||
- Before drawing, describe your layout plan in 2-3 sentences, so shapes do not overlap and edges do not cross shapes.
|
- Before drawing, describe your layout plan in 2-3 sentences, so shapes do not overlap and edges do not cross shapes.
|
||||||
- Send XML only through tool calls, never in chat text. Never draw a box just to send the user a message.
|
- Send XML only through tool calls, never in chat text. Never draw a box just to send the user a message.
|
||||||
- Before using any icon library (AWS, Azure, GCP, Kubernetes, Cisco, BPMN, Material Design, web icons...), call get_shape_library and use the exact style names it returns. NEVER guess icon style names. For AWS, use the AWS 2025 icons (library aws4).
|
- Before using any icon library (AWS, Azure, GCP, Kubernetes, Cisco, BPMN, Material Design, web icons...), call get_shape_library and use the exact style names it returns. NEVER guess icon style names. For AWS, use the AWS 2025 icons (library aws4).
|
||||||
- After drawing or heavily editing a complex diagram, call screenshot_diagram once to see the result, and fix overlapping shapes and edges that cross shapes.
|
- After drawing or heavily editing a complex diagram, pass screenshot: true on that create_new_diagram or edit_diagram call (or call screenshot_diagram) to see the result, and fix overlapping shapes and edges that cross shapes.
|
||||||
- When replicating a diagram from an image, match its style and layout closely: straight or curved lines, rounded or square shapes.
|
- Drawing from a source: to draw from a document (PDF, Markdown, code, data), an image or screenshot, or a web page, first read it yourself (the attachment the user shared, or your own file-reading or web-fetch tools), then plan the layout and draw. This server never receives attachments; it only receives the XML you send. Extract the entities and relationships the diagram needs; do not copy the text into boxes.
|
||||||
- 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.
|
- When replicating a diagram from an image, match its style and layout closely: straight or curved lines, rounded or square shapes, colors and relative positions. Call screenshot_diagram afterwards and compare with the original.
|
||||||
|
- After a successful create_new_diagram or edit_diagram call, do not describe the diagram; the user sees it in the preview. One short sentence at most.
|
||||||
|
- For artistic requests (a cat, a logo, a scene), compose the picture from standard shapes and connectors while keeping it clear.
|
||||||
|
- To clear the canvas to one blank page, call create_new_diagram with only the two root cells <mxCell id="0"/><mxCell id="1" parent="0"/>; the previous diagram stays in History.
|
||||||
|
- The preview keeps the last 20 versions (a snapshot before every AI change) and can restore any of them. You can make changes freely; nothing is lost.
|
||||||
|
- If the user asks to undo or go back, call restore_version instead of re-sending the earlier XML from memory.
|
||||||
|
|
||||||
## 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.
|
||||||
@@ -50,6 +53,8 @@ Containers and swimlanes: children use the container id as parent and coordinate
|
|||||||
|
|
||||||
${indent(SWIMLANE_EXAMPLE)}
|
${indent(SWIMLANE_EXAMPLE)}
|
||||||
|
|
||||||
|
Large diagrams: if the diagram will not fit one call, send the first cells with create_new_diagram and add the rest with edit_diagram add operations (one per cell, same id rules). A call rejected because the XML ends inside an unfinished cell drew nothing: send all of its cells again, in smaller parts if needed.
|
||||||
|
|
||||||
## Layout
|
## Layout
|
||||||
- Keep every element of a page within x 0 to 800 and y 0 to 600, so the whole diagram fits one view without a page break.
|
- Keep every element of a page within x 0 to 800 and y 0 to 600, so the whole diagram fits one view without a page break.
|
||||||
- Containers (for example AWS cloud boxes) are at most 700 pixels wide and 550 pixels tall.
|
- Containers (for example AWS cloud boxes) are at most 700 pixels wide and 550 pixels tall.
|
||||||
@@ -59,7 +64,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 +85,36 @@ 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.
|
||||||
|
- Edge options: dashed=1; strokeWidth=2; edgeStyle=elbowEdgeStyle;elbow=vertical (or horizontal); jumpStyle=arc;jumpSize=10 where edges cross.
|
||||||
|
|
||||||
|
For tables, layers or groups, call get_drawing_guide with topic=tables, layers or groups.
|
||||||
|
|
||||||
## 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.
|
- Use create_new_diagram for a new diagram, a major restructuring or an empty canvas. Use edit_diagram for small changes: adding or removing a few elements, labels, colors or positions. Never redraw a large diagram for a small change.
|
||||||
|
|
||||||
{"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.
|
||||||
|
|||||||
@@ -0,0 +1,24 @@
|
|||||||
|
export const DRAWIO_THEMES = [
|
||||||
|
"kennedy",
|
||||||
|
"atlas",
|
||||||
|
"dark",
|
||||||
|
"min",
|
||||||
|
"sketch",
|
||||||
|
"simple",
|
||||||
|
] as const
|
||||||
|
|
||||||
|
export type DrawioTheme = (typeof DRAWIO_THEMES)[number]
|
||||||
|
|
||||||
|
export function isDrawioTheme(value: unknown): value is DrawioTheme {
|
||||||
|
return (
|
||||||
|
typeof value === "string" &&
|
||||||
|
(DRAWIO_THEMES as readonly string[]).includes(value)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** draw.io's name for a locale: lowercase, "zh-tw" for Traditional and "zh" for Simplified Chinese */
|
||||||
|
export function toDrawioLang(locale: string): string {
|
||||||
|
const lang = locale.toLowerCase()
|
||||||
|
if (lang === "zh-hant") return "zh-tw"
|
||||||
|
return lang === "zh-hans" ? "zh" : lang
|
||||||
|
}
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
{
|
||||||
|
"version": "v32.0.2",
|
||||||
|
"sha256": "3cb8abec8e9bfc7504760c9cdc9194ecf7e8de178aa2a1d668801c32ecf1a1a7",
|
||||||
|
"downloadUrl": "https://github.com/jgraph/drawio/releases/download/v32.0.2/draw.war"
|
||||||
|
}
|
||||||
@@ -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 }
|
||||||
|
|
||||||
|
|||||||
@@ -142,3 +142,116 @@ export function markPageSeen(
|
|||||||
? liveXml
|
? liveXml
|
||||||
: lastSeenXml
|
: lastSeenXml
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const CELL_TAGS = new Set(["mxCell", "UserObject", "object"])
|
||||||
|
|
||||||
|
/** The cells of a page by id: the direct children of its <root>. */
|
||||||
|
function pageCells(page: Element): Map<string, Element> {
|
||||||
|
const cells = new Map<string, Element>()
|
||||||
|
for (const el of Array.from(page.querySelector("root")?.children ?? [])) {
|
||||||
|
const id = el.getAttribute("id")
|
||||||
|
if (id && CELL_TAGS.has(el.tagName)) cells.set(id, el)
|
||||||
|
}
|
||||||
|
return cells
|
||||||
|
}
|
||||||
|
|
||||||
|
/** `id ("label")`: the cell's value without HTML, cut at 30 characters. */
|
||||||
|
function describeCell(id: string, cell: Element): string {
|
||||||
|
// getAttributeNode gives the decoded value (linkedom's getAttribute
|
||||||
|
// escapes it again); an HTML label then still holds tags and entities
|
||||||
|
const label = (
|
||||||
|
(cell.getAttributeNode("value") ?? cell.getAttributeNode("label"))
|
||||||
|
?.value ?? ""
|
||||||
|
)
|
||||||
|
.replace(/<[^>]*>/g, " ")
|
||||||
|
.replace(/ /g, " ")
|
||||||
|
.replace(/</g, "<")
|
||||||
|
.replace(/>/g, ">")
|
||||||
|
.replace(/"/g, '"')
|
||||||
|
.replace(/&/g, "&")
|
||||||
|
.replace(/\s+/g, " ")
|
||||||
|
.trim()
|
||||||
|
if (!label) return id
|
||||||
|
return `${id} ("${label.length > 30 ? `${label.slice(0, 30)}...` : label}")`
|
||||||
|
}
|
||||||
|
|
||||||
|
/** At most 20 entries, then "and N more". */
|
||||||
|
function listCells(cells: string[]): string {
|
||||||
|
const shown = cells.slice(0, 20).join(", ")
|
||||||
|
return cells.length > 20 ? `${shown} and ${cells.length - 20} more` : shown
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One paragraph naming what the user changed between the XML the model last
|
||||||
|
* saw and the live store, for stale rejections and get_diagram. Pages are
|
||||||
|
* paired by index (draw.io regenerates diagram ids on re-serialisation) and
|
||||||
|
* cells by id. Returns "" when there is nothing to compare or the two sides
|
||||||
|
* have the same content.
|
||||||
|
*/
|
||||||
|
export function describeChanges(lastSeenXml: string, liveXml: string): string {
|
||||||
|
if (!lastSeenXml || lastSeenXml === liveXml) return ""
|
||||||
|
const parse = (xml: string) => {
|
||||||
|
const normalized = normalizeToMxfile(xml)
|
||||||
|
return normalized ? parseMxfile(normalized) : null
|
||||||
|
}
|
||||||
|
const before = parse(lastSeenXml)
|
||||||
|
const live = parse(liveXml)
|
||||||
|
if (!before || !live) return ""
|
||||||
|
// Same rule as checkEditGate: a bare <mxGraphModel> carries no page name
|
||||||
|
const includeNames =
|
||||||
|
!isMxGraphModel(liveXml) && !isMxGraphModel(lastSeenXml)
|
||||||
|
if (
|
||||||
|
contentFingerprint(lastSeenXml, includeNames) ===
|
||||||
|
contentFingerprint(liveXml, includeNames)
|
||||||
|
)
|
||||||
|
return ""
|
||||||
|
|
||||||
|
const pagesBefore = Array.from(before.querySelectorAll("diagram"))
|
||||||
|
const pagesLive = Array.from(live.querySelectorAll("diagram"))
|
||||||
|
const pageCount = Math.max(pagesBefore.length, pagesLive.length)
|
||||||
|
const clauses: string[] = []
|
||||||
|
const renames: string[] = []
|
||||||
|
for (let i = 0; i < pageCount; i++) {
|
||||||
|
const pageBefore = pagesBefore[i]
|
||||||
|
const pageLive = pagesLive[i]
|
||||||
|
if (!pageBefore || !pageLive) {
|
||||||
|
clauses.push(`page ${i + 1} ${pageLive ? "added" : "removed"}`)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
const cellsBefore = pageCells(pageBefore)
|
||||||
|
const cellsLive = pageCells(pageLive)
|
||||||
|
const modified: string[] = []
|
||||||
|
const added: string[] = []
|
||||||
|
const removed: string[] = []
|
||||||
|
for (const [id, cell] of cellsLive) {
|
||||||
|
const old = cellsBefore.get(id)
|
||||||
|
if (!old) added.push(describeCell(id, cell))
|
||||||
|
else if (canonicalizeElement(old) !== canonicalizeElement(cell))
|
||||||
|
modified.push(describeCell(id, cell))
|
||||||
|
}
|
||||||
|
for (const [id, cell] of cellsBefore) {
|
||||||
|
if (!cellsLive.has(id)) removed.push(describeCell(id, cell))
|
||||||
|
}
|
||||||
|
// Name the page only when the document has several
|
||||||
|
const where = pageCount > 1 ? `page ${i + 1} ` : ""
|
||||||
|
if (modified.length)
|
||||||
|
clauses.push(`${where}modified ${listCells(modified)}`)
|
||||||
|
if (added.length) clauses.push(`${where}added ${listCells(added)}`)
|
||||||
|
if (removed.length)
|
||||||
|
clauses.push(`${where}removed ${listCells(removed)}`)
|
||||||
|
if (includeNames) {
|
||||||
|
const name = pageLive.getAttribute("name") ?? ""
|
||||||
|
if (name !== (pageBefore.getAttribute("name") ?? ""))
|
||||||
|
renames.push(`Page ${i + 1} renamed to "${name}".`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// The fingerprint also covers the cell order (z-order) and anything in
|
||||||
|
// <root> that is not a cell
|
||||||
|
if (!clauses.length && !renames.length)
|
||||||
|
clauses.push("the order of the cells changed")
|
||||||
|
const intro = "Since you last saw it, the user changed the diagram"
|
||||||
|
const body = clauses.length
|
||||||
|
? `${intro}: ${clauses.join("; ")}.`
|
||||||
|
: `${intro}.`
|
||||||
|
return [body, ...renames].join(" ")
|
||||||
|
}
|
||||||
|
|||||||
@@ -3,20 +3,36 @@
|
|||||||
* Stores {xml, svg} entries in a circular buffer
|
* Stores {xml, svg} entries in a circular buffer
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
import { contentFingerprint } from "./edit-gate.ts"
|
||||||
import { log } from "./logger.ts"
|
import { log } from "./logger.ts"
|
||||||
|
import { isMxGraphModel } from "./pages.ts"
|
||||||
|
|
||||||
const MAX_HISTORY = 20
|
const MAX_HISTORY = 20
|
||||||
|
|
||||||
interface HistoryEntry {
|
/**
|
||||||
|
* Who made an entry's diagram: an edit in the browser ("user"), a tab's
|
||||||
|
* copy kept when the server recovered the session ("recover"), a restored
|
||||||
|
* copy of an older entry ("restore"). The AI's writes and entries loaded
|
||||||
|
* from the saved file carry none.
|
||||||
|
*/
|
||||||
|
export type HistorySource = "user" | "recover" | "restore"
|
||||||
|
|
||||||
|
export interface HistoryEntry {
|
||||||
id: number // Stable across shifts of the circular buffer
|
id: number // Stable across shifts of the circular buffer
|
||||||
xml: string
|
xml: string
|
||||||
svg: string
|
svg: string
|
||||||
|
source?: HistorySource
|
||||||
}
|
}
|
||||||
|
|
||||||
let nextEntryId = 0
|
let nextEntryId = 0
|
||||||
const historyStore = new Map<string, HistoryEntry[]>()
|
const historyStore = new Map<string, HistoryEntry[]>()
|
||||||
|
|
||||||
export function addHistory(sessionId: string, xml: string, svg = ""): number {
|
export function addHistory(
|
||||||
|
sessionId: string,
|
||||||
|
xml: string,
|
||||||
|
svg = "",
|
||||||
|
source?: HistorySource,
|
||||||
|
): number {
|
||||||
let history = historyStore.get(sessionId)
|
let history = historyStore.get(sessionId)
|
||||||
if (!history) {
|
if (!history) {
|
||||||
history = []
|
history = []
|
||||||
@@ -32,7 +48,12 @@ export function addHistory(sessionId: string, xml: string, svg = ""): number {
|
|||||||
return history.length - 1
|
return history.length - 1
|
||||||
}
|
}
|
||||||
|
|
||||||
history.push({ id: nextEntryId++, xml, svg })
|
history.push({
|
||||||
|
id: nextEntryId++,
|
||||||
|
xml,
|
||||||
|
svg,
|
||||||
|
...(source && { source }),
|
||||||
|
})
|
||||||
|
|
||||||
// Circular buffer
|
// Circular buffer
|
||||||
if (history.length > MAX_HISTORY) {
|
if (history.length > MAX_HISTORY) {
|
||||||
@@ -59,6 +80,107 @@ export function clearHistory(sessionId: string): void {
|
|||||||
historyStore.delete(sessionId)
|
historyStore.delete(sessionId)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The versions the diagram can go back (or forward) to: every entry whose
|
||||||
|
* content differs from currentXml, newest first, one entry per distinct
|
||||||
|
* content (its newest copy). Re-serialised copies of one diagram count as
|
||||||
|
* the same version.
|
||||||
|
*/
|
||||||
|
export function otherVersions(
|
||||||
|
sessionId: string,
|
||||||
|
currentXml: string,
|
||||||
|
): HistoryEntry[] {
|
||||||
|
const kept = [versionOf(currentXml)]
|
||||||
|
const result: HistoryEntry[] = []
|
||||||
|
const history = getHistory(sessionId)
|
||||||
|
for (let i = history.length - 1; i >= 0; i--) {
|
||||||
|
const version = entryVersion(history[i])
|
||||||
|
if (kept.some((seen) => sameVersion(seen, version))) continue
|
||||||
|
kept.push(version)
|
||||||
|
result.push(history[i])
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
/** True when History holds a copy of this diagram. */
|
||||||
|
export function isInHistory(sessionId: string, xml: string): boolean {
|
||||||
|
const version = versionOf(xml)
|
||||||
|
return getHistory(sessionId).some((entry) =>
|
||||||
|
sameVersion(version, entryVersion(entry)),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The entry the canvas shows: the newest one with the content of xml (the
|
||||||
|
* rule of otherVersions), or null when the canvas is in none of them
|
||||||
|
*/
|
||||||
|
export function currentEntryId(sessionId: string, xml: string): number | null {
|
||||||
|
const version = versionOf(xml)
|
||||||
|
const history = getHistory(sessionId)
|
||||||
|
for (let i = history.length - 1; i >= 0; i--) {
|
||||||
|
if (sameVersion(version, entryVersion(history[i]))) {
|
||||||
|
return history[i].id
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* For every entry, the id of the oldest entry with the same content (its
|
||||||
|
* own when no earlier one has it). A restore adds a copy of an older entry;
|
||||||
|
* the shell shows one version per content, where its first copy is, and
|
||||||
|
* restores the newest copy (as restore_version picks it). An entry is
|
||||||
|
* compared with the first of each group, never with later copies: a bare
|
||||||
|
* model matches any page name, so "same" is not transitive, and every
|
||||||
|
* entry must name a first copy that has its content.
|
||||||
|
*/
|
||||||
|
export function firstCopyIds(sessionId: string): Map<number, number> {
|
||||||
|
const result = new Map<number, number>()
|
||||||
|
const firsts: { version: Version; id: number }[] = []
|
||||||
|
for (const entry of getHistory(sessionId)) {
|
||||||
|
const version = entryVersion(entry)
|
||||||
|
let first = firsts.find((f) => sameVersion(f.version, version))
|
||||||
|
if (!first) {
|
||||||
|
first = { version, id: entry.id }
|
||||||
|
firsts.push(first)
|
||||||
|
}
|
||||||
|
result.set(entry.id, first.id)
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Version {
|
||||||
|
bare: boolean
|
||||||
|
named: string
|
||||||
|
cells: string
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A diagram's fingerprints, with and without its page names */
|
||||||
|
function versionOf(xml: string): Version {
|
||||||
|
return {
|
||||||
|
bare: isMxGraphModel(xml),
|
||||||
|
named: contentFingerprint(xml),
|
||||||
|
cells: contentFingerprint(xml, false),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// An entry's XML never changes: its fingerprints are computed once
|
||||||
|
const entryVersions = new WeakMap<HistoryEntry, Version>()
|
||||||
|
function entryVersion(entry: HistoryEntry): Version {
|
||||||
|
let version = entryVersions.get(entry)
|
||||||
|
if (!version) {
|
||||||
|
version = versionOf(entry.xml)
|
||||||
|
entryVersions.set(entry, version)
|
||||||
|
}
|
||||||
|
return version
|
||||||
|
}
|
||||||
|
|
||||||
|
// Same rule as checkEditGate: a bare <mxGraphModel> (the browser's sync
|
||||||
|
// can send one) has no page name, so names count only when both have them
|
||||||
|
function sameVersion(a: Version, b: Version): boolean {
|
||||||
|
return a.bare || b.bare ? a.cells === b.cells : a.named === b.named
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Give the last entry the image the browser took of shownXml, the diagram
|
* Give the last entry the image the browser took of shownXml, the diagram
|
||||||
* it just loaded, when that entry is this diagram
|
* it just loaded, when that entry is this diagram
|
||||||
|
|||||||
@@ -3,10 +3,11 @@
|
|||||||
* Serves draw.io embed with state sync and history UI
|
* Serves draw.io embed with state sync and history UI
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { randomUUID } from "node:crypto"
|
import { randomBytes, randomUUID } from "node:crypto"
|
||||||
import { readFileSync } from "node:fs"
|
import { createReadStream, existsSync, readFileSync, statSync } from "node:fs"
|
||||||
import http from "node:http"
|
import http from "node:http"
|
||||||
import { dirname, join } from "node:path"
|
import { dirname, extname, join, posix, resolve, sep } from "node:path"
|
||||||
|
import { pipeline } from "node:stream"
|
||||||
import { fileURLToPath } from "node:url"
|
import { fileURLToPath } from "node:url"
|
||||||
|
|
||||||
const MAX_BODY_BYTES = 10 * 1024 * 1024 // 10 MiB
|
const MAX_BODY_BYTES = 10 * 1024 * 1024 // 10 MiB
|
||||||
@@ -44,20 +45,128 @@ function readBody(
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
import { isDrawioTheme, toDrawioLang } from "./drawio-themes.ts"
|
||||||
import { contentFingerprint } from "./edit-gate.ts"
|
import { contentFingerprint } from "./edit-gate.ts"
|
||||||
import {
|
import {
|
||||||
addHistory,
|
addHistory,
|
||||||
clearHistory,
|
clearHistory,
|
||||||
|
currentEntryId,
|
||||||
|
firstCopyIds,
|
||||||
getHistory,
|
getHistory,
|
||||||
getHistoryEntry,
|
getHistoryEntry,
|
||||||
updateLastHistorySvg,
|
updateLastHistorySvg,
|
||||||
} from "./history.ts"
|
} from "./history.ts"
|
||||||
import { log } from "./logger.ts"
|
import { log } from "./logger.ts"
|
||||||
import { BLANK_MXFILE } from "./pages.ts"
|
import { BLANK_MXFILE } from "./pages.ts"
|
||||||
|
import { parseSelectionAnswer, type SelectionAnswer } from "./selection.ts"
|
||||||
|
|
||||||
// Configurable draw.io embed URL for private deployments
|
// Configurable draw.io embed URL for private deployments. Set, it replaces
|
||||||
const DRAWIO_BASE_URL =
|
// the bundled copy (see drawioDir below).
|
||||||
|
export const DRAWIO_BASE_URL =
|
||||||
process.env.DRAWIO_BASE_URL || "https://embed.diagrams.net"
|
process.env.DRAWIO_BASE_URL || "https://embed.diagrams.net"
|
||||||
|
const EXTERNAL_DRAWIO = !!process.env.DRAWIO_BASE_URL
|
||||||
|
|
||||||
|
// Every /api request must carry this token in the X-Drawio-Token header.
|
||||||
|
// The page reads it from its HTML. Another website cannot add a custom
|
||||||
|
// header without a CORS preflight, which this server never grants, so its
|
||||||
|
// requests stop here whatever Host and Origin they manage to send.
|
||||||
|
const API_TOKEN = randomBytes(16).toString("hex")
|
||||||
|
|
||||||
|
export function getApiToken(): string {
|
||||||
|
return API_TOKEN
|
||||||
|
}
|
||||||
|
|
||||||
|
// The trimmed draw.io copy that scripts/fetch-drawio.mjs puts in dist/drawio,
|
||||||
|
// next to the compiled server; run from src with tsx, it is one level up
|
||||||
|
const HERE = dirname(fileURLToPath(import.meta.url))
|
||||||
|
let drawioDir: string | null =
|
||||||
|
[join(HERE, "drawio"), join(HERE, "../dist/drawio")].find((dir) =>
|
||||||
|
existsSync(join(dir, "index.html")),
|
||||||
|
) ?? null
|
||||||
|
let drawioVersion = readDrawioVersion(drawioDir)
|
||||||
|
|
||||||
|
/** For tests: serve draw.io from this directory (null: no bundled copy) */
|
||||||
|
export function setDrawioDir(dir: string | null): void {
|
||||||
|
drawioDir = dir
|
||||||
|
drawioVersion = readDrawioVersion(dir)
|
||||||
|
}
|
||||||
|
|
||||||
|
// The canvas shell (the web app's canvas without the chat), built by
|
||||||
|
// scripts/build-shell.mjs into dist/shell
|
||||||
|
let shellDir: string | null =
|
||||||
|
[join(HERE, "shell"), join(HERE, "../dist/shell")].find((dir) =>
|
||||||
|
existsSync(join(dir, "index.html")),
|
||||||
|
) ?? null
|
||||||
|
|
||||||
|
/** For tests: serve the shell from this directory (null: not built) */
|
||||||
|
export function setShellDir(dir: string | null): void {
|
||||||
|
shellDir = dir
|
||||||
|
}
|
||||||
|
|
||||||
|
// This package's version, stamped into the shell files' ETags (see
|
||||||
|
// readDrawioVersion for why)
|
||||||
|
const PACKAGE_VERSION: string = JSON.parse(
|
||||||
|
readFileSync(join(HERE, "../package.json"), "utf8"),
|
||||||
|
).version
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The preview page start_session opens: the canvas shell (shell/, the web
|
||||||
|
* app's canvas), or with DRAWIO_PREVIEW_UI=classic the classic page
|
||||||
|
* (src/preview), kept for one more release.
|
||||||
|
*/
|
||||||
|
export type PreviewUi = "classic" | "shell"
|
||||||
|
|
||||||
|
export function previewUiFromEnv(
|
||||||
|
env: NodeJS.ProcessEnv = process.env,
|
||||||
|
): PreviewUi {
|
||||||
|
return (env.DRAWIO_PREVIEW_UI ?? "").toLowerCase() === "classic"
|
||||||
|
? "classic"
|
||||||
|
: "shell"
|
||||||
|
}
|
||||||
|
|
||||||
|
export const PREVIEW_UI: PreviewUi = previewUiFromEnv()
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The page start_session actually opens: the classic page stands in while
|
||||||
|
* the shell is not built (a run from the sources), as embed.diagrams.net
|
||||||
|
* does for a missing dist/drawio
|
||||||
|
*/
|
||||||
|
export function activePreviewUi(ui: PreviewUi = PREVIEW_UI): PreviewUi {
|
||||||
|
return ui === "shell" && shellDir === null ? "classic" : ui
|
||||||
|
}
|
||||||
|
|
||||||
|
export function previewUrl(
|
||||||
|
port: number,
|
||||||
|
sessionId: string,
|
||||||
|
ui: PreviewUi = PREVIEW_UI,
|
||||||
|
): string {
|
||||||
|
const path = activePreviewUi(ui) === "shell" ? "/shell/" : ""
|
||||||
|
return `http://localhost:${port}${path}?mcp=${sessionId}`
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The draw.io version the fetch script stamps into the copy. It is part of
|
||||||
|
* every file's ETag: an install that keeps the archive's dates (npm does
|
||||||
|
* not) would otherwise hand out the old ETag for a changed file of the
|
||||||
|
* same size after an upgrade.
|
||||||
|
*/
|
||||||
|
function readDrawioVersion(dir: string | null): string {
|
||||||
|
if (!dir) return ""
|
||||||
|
try {
|
||||||
|
return readFileSync(join(dir, ".version"), "utf8").trim()
|
||||||
|
} catch {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether the preview embeds draw.io from its own origin (the bundled copy),
|
||||||
|
* which lets the page call the editor directly. False with DRAWIO_BASE_URL,
|
||||||
|
* or when the bundled copy is missing.
|
||||||
|
*/
|
||||||
|
export function isSameOriginDrawio(): boolean {
|
||||||
|
return !EXTERNAL_DRAWIO && drawioDir !== null
|
||||||
|
}
|
||||||
|
|
||||||
// Extract origin (scheme + host + port) from URL for postMessage security check
|
// Extract origin (scheme + host + port) from URL for postMessage security check
|
||||||
function getOrigin(url: string): string {
|
function getOrigin(url: string): string {
|
||||||
@@ -148,8 +257,10 @@ interface SessionState {
|
|||||||
exportFormat?: ExportFormat // Set by MCP tool to request browser export
|
exportFormat?: ExportFormat // Set by MCP tool to request browser export
|
||||||
exportXml?: string // Single-page projection to load before a page-targeted export
|
exportXml?: string // Single-page projection to load before a page-targeted export
|
||||||
exportOptions?: ExportOptions // Extra draw.io export parameters (PNG only)
|
exportOptions?: ExportOptions // Extra draw.io export parameters (PNG only)
|
||||||
exportId?: number // Number of the pending export, echoed with its result
|
exportId?: string // Random id of the pending export, echoed with its result
|
||||||
exportData?: string // Base64/SVG data returned by browser after export
|
exportData?: string // Base64/SVG data returned by browser after export
|
||||||
|
selectionId?: string // Random id of the pending get_selection request
|
||||||
|
selection?: SelectionAnswer // The page's answer to it
|
||||||
}
|
}
|
||||||
|
|
||||||
/** draw.io export formats; xmlsvg is an SVG with the diagram embedded */
|
/** draw.io export formats; xmlsvg is an SVG with the diagram embedded */
|
||||||
@@ -224,6 +335,8 @@ export function setState(
|
|||||||
exportOptions: existing?.exportOptions,
|
exportOptions: existing?.exportOptions,
|
||||||
exportId: existing?.exportId,
|
exportId: existing?.exportId,
|
||||||
exportData: existing?.exportData, // Preserve export result
|
exportData: existing?.exportData, // Preserve export result
|
||||||
|
selectionId: existing?.selectionId, // Preserve pending selection request
|
||||||
|
selection: existing?.selection,
|
||||||
})
|
})
|
||||||
log.debug(`State updated: session=${sessionId}, version=${newVersion}`)
|
log.debug(`State updated: session=${sessionId}, version=${newVersion}`)
|
||||||
if (notify) stateListener?.(sessionId, xml)
|
if (notify) stateListener?.(sessionId, xml)
|
||||||
@@ -242,7 +355,7 @@ export function keepInHistory(sessionId: string, xml: string, svg = ""): void {
|
|||||||
if (state && !state.userEdited && last && last.xml === state.serverXml) {
|
if (state && !state.userEdited && last && last.xml === state.serverXml) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
addHistory(sessionId, xml, svg)
|
addHistory(sessionId, xml, svg, state?.userEdited ? "user" : undefined)
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -269,13 +382,13 @@ export function requestExport(
|
|||||||
state.exportOptions = options
|
state.exportOptions = options
|
||||||
state.exportFormat = format
|
state.exportFormat = format
|
||||||
// The browser sends this back with the result, so a late result of an
|
// The browser sends this back with the result, so a late result of an
|
||||||
// export that timed out is not taken for this one
|
// export that timed out is not taken for this one. Random rather than
|
||||||
state.exportId = ++lastExportId
|
// counted: a counter restarts with the process, and the page retries a
|
||||||
|
// result refused with 403 against the process that took over the port
|
||||||
|
state.exportId = randomUUID()
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
let lastExportId = 0
|
|
||||||
|
|
||||||
export function requestSync(sessionId: string): boolean {
|
export function requestSync(sessionId: string): boolean {
|
||||||
const state = stateStore.get(sessionId)
|
const state = stateStore.get(sessionId)
|
||||||
if (state) {
|
if (state) {
|
||||||
@@ -301,6 +414,95 @@ export async function waitForSync(
|
|||||||
return false // Timeout
|
return false // Timeout
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ask the preview tab which cells the user has selected (the get_selection
|
||||||
|
* tool). Answered through the poll like an export: the tab sees selectionId
|
||||||
|
* in GET /api/state and POSTs its reading with that id. Returns false when
|
||||||
|
* the session is unknown.
|
||||||
|
*/
|
||||||
|
export function requestSelection(sessionId: string): boolean {
|
||||||
|
const state = stateStore.get(sessionId)
|
||||||
|
if (!state) return false
|
||||||
|
state.selection = undefined
|
||||||
|
// Random, as exportId: a late answer to an earlier request, or one meant
|
||||||
|
// for the process that had this port before, is not taken for this one
|
||||||
|
state.selectionId = randomUUID()
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The tab's answer to the pending selection request, or null in time */
|
||||||
|
export async function waitForSelection(
|
||||||
|
sessionId: string,
|
||||||
|
timeoutMs = 10000,
|
||||||
|
): Promise<SelectionAnswer | null> {
|
||||||
|
const start = Date.now()
|
||||||
|
let answer: SelectionAnswer | undefined
|
||||||
|
while (Date.now() - start < timeoutMs) {
|
||||||
|
// Re-read the store entry each tick: setState replaces it
|
||||||
|
answer = stateStore.get(sessionId)?.selection
|
||||||
|
if (answer) break
|
||||||
|
await new Promise((r) => setTimeout(r, 100))
|
||||||
|
}
|
||||||
|
const state = stateStore.get(sessionId)
|
||||||
|
if (state) {
|
||||||
|
state.selection = undefined
|
||||||
|
state.selectionId = undefined
|
||||||
|
}
|
||||||
|
if (!answer) log.warn(`Selection timeout for session=${sessionId}`)
|
||||||
|
return answer ?? null
|
||||||
|
}
|
||||||
|
|
||||||
|
// One selection slot per session, as the export slot: overlapping
|
||||||
|
// get_selection calls take turns instead of replacing each other's request
|
||||||
|
let selectionQueue: Promise<unknown> = Promise.resolve()
|
||||||
|
|
||||||
|
/** requestSelection + waitForSelection, one call at a time; null when the
|
||||||
|
* session is unknown or the tab does not answer in time */
|
||||||
|
export function readSelection(
|
||||||
|
sessionId: string,
|
||||||
|
timeoutMs = 10000,
|
||||||
|
): Promise<SelectionAnswer | null> {
|
||||||
|
const run = selectionQueue.then(() => {
|
||||||
|
if (!requestSelection(sessionId)) return null
|
||||||
|
return waitForSelection(sessionId, timeoutMs)
|
||||||
|
})
|
||||||
|
selectionQueue = run.catch(() => {})
|
||||||
|
return run
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* What the version cards depend on: History's entries (their count and the
|
||||||
|
* newest id) and the entry the canvas shows. The shell reads History again
|
||||||
|
* only when this changes, so a hand edit (a new version, the same History)
|
||||||
|
* costs no download of every entry
|
||||||
|
*/
|
||||||
|
function historyKeyOf(sessionId: string, state?: SessionState): string {
|
||||||
|
const history = getHistory(sessionId)
|
||||||
|
const current =
|
||||||
|
state && history.length > 0
|
||||||
|
? currentEntryId(sessionId, state.xml)
|
||||||
|
: null
|
||||||
|
return `${history.length}:${history.at(-1)?.id ?? -1}:${current}`
|
||||||
|
}
|
||||||
|
|
||||||
|
/** POST /api/state with a selection: the tab's answer to requestSelection */
|
||||||
|
function handleSelectionResult(
|
||||||
|
sessionId: string,
|
||||||
|
data: { selectionId?: unknown; selection?: unknown },
|
||||||
|
): void {
|
||||||
|
const state = stateStore.get(sessionId)
|
||||||
|
// Only the pending request's answer (none pending: none is taken)
|
||||||
|
if (!state?.selectionId || data.selectionId !== state.selectionId) {
|
||||||
|
log.debug(`Ignored a late selection answer for session=${sessionId}`)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const answer = parseSelectionAnswer(data.selection)
|
||||||
|
if (!answer) return
|
||||||
|
state.selection = answer
|
||||||
|
state.selectionId = undefined
|
||||||
|
log.debug(`Selection received for session=${sessionId}`)
|
||||||
|
}
|
||||||
|
|
||||||
export function startHttpServer(port = 6002): Promise<number> {
|
export function startHttpServer(port = 6002): Promise<number> {
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
if (server) {
|
if (server) {
|
||||||
@@ -334,6 +536,16 @@ export function startHttpServer(port = 6002): Promise<number> {
|
|||||||
server.listen(port, "127.0.0.1", () => {
|
server.listen(port, "127.0.0.1", () => {
|
||||||
serverPort = port
|
serverPort = port
|
||||||
log.info(`HTTP server running on http://localhost:${port}`)
|
log.info(`HTTP server running on http://localhost:${port}`)
|
||||||
|
if (!EXTERNAL_DRAWIO && drawioDir === null) {
|
||||||
|
log.warn(
|
||||||
|
"No bundled draw.io (dist/drawio missing); the preview loads it from embed.diagrams.net",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if (PREVIEW_UI === "shell" && activePreviewUi() === "classic") {
|
||||||
|
log.warn(
|
||||||
|
"The canvas shell is not built (dist/shell missing); start_session opens the classic page",
|
||||||
|
)
|
||||||
|
}
|
||||||
resolve(port)
|
resolve(port)
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
@@ -424,7 +636,10 @@ function routeRequest(
|
|||||||
if (requestOrigin === `http://localhost:${serverPort}`) {
|
if (requestOrigin === `http://localhost:${serverPort}`) {
|
||||||
res.setHeader("Access-Control-Allow-Origin", requestOrigin)
|
res.setHeader("Access-Control-Allow-Origin", requestOrigin)
|
||||||
res.setHeader("Access-Control-Allow-Methods", "GET, POST, OPTIONS")
|
res.setHeader("Access-Control-Allow-Methods", "GET, POST, OPTIONS")
|
||||||
res.setHeader("Access-Control-Allow-Headers", "Content-Type")
|
res.setHeader(
|
||||||
|
"Access-Control-Allow-Headers",
|
||||||
|
"Content-Type, X-Drawio-Token",
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
if (req.method === "OPTIONS") {
|
if (req.method === "OPTIONS") {
|
||||||
@@ -433,30 +648,43 @@ function routeRequest(
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (url.pathname.startsWith("/drawio/")) {
|
||||||
|
serveStaticFile(
|
||||||
|
req,
|
||||||
|
res,
|
||||||
|
drawioDir,
|
||||||
|
url.pathname.slice("/drawio/".length),
|
||||||
|
drawioVersion,
|
||||||
|
)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (url.pathname === "/shell") {
|
||||||
|
res.writeHead(302, { Location: `/shell/${url.search}` })
|
||||||
|
res.end()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (url.pathname.startsWith("/shell/")) {
|
||||||
|
const rest = url.pathname.slice("/shell/".length)
|
||||||
|
if (rest === "" || rest === "index.html") {
|
||||||
|
servePage(req, res, url, "/shell/", getShellPage)
|
||||||
|
} else {
|
||||||
|
serveStaticFile(req, res, shellDir, rest, PACKAGE_VERSION)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
url.pathname.startsWith("/api/") &&
|
||||||
|
req.headers["x-drawio-token"] !== API_TOKEN
|
||||||
|
) {
|
||||||
|
res.writeHead(403)
|
||||||
|
res.end("Forbidden")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
if (url.pathname === "/" || url.pathname === "/index.html") {
|
if (url.pathname === "/" || url.pathname === "/index.html") {
|
||||||
const sessionId = url.searchParams.get("mcp") || ""
|
servePage(req, res, url, "/", getHtmlPage)
|
||||||
if (sessionId && !isValidSessionId(sessionId)) {
|
|
||||||
res.writeHead(400)
|
|
||||||
res.end("Invalid session id")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Auto-redirect to most recent session if no sessionId provided
|
|
||||||
if (!sessionId) {
|
|
||||||
const recentSessionId = getMostRecentSessionId()
|
|
||||||
if (recentSessionId) {
|
|
||||||
res.writeHead(302, {
|
|
||||||
Location: `/?mcp=${encodeURIComponent(recentSessionId)}`,
|
|
||||||
})
|
|
||||||
res.end()
|
|
||||||
return
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
ensureSessionStateInitialized(sessionId)
|
|
||||||
|
|
||||||
res.writeHead(200, { "Content-Type": "text/html" })
|
|
||||||
res.end(getHtmlPage(sessionId))
|
|
||||||
} else if (url.pathname === "/api/state") {
|
} else if (url.pathname === "/api/state") {
|
||||||
handleStateApi(req, res, url)
|
handleStateApi(req, res, url)
|
||||||
} else if (url.pathname === "/api/history") {
|
} else if (url.pathname === "/api/history") {
|
||||||
@@ -471,6 +699,60 @@ function routeRequest(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A preview page (the classic one at "/", the shell at "/shell/") for the
|
||||||
|
* session in ?mcp=<id>; without one, the most recent session's.
|
||||||
|
*/
|
||||||
|
function servePage(
|
||||||
|
req: http.IncomingMessage,
|
||||||
|
res: http.ServerResponse,
|
||||||
|
url: URL,
|
||||||
|
pagePath: string,
|
||||||
|
render: (sessionId: string) => string | null,
|
||||||
|
): void {
|
||||||
|
if (req.method !== "GET" && req.method !== "HEAD") {
|
||||||
|
res.writeHead(405)
|
||||||
|
res.end("Method Not Allowed")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const sessionId = url.searchParams.get("mcp") || ""
|
||||||
|
if (sessionId && !isValidSessionId(sessionId)) {
|
||||||
|
res.writeHead(400)
|
||||||
|
res.end("Invalid session id")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Auto-redirect to most recent session if no sessionId provided
|
||||||
|
if (!sessionId) {
|
||||||
|
const recentSessionId = getMostRecentSessionId()
|
||||||
|
if (recentSessionId) {
|
||||||
|
res.writeHead(302, {
|
||||||
|
Location: `${pagePath}?mcp=${encodeURIComponent(recentSessionId)}`,
|
||||||
|
})
|
||||||
|
res.end()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const html = render(sessionId)
|
||||||
|
if (html === null) {
|
||||||
|
res.writeHead(404, { "Content-Type": "text/plain; charset=utf-8" })
|
||||||
|
res.end(
|
||||||
|
"The canvas shell is not built (dist/shell is missing); run npm run build in packages/mcp-server",
|
||||||
|
)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
ensureSessionStateInitialized(sessionId)
|
||||||
|
|
||||||
|
// The page holds this process's token: never served from a cache
|
||||||
|
res.writeHead(200, {
|
||||||
|
"Content-Type": "text/html; charset=utf-8",
|
||||||
|
"Cache-Control": "no-store",
|
||||||
|
...HTML_SECURITY_HEADERS,
|
||||||
|
})
|
||||||
|
res.end(req.method === "HEAD" ? undefined : html)
|
||||||
|
}
|
||||||
|
|
||||||
function handleStateApi(
|
function handleStateApi(
|
||||||
req: http.IncomingMessage,
|
req: http.IncomingMessage,
|
||||||
res: http.ServerResponse,
|
res: http.ServerResponse,
|
||||||
@@ -500,6 +782,8 @@ function handleStateApi(
|
|||||||
exportXml: state?.exportXml || null,
|
exportXml: state?.exportXml || null,
|
||||||
exportOptions: state?.exportOptions || null,
|
exportOptions: state?.exportOptions || null,
|
||||||
exportId: state?.exportId ?? null,
|
exportId: state?.exportId ?? null,
|
||||||
|
selectionId: state?.selectionId ?? null,
|
||||||
|
historyKey: historyKeyOf(sessionId, state),
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
} else if (req.method === "POST") {
|
} else if (req.method === "POST") {
|
||||||
@@ -537,6 +821,14 @@ function handleStateApi(
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// The tab is answering a selection request (get_selection)
|
||||||
|
if (data.selection !== undefined) {
|
||||||
|
handleSelectionResult(sessionId, data)
|
||||||
|
res.writeHead(200, { "Content-Type": "application/json" })
|
||||||
|
res.end(JSON.stringify({ success: true }))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
// A push can come before the tab's first poll after a
|
// A push can come before the tab's first poll after a
|
||||||
// restart: recover the saved file first, so it is compared
|
// restart: recover the saved file first, so it is compared
|
||||||
// with that and never overwrites it unseen
|
// with that and never overwrites it unseen
|
||||||
@@ -568,7 +860,14 @@ function handleStateApi(
|
|||||||
!!data.xml &&
|
!!data.xml &&
|
||||||
data.xml !== current.xml
|
data.xml !== current.xml
|
||||||
if (saved) {
|
if (saved) {
|
||||||
addHistory(sessionId, data.xml, data.svg || "")
|
addHistory(
|
||||||
|
sessionId,
|
||||||
|
data.xml,
|
||||||
|
data.svg || "",
|
||||||
|
"recover",
|
||||||
|
)
|
||||||
|
// Saved with the History, as after any change
|
||||||
|
stateListener?.(sessionId, current.xml)
|
||||||
}
|
}
|
||||||
res.writeHead(409, {
|
res.writeHead(409, {
|
||||||
"Content-Type": "application/json",
|
"Content-Type": "application/json",
|
||||||
@@ -603,8 +902,9 @@ function handleStateApi(
|
|||||||
} else if (typeof data.xml === "string" && data.xml) {
|
} else if (typeof data.xml === "string" && data.xml) {
|
||||||
// A user edit lost the race with an AI write. Keep
|
// A user edit lost the race with an AI write. Keep
|
||||||
// it in history so the user can restore it.
|
// it in history so the user can restore it.
|
||||||
addHistory(sessionId, data.xml, data.svg || "")
|
addHistory(sessionId, data.xml, data.svg || "", "user")
|
||||||
savedToHistory = true
|
savedToHistory = true
|
||||||
|
if (current) stateListener?.(sessionId, current.xml)
|
||||||
}
|
}
|
||||||
res.writeHead(409, { "Content-Type": "application/json" })
|
res.writeHead(409, { "Content-Type": "application/json" })
|
||||||
res.end(
|
res.end(
|
||||||
@@ -626,7 +926,13 @@ function handleStateApi(
|
|||||||
const saved = stateStore.get(sessionId)
|
const saved = stateStore.get(sessionId)
|
||||||
if (saved && data.source !== "sync") saved.userEdited = true
|
if (saved && data.source !== "sync") saved.userEdited = true
|
||||||
res.writeHead(200, { "Content-Type": "application/json" })
|
res.writeHead(200, { "Content-Type": "application/json" })
|
||||||
res.end(JSON.stringify({ success: true, version }))
|
res.end(
|
||||||
|
JSON.stringify({
|
||||||
|
success: true,
|
||||||
|
version,
|
||||||
|
historyKey: historyKeyOf(sessionId, saved),
|
||||||
|
}),
|
||||||
|
)
|
||||||
} catch {
|
} catch {
|
||||||
res.writeHead(400, { "Content-Type": "application/json" })
|
res.writeHead(400, { "Content-Type": "application/json" })
|
||||||
res.end(JSON.stringify({ error: "Invalid JSON" }))
|
res.end(JSON.stringify({ error: "Invalid JSON" }))
|
||||||
@@ -656,7 +962,12 @@ function handleHistoryApi(
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// The classic page reads index, id and svg; the shell's version cards
|
||||||
|
// the rest: the document, who made it, which earlier entry it is a copy
|
||||||
|
// of, the state the ids belong to, and the entry the canvas shows
|
||||||
const history = getHistory(sessionId)
|
const history = getHistory(sessionId)
|
||||||
|
const firstIds = firstCopyIds(sessionId)
|
||||||
|
const state = stateStore.get(sessionId)
|
||||||
res.writeHead(200, { "Content-Type": "application/json" })
|
res.writeHead(200, { "Content-Type": "application/json" })
|
||||||
res.end(
|
res.end(
|
||||||
JSON.stringify({
|
JSON.stringify({
|
||||||
@@ -664,12 +975,47 @@ function handleHistoryApi(
|
|||||||
index: i,
|
index: i,
|
||||||
id: entry.id,
|
id: entry.id,
|
||||||
svg: entry.svg,
|
svg: entry.svg,
|
||||||
|
xml: entry.xml,
|
||||||
|
source: entry.source ?? null,
|
||||||
|
firstId: firstIds.get(entry.id),
|
||||||
})),
|
})),
|
||||||
count: history.length,
|
count: history.length,
|
||||||
|
stateId: state?.stateId ?? null,
|
||||||
|
currentId: state ? currentEntryId(sessionId, state.xml) : null,
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Put a History entry back on the canvas (the preview page's Restore button
|
||||||
|
* and the restore_version tool). Returns the new version, or null when the
|
||||||
|
* entry is unknown.
|
||||||
|
*/
|
||||||
|
export function restoreHistoryEntry(
|
||||||
|
sessionId: string,
|
||||||
|
entryId: number,
|
||||||
|
): number | null {
|
||||||
|
const entry = getHistoryEntry(sessionId, entryId)
|
||||||
|
if (!entry) return null
|
||||||
|
|
||||||
|
// Edits in the browser since the last entry are not in history
|
||||||
|
// yet: keep them, so the restore can be undone
|
||||||
|
// (any state besides a blank page; a cleared document with its
|
||||||
|
// own pages counts)
|
||||||
|
const current = stateStore.get(sessionId)
|
||||||
|
if (
|
||||||
|
current &&
|
||||||
|
contentFingerprint(current.xml) !== contentFingerprint(BLANK_MXFILE)
|
||||||
|
) {
|
||||||
|
keepInHistory(sessionId, current.xml, current.svg)
|
||||||
|
}
|
||||||
|
const newVersion = setState(sessionId, entry.xml)
|
||||||
|
addHistory(sessionId, entry.xml, entry.svg, "restore")
|
||||||
|
|
||||||
|
log.info(`Restored session ${sessionId} to history entry ${entryId}`)
|
||||||
|
return newVersion
|
||||||
|
}
|
||||||
|
|
||||||
function handleRestoreApi(
|
function handleRestoreApi(
|
||||||
req: http.IncomingMessage,
|
req: http.IncomingMessage,
|
||||||
res: http.ServerResponse,
|
res: http.ServerResponse,
|
||||||
@@ -682,37 +1028,34 @@ function handleRestoreApi(
|
|||||||
|
|
||||||
readBody(req, res, (body) => {
|
readBody(req, res, (body) => {
|
||||||
try {
|
try {
|
||||||
const { sessionId, id } = JSON.parse(body)
|
const data = JSON.parse(body)
|
||||||
|
const { sessionId, id } = data
|
||||||
if (!sessionId || typeof id !== "number") {
|
if (!sessionId || typeof id !== "number") {
|
||||||
res.writeHead(400, { "Content-Type": "application/json" })
|
res.writeHead(400, { "Content-Type": "application/json" })
|
||||||
res.end(JSON.stringify({ error: "sessionId and id required" }))
|
res.end(JSON.stringify({ error: "sessionId and id required" }))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
// Picked from the list of a state the server has since lost
|
||||||
|
// (a restart reloads History under new ids), or before the tab
|
||||||
|
// knew the state. A tab of an older version sends none.
|
||||||
|
const current = stateStore.get(sessionId)
|
||||||
|
if (
|
||||||
|
current &&
|
||||||
|
"stateId" in data &&
|
||||||
|
data.stateId !== current.stateId
|
||||||
|
) {
|
||||||
|
res.writeHead(409, { "Content-Type": "application/json" })
|
||||||
|
res.end(JSON.stringify({ error: "Session was recreated" }))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
const entry = getHistoryEntry(sessionId, id)
|
const newVersion = restoreHistoryEntry(sessionId, id)
|
||||||
if (!entry) {
|
if (newVersion === null) {
|
||||||
res.writeHead(404, { "Content-Type": "application/json" })
|
res.writeHead(404, { "Content-Type": "application/json" })
|
||||||
res.end(JSON.stringify({ error: "Entry not found" }))
|
res.end(JSON.stringify({ error: "Entry not found" }))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Edits in the browser since the last entry are not in history
|
|
||||||
// yet: keep them, so the restore can be undone
|
|
||||||
// (any state besides a blank page; a cleared document with its
|
|
||||||
// own pages counts)
|
|
||||||
const current = stateStore.get(sessionId)
|
|
||||||
if (
|
|
||||||
current &&
|
|
||||||
contentFingerprint(current.xml) !==
|
|
||||||
contentFingerprint(BLANK_MXFILE)
|
|
||||||
) {
|
|
||||||
keepInHistory(sessionId, current.xml, current.svg)
|
|
||||||
}
|
|
||||||
const newVersion = setState(sessionId, entry.xml)
|
|
||||||
addHistory(sessionId, entry.xml, entry.svg)
|
|
||||||
|
|
||||||
log.info(`Restored session ${sessionId} to history entry ${id}`)
|
|
||||||
|
|
||||||
res.writeHead(200, { "Content-Type": "application/json" })
|
res.writeHead(200, { "Content-Type": "application/json" })
|
||||||
res.end(JSON.stringify({ success: true, newVersion }))
|
res.end(JSON.stringify({ success: true, newVersion }))
|
||||||
} catch {
|
} catch {
|
||||||
@@ -764,8 +1107,121 @@ function handleHistorySvgApi(
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Pages may be framed only by this origin, and nothing is content-sniffed
|
||||||
|
const HTML_SECURITY_HEADERS = {
|
||||||
|
"Content-Security-Policy": "frame-ancestors 'self'",
|
||||||
|
"X-Content-Type-Options": "nosniff",
|
||||||
|
}
|
||||||
|
|
||||||
|
const MIME_TYPES: Record<string, string> = {
|
||||||
|
".html": "text/html; charset=utf-8",
|
||||||
|
".js": "text/javascript; charset=utf-8",
|
||||||
|
".css": "text/css; charset=utf-8",
|
||||||
|
".svg": "image/svg+xml",
|
||||||
|
".png": "image/png",
|
||||||
|
".gif": "image/gif",
|
||||||
|
".jpg": "image/jpeg",
|
||||||
|
".jpeg": "image/jpeg",
|
||||||
|
".woff": "font/woff",
|
||||||
|
".woff2": "font/woff2",
|
||||||
|
".ttf": "font/ttf",
|
||||||
|
".txt": "text/plain; charset=utf-8",
|
||||||
|
".xml": "text/xml; charset=utf-8",
|
||||||
|
".json": "application/json",
|
||||||
|
".ico": "image/x-icon",
|
||||||
|
".wasm": "application/wasm",
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /drawio/<path> or /shell/<path>: a file of the bundled draw.io copy or
|
||||||
|
* of the built shell. `stamp` names the files' version in their ETags.
|
||||||
|
*/
|
||||||
|
function serveStaticFile(
|
||||||
|
req: http.IncomingMessage,
|
||||||
|
res: http.ServerResponse,
|
||||||
|
dir: string | null,
|
||||||
|
rawPath: string,
|
||||||
|
stamp: string,
|
||||||
|
): void {
|
||||||
|
if (req.method !== "GET" && req.method !== "HEAD") {
|
||||||
|
res.writeHead(405)
|
||||||
|
res.end("Method Not Allowed")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
let rel: string
|
||||||
|
try {
|
||||||
|
rel = decodeURIComponent(rawPath)
|
||||||
|
} catch {
|
||||||
|
res.writeHead(400)
|
||||||
|
res.end("Bad Request")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// One normalized path inside the directory; the war's server-side parts
|
||||||
|
// are never served, whatever was extracted
|
||||||
|
const normalized = posix.normalize(rel)
|
||||||
|
const file = dir ? resolve(dir, normalized) : null
|
||||||
|
if (
|
||||||
|
!dir ||
|
||||||
|
!file ||
|
||||||
|
/[\\\0]/.test(rel) ||
|
||||||
|
/(^|\/)(WEB-INF|META-INF)(\/|$)/i.test(normalized) ||
|
||||||
|
!file.startsWith(dir + sep)
|
||||||
|
) {
|
||||||
|
res.writeHead(404)
|
||||||
|
res.end("Not Found")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
let size: number
|
||||||
|
let etag: string
|
||||||
|
try {
|
||||||
|
const stat = statSync(file)
|
||||||
|
if (!stat.isFile()) throw new Error("not a file")
|
||||||
|
size = stat.size
|
||||||
|
etag = `"${stamp}-${size.toString(16)}-${Math.floor(stat.mtimeMs).toString(16)}"`
|
||||||
|
} catch {
|
||||||
|
res.writeHead(404)
|
||||||
|
res.end("Not Found")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// The file names do not change between draw.io versions, so the browser
|
||||||
|
// may keep a copy but asks before using it (a 304 from localhost is
|
||||||
|
// cheap); after a package upgrade the next preview gets the new files
|
||||||
|
const cacheHeaders = {
|
||||||
|
ETag: etag,
|
||||||
|
"Cache-Control": "no-cache",
|
||||||
|
"X-Content-Type-Options": "nosniff",
|
||||||
|
}
|
||||||
|
if (req.headers["if-none-match"] === etag) {
|
||||||
|
res.writeHead(304, cacheHeaders)
|
||||||
|
res.end()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const ext = extname(file).toLowerCase()
|
||||||
|
res.writeHead(200, {
|
||||||
|
...cacheHeaders,
|
||||||
|
"Content-Type": MIME_TYPES[ext] || "application/octet-stream",
|
||||||
|
"Content-Length": size,
|
||||||
|
...(ext === ".html" ? HTML_SECURITY_HEADERS : {}),
|
||||||
|
})
|
||||||
|
if (req.method === "HEAD") {
|
||||||
|
res.end()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// pipeline closes both ends when either fails or goes away (a tab closed
|
||||||
|
// mid-download), so no file handle leaks and no error goes unhandled; a
|
||||||
|
// client that left early is routine, not worth a log line
|
||||||
|
pipeline(createReadStream(file), res, () => {})
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Where the iframe loads the editor from (without its query) */
|
||||||
|
function drawioEditorUrl(): string {
|
||||||
|
return isSameOriginDrawio()
|
||||||
|
? "/drawio/index.html"
|
||||||
|
: `${normalizeUrl(DRAWIO_BASE_URL)}/`
|
||||||
|
}
|
||||||
|
|
||||||
// The preview page lives in src/preview (the build copies it to dist/preview)
|
// The preview page lives in src/preview (the build copies it to dist/preview)
|
||||||
const PREVIEW_DIR = join(dirname(fileURLToPath(import.meta.url)), "preview")
|
const PREVIEW_DIR = join(HERE, "preview")
|
||||||
let previewTemplate: string | null = null
|
let previewTemplate: string | null = null
|
||||||
|
|
||||||
function loadPreviewTemplate(): string {
|
function loadPreviewTemplate(): string {
|
||||||
@@ -779,19 +1235,108 @@ function loadPreviewTemplate(): string {
|
|||||||
return previewTemplate
|
return previewTemplate
|
||||||
}
|
}
|
||||||
|
|
||||||
/** A JSON string literal that is safe inside a <script> element */
|
/** JSON that is safe inside a <script> element ("<" escaped) */
|
||||||
const scriptJson = (value: string) =>
|
const scriptJson = (value: unknown) =>
|
||||||
JSON.stringify(value).replace(/</g, "\\u003c")
|
JSON.stringify(value).replace(/</g, "\\u003c")
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The editor settings the host config fixes: theme (DRAWIO_UI), language
|
||||||
|
* (DRAWIO_LANG) and dark mode (DRAWIO_DARK). ui and lang are "" unless set,
|
||||||
|
* because draw.io hides its Extras > Language / Theme submenu once they
|
||||||
|
* are given.
|
||||||
|
*/
|
||||||
|
function hostDrawioSettings(env: NodeJS.ProcessEnv): {
|
||||||
|
ui: string
|
||||||
|
lang: string
|
||||||
|
dark: "dark" | "light" | "auto"
|
||||||
|
} {
|
||||||
|
const ui = (env.DRAWIO_UI ?? "").toLowerCase()
|
||||||
|
const dark = (env.DRAWIO_DARK ?? "").toLowerCase()
|
||||||
|
const lang = toDrawioLang(env.DRAWIO_LANG ?? "")
|
||||||
|
return {
|
||||||
|
ui: isDrawioTheme(ui) ? ui : "",
|
||||||
|
lang: /^[a-z]{2,3}(-[a-z]{2,4})?$/.test(lang) ? lang : "",
|
||||||
|
dark: ["1", "true", "dark"].includes(dark)
|
||||||
|
? "dark"
|
||||||
|
: ["0", "false", "light"].includes(dark)
|
||||||
|
? "light"
|
||||||
|
: // draw.io takes ui=dark as dark mode only when no dark
|
||||||
|
// parameter is present, and the pages always send one
|
||||||
|
!dark && ui === "dark"
|
||||||
|
? "dark"
|
||||||
|
: "auto",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The configurable tail of the classic page's draw.io iframe query: dark
|
||||||
|
* mode, and the language and theme when the host config fixes them.
|
||||||
|
*/
|
||||||
|
export function drawioEmbedParams(
|
||||||
|
env: NodeJS.ProcessEnv = process.env,
|
||||||
|
): string {
|
||||||
|
const settings = hostDrawioSettings(env)
|
||||||
|
const params = new URLSearchParams()
|
||||||
|
params.set(
|
||||||
|
"dark",
|
||||||
|
settings.dark === "auto"
|
||||||
|
? "auto"
|
||||||
|
: settings.dark === "dark"
|
||||||
|
? "1"
|
||||||
|
: "0",
|
||||||
|
)
|
||||||
|
if (settings.lang) params.set("lang", settings.lang)
|
||||||
|
if (settings.ui) params.set("ui", settings.ui)
|
||||||
|
return params.toString()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* What the shell page gets as window.__MCP_CONFIG__ (shell/runtime-config.ts
|
||||||
|
* reads it): the session, the API token, where draw.io comes from and the
|
||||||
|
* host's editor settings.
|
||||||
|
*/
|
||||||
|
export function shellConfig(
|
||||||
|
sessionId: string,
|
||||||
|
env: NodeJS.ProcessEnv = process.env,
|
||||||
|
): Record<string, string> {
|
||||||
|
const settings = hostDrawioSettings(env)
|
||||||
|
return {
|
||||||
|
sessionId,
|
||||||
|
token: API_TOKEN,
|
||||||
|
apiBase: "/api",
|
||||||
|
drawioBaseUrl: drawioEditorUrl(),
|
||||||
|
drawioUi: settings.ui,
|
||||||
|
drawioLang: settings.lang,
|
||||||
|
drawioDark: settings.dark,
|
||||||
|
lang: env.DRAWIO_LANG ?? "",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The shell page, or null when the shell is not built */
|
||||||
|
function getShellPage(sessionId: string): string | null {
|
||||||
|
if (!shellDir) return null
|
||||||
|
const template = readFileSync(join(shellDir, "index.html"), "utf8")
|
||||||
|
return template.replace("{{CONFIG_JSON}}", () =>
|
||||||
|
scriptJson(shellConfig(sessionId)),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
function getHtmlPage(sessionId: string): string {
|
function getHtmlPage(sessionId: string): string {
|
||||||
return loadPreviewTemplate()
|
return (
|
||||||
.replace("{{SESSION_BADGE}}", () =>
|
loadPreviewTemplate()
|
||||||
sessionId
|
.replace("{{SESSION_BADGE}}", () =>
|
||||||
? `<span class="session">${sessionId.slice(-8)}</span>`
|
sessionId
|
||||||
: "",
|
? `<span class="session">${sessionId.slice(-8)}</span>`
|
||||||
)
|
: "",
|
||||||
.replaceAll("{{DISABLED}}", sessionId ? "" : "disabled")
|
)
|
||||||
.replace("{{DRAWIO_URL}}", () => normalizeUrl(DRAWIO_BASE_URL))
|
.replaceAll("{{DISABLED}}", sessionId ? "" : "disabled")
|
||||||
.replace("{{SESSION_JSON}}", () => scriptJson(sessionId))
|
.replace("{{DRAWIO_URL}}", () => drawioEditorUrl())
|
||||||
.replace("{{ORIGIN_JSON}}", () => scriptJson(DRAWIO_ORIGIN))
|
.replace("{{DRAWIO_PARAMS}}", () => drawioEmbedParams())
|
||||||
|
.replace("{{SESSION_JSON}}", () => scriptJson(sessionId))
|
||||||
|
// Same origin: the page fills in its own origin
|
||||||
|
.replace("{{ORIGIN_JSON}}", () =>
|
||||||
|
scriptJson(isSameOriginDrawio() ? "" : DRAWIO_ORIGIN),
|
||||||
|
)
|
||||||
|
.replace("{{TOKEN_JSON}}", () => scriptJson(API_TOKEN))
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
+762
-159
File diff suppressed because it is too large
Load Diff
@@ -18,7 +18,7 @@ import {
|
|||||||
import { getXmlSyntaxError } from "./xml-syntax.ts"
|
import { getXmlSyntaxError } from "./xml-syntax.ts"
|
||||||
|
|
||||||
export type LoadResult =
|
export type LoadResult =
|
||||||
| { ok: true; xml: string }
|
| { ok: true; xml: string; hadCompressedPages: boolean }
|
||||||
| { ok: false; error: string }
|
| { ok: false; error: string }
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -43,11 +43,27 @@ export function decompressPageContent(compressed: string): string | null {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Read the diagram XML a .drawio.svg (Editable SVG) carries in its root
|
||||||
|
* element's content attribute. Returns undefined when the text is not an
|
||||||
|
* SVG, and "" when the SVG has no embedded diagram.
|
||||||
|
*
|
||||||
|
* Uses getAttributeNode().value rather than getAttribute(): linkedom's
|
||||||
|
* getAttribute re-escapes <, > and & in XML documents, which would turn
|
||||||
|
* the embedded "<mxfile" into "<mxfile".
|
||||||
|
*/
|
||||||
|
export function extractSvgEmbeddedXml(text: string): string | undefined {
|
||||||
|
const doc = new DOMParser().parseFromString(text, "text/xml")
|
||||||
|
const root = doc.documentElement
|
||||||
|
if (root?.tagName !== "svg") return undefined
|
||||||
|
return (root.getAttributeNode("content")?.value ?? "").trim()
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Parse the content of a .drawio file into the canonical session shape:
|
* Parse the content of a .drawio file into the canonical session shape:
|
||||||
* an <mxfile> whose every page holds plain <mxGraphModel> XML. Accepts a
|
* an <mxfile> whose every page holds plain <mxGraphModel> XML. Accepts a
|
||||||
* bare <mxGraphModel> (wrapped into a one-page mxfile) and decompresses
|
* bare <mxGraphModel> (wrapped into a one-page mxfile), a .drawio.svg with
|
||||||
* any compressed pages.
|
* the diagram embedded, and decompresses any compressed pages.
|
||||||
*/
|
*/
|
||||||
export function parseDrawioFileContent(content: string): LoadResult {
|
export function parseDrawioFileContent(content: string): LoadResult {
|
||||||
let trimmed = content.trim()
|
let trimmed = content.trim()
|
||||||
@@ -62,9 +78,25 @@ export function parseDrawioFileContent(content: string): LoadResult {
|
|||||||
trimmed = normalized
|
trimmed = normalized
|
||||||
}
|
}
|
||||||
if (!isMxFile(trimmed)) {
|
if (!isMxFile(trimmed)) {
|
||||||
|
const embedded = extractSvgEmbeddedXml(trimmed)
|
||||||
|
if (embedded === undefined) {
|
||||||
|
return {
|
||||||
|
ok: false,
|
||||||
|
error: "Not a draw.io file: expected an <mxfile> or <mxGraphModel> root element, or a .drawio.svg with the diagram embedded.",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!embedded) {
|
||||||
|
return {
|
||||||
|
ok: false,
|
||||||
|
error: "This SVG has no embedded diagram. Only .drawio.svg (Editable SVG) files can be loaded; ask for the .drawio file or redraw from the image.",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (isMxFile(embedded) || isMxGraphModel(embedded)) {
|
||||||
|
return parseDrawioFileContent(embedded)
|
||||||
|
}
|
||||||
return {
|
return {
|
||||||
ok: false,
|
ok: false,
|
||||||
error: "Not a draw.io file: expected an <mxfile> or <mxGraphModel> root element.",
|
error: "The SVG's embedded content is not draw.io XML.",
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
const doc = parseMxfile(trimmed)
|
const doc = parseMxfile(trimmed)
|
||||||
@@ -101,5 +133,9 @@ export function parseDrawioFileContent(content: string): LoadResult {
|
|||||||
decompressedAny = true
|
decompressedAny = true
|
||||||
}
|
}
|
||||||
// Nothing changed — keep the file's own serialisation.
|
// Nothing changed — keep the file's own serialisation.
|
||||||
return { ok: true, xml: decompressedAny ? serializeMxfile(doc) : trimmed }
|
return {
|
||||||
|
ok: true,
|
||||||
|
xml: decompressedAny ? serializeMxfile(doc) : trimmed,
|
||||||
|
hadCompressedPages: decompressedAny,
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,9 +2,24 @@
|
|||||||
* 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 {
|
||||||
|
generatePageId,
|
||||||
|
hasCells,
|
||||||
|
normalizeToMxfile,
|
||||||
|
parseMxfile,
|
||||||
|
serializeMxfile,
|
||||||
|
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 { validateAndFixXml } from "./xml-validation.ts"
|
import { getXmlSyntaxError } from "./xml-syntax.ts"
|
||||||
|
import { repairQuoteBeforeSlash, validateAndFixXml } from "./xml-validation.ts"
|
||||||
|
|
||||||
export type NewDiagram =
|
export type NewDiagram =
|
||||||
| { ok: true; xml: string; fixes: string[] }
|
| { ok: true; xml: string; fixes: string[] }
|
||||||
@@ -29,11 +44,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,29 +61,154 @@ export function reservedIdError(input: string): string | null {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Bare cells get the wrapper and root cells first, since the strict parser
|
* Check if mxCell XML output is complete (not truncated).
|
||||||
* rejects several top-level elements. Then the XML is validated and
|
* Complete XML ends with a self-closing tag (/>) or closing mxCell tag.
|
||||||
* auto-fixed while it is still a bare model, where duplicate ids are
|
* Uses a robust approach that handles any LLM provider's wrapper tags
|
||||||
* renamed, and finally turned into an <mxfile>.
|
* by finding the last valid mxCell ending and checking if suffix is just closing tags.
|
||||||
|
* @param xml - The XML string to check (can be undefined/null)
|
||||||
|
* @returns true if XML appears complete, false if truncated or empty
|
||||||
*/
|
*/
|
||||||
|
export function isMxCellXmlComplete(xml: string | undefined | null): boolean {
|
||||||
|
// 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
|
||||||
|
|
||||||
|
// Find position of last complete mxCell ending (either /> or </mxCell>)
|
||||||
|
const lastSelfClose = trimmed.lastIndexOf("/>")
|
||||||
|
const lastMxCellClose = trimmed.lastIndexOf("</mxCell>")
|
||||||
|
|
||||||
|
const lastValidEnd = Math.max(lastSelfClose, lastMxCellClose)
|
||||||
|
|
||||||
|
// No valid ending found at all
|
||||||
|
if (lastValidEnd === -1) return false
|
||||||
|
|
||||||
|
// If the last mxCell has no </mxCell> after it, it must be self-closing.
|
||||||
|
// Otherwise the trailing "/>" belongs to a child such as <mxGeometry .../>
|
||||||
|
// and the output was cut off before the cell was closed.
|
||||||
|
const lastCellStart = trimmed.lastIndexOf("<mxCell")
|
||||||
|
if (
|
||||||
|
lastCellStart > lastMxCellClose &&
|
||||||
|
// (quoted values may hold a raw "<", which the auto-fix escapes)
|
||||||
|
!/^<mxCell\b(?:[^<>"']|"[^"]*"|'[^']*')*\/>/.test(
|
||||||
|
trimmed.slice(lastCellStart),
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check what comes after the last valid ending
|
||||||
|
// For />: add 2 chars, for </mxCell>: add 9 chars
|
||||||
|
const endOffset = lastMxCellClose > lastSelfClose ? 9 : 2
|
||||||
|
const suffix = trimmed.slice(lastValidEnd + endOffset)
|
||||||
|
|
||||||
|
// If suffix is empty or only contains closing tags (any provider's wrapper) or whitespace, it's complete
|
||||||
|
// This regex matches any sequence of closing XML tags like </foo>, </bar>, </|DSML|xyz>
|
||||||
|
return /^(\s*<\/[^>]+>)*\s*$/.test(suffix)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Output that stops inside an mxCell would be wrapped and rejected with an
|
||||||
|
* error about wrapper tags the model never sent. Bare cells only. Returns the
|
||||||
|
* error for the model, or null.
|
||||||
|
*/
|
||||||
|
export function truncatedCellError(input: string): string | null {
|
||||||
|
if (/<(mxGraphModel|mxfile)\b/.test(input)) return null
|
||||||
|
// Comments, and closing tags such as </mxCell/> that the auto-fix
|
||||||
|
// repairs, are no cut
|
||||||
|
const stripped = input
|
||||||
|
.replace(/<!--[\s\S]*?-->/g, "")
|
||||||
|
.replace(/<\/([a-zA-Z][a-zA-Z0-9]*)\s*\/>/g, "</$1>")
|
||||||
|
// No cell at all: the validator's message fits better
|
||||||
|
if (!/<mxCell\b/.test(stripped) || isMxCellXmlComplete(stripped))
|
||||||
|
return null
|
||||||
|
return `The XML ends inside an unfinished mxCell, so the output was probably cut off. Nothing was drawn. It ended with:\n${input.slice(-300)}\nResend the complete diagram, or send the first cells now and add the remaining cells with edit_diagram add operations.`
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The named style definitions are taken out first (style-classes.ts). Bare
|
||||||
|
* cells then get the wrapper and root cells, since the strict parser rejects
|
||||||
|
* several top-level elements. Then the XML is validated and auto-fixed while
|
||||||
|
* 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)
|
const truncated = truncatedCellError(cells)
|
||||||
|
if (truncated) return { ok: false, error: truncated }
|
||||||
|
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}` }
|
||||||
}
|
}
|
||||||
const normalized = normalizeToMxfile(xml, page)
|
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}`,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let normalized = normalizeToMxfile(xml, page)
|
||||||
if (!normalized) {
|
if (!normalized) {
|
||||||
return {
|
return {
|
||||||
ok: false,
|
ok: false,
|
||||||
error: "XML must be the mxCell elements of one page, a <mxGraphModel>, or an <mxfile> with one or more <diagram> children.",
|
error: "XML must be the mxCell elements of one page, a <mxGraphModel>, or an <mxfile> with one or more <diagram> children.",
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// A <diagram> the model sent without an id gets one here. draw.io would
|
||||||
|
// otherwise make its own, which the server never sees, and get_selection
|
||||||
|
// would then name a page edit_diagram cannot find
|
||||||
|
const doc = parseMxfile(normalized)
|
||||||
|
if (doc) {
|
||||||
|
let added = false
|
||||||
|
doc.querySelectorAll("diagram").forEach((d) => {
|
||||||
|
if (!d.getAttribute("id")) {
|
||||||
|
d.setAttribute("id", generatePageId())
|
||||||
|
added = true
|
||||||
|
}
|
||||||
|
})
|
||||||
|
if (added) normalized = serializeMxfile(doc)
|
||||||
|
}
|
||||||
return { ok: true, xml: normalized, fixes }
|
return { ok: true, xml: normalized, fixes }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,7 +3,9 @@
|
|||||||
* diagram survives the MCP process (hosts start a new one when a
|
* diagram survives the MCP process (hosts start a new one when a
|
||||||
* conversation is resumed). Like the web app's IndexedDB sessions
|
* conversation is resumed). Like the web app's IndexedDB sessions
|
||||||
* (lib/session-storage.ts): saved 1 second after the last change, at most
|
* (lib/session-storage.ts): saved 1 second after the last change, at most
|
||||||
* 50 kept. History is not saved.
|
* 50 kept. The session's History (the XML of its last 20 versions and who
|
||||||
|
* made each, without the thumbnails) is saved next to it as
|
||||||
|
* <session-id>.history.json.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import {
|
import {
|
||||||
@@ -12,6 +14,7 @@ import {
|
|||||||
readdirSync,
|
readdirSync,
|
||||||
readFileSync,
|
readFileSync,
|
||||||
renameSync,
|
renameSync,
|
||||||
|
rmSync,
|
||||||
statSync,
|
statSync,
|
||||||
unlinkSync,
|
unlinkSync,
|
||||||
writeFileSync,
|
writeFileSync,
|
||||||
@@ -19,6 +22,7 @@ import {
|
|||||||
import { homedir } from "node:os"
|
import { homedir } from "node:os"
|
||||||
import { join } from "node:path"
|
import { join } from "node:path"
|
||||||
import { contentFingerprint } from "./edit-gate.ts"
|
import { contentFingerprint } from "./edit-gate.ts"
|
||||||
|
import type { HistorySource } from "./history.ts"
|
||||||
import { log } from "./logger.ts"
|
import { log } from "./logger.ts"
|
||||||
import { BLANK_MXFILE, hasCells } from "./pages.ts"
|
import { BLANK_MXFILE, hasCells } from "./pages.ts"
|
||||||
|
|
||||||
@@ -31,6 +35,16 @@ const isBlank = (xml: string) =>
|
|||||||
const DELAY_MS = 1000
|
const DELAY_MS = 1000
|
||||||
const MAX_FILES = 50
|
const MAX_FILES = 50
|
||||||
|
|
||||||
|
/** A History entry as the file keeps it */
|
||||||
|
export interface SavedHistoryEntry {
|
||||||
|
xml: string
|
||||||
|
source?: HistorySource
|
||||||
|
}
|
||||||
|
|
||||||
|
const SOURCES: readonly string[] = ["user", "recover", "restore"]
|
||||||
|
const isSource = (v: unknown): v is HistorySource =>
|
||||||
|
typeof v === "string" && SOURCES.includes(v)
|
||||||
|
|
||||||
/** Expand a leading ~ to the home directory (shells do this, MCP hosts don't). */
|
/** Expand a leading ~ to the home directory (shells do this, MCP hosts don't). */
|
||||||
export function expandHome(p: string): string {
|
export function expandHome(p: string): string {
|
||||||
if (p === "~") return homedir()
|
if (p === "~") return homedir()
|
||||||
@@ -65,6 +79,10 @@ export class Autosaver {
|
|||||||
private dir: string | null,
|
private dir: string | null,
|
||||||
private delayMs = DELAY_MS,
|
private delayMs = DELAY_MS,
|
||||||
private maxFiles = MAX_FILES,
|
private maxFiles = MAX_FILES,
|
||||||
|
// The session's History entries, saved next to its diagram
|
||||||
|
private history: (
|
||||||
|
sessionId: string,
|
||||||
|
) => { id: number; xml: string; source?: HistorySource }[] = () => [],
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
/** Path of a session's file, or null when saving is off. */
|
/** Path of a session's file, or null when saving is off. */
|
||||||
@@ -72,6 +90,72 @@ export class Autosaver {
|
|||||||
return this.dir ? join(this.dir, `${sessionId}.drawio`) : null
|
return this.dir ? join(this.dir, `${sessionId}.drawio`) : null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Path of a session's History file, or null when saving is off. */
|
||||||
|
historyPathFor(sessionId: string): string | null {
|
||||||
|
return this.dir ? join(this.dir, `${sessionId}.history.json`) : null
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The session's saved History entries, oldest first. */
|
||||||
|
loadHistory(sessionId: string): SavedHistoryEntry[] {
|
||||||
|
const path = this.historyPathFor(sessionId)
|
||||||
|
if (!path) return []
|
||||||
|
try {
|
||||||
|
const entries: unknown = JSON.parse(readFileSync(path, "utf-8"))
|
||||||
|
if (!Array.isArray(entries)) return []
|
||||||
|
const result: SavedHistoryEntry[] = []
|
||||||
|
for (const e of entries) {
|
||||||
|
// Files of earlier versions hold the XML strings alone
|
||||||
|
if (typeof e === "string") {
|
||||||
|
result.push({ xml: e })
|
||||||
|
} else if (e && typeof e.xml === "string") {
|
||||||
|
result.push({
|
||||||
|
xml: e.xml,
|
||||||
|
...(isSource(e.source) && { source: e.source }),
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
} catch (error) {
|
||||||
|
if ((error as NodeJS.ErrnoException).code !== "ENOENT") {
|
||||||
|
log.warn(`Could not read the saved History ${path}: ${error}`)
|
||||||
|
}
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The folder the files are saved in, or null when saving is off. */
|
||||||
|
dataDir(): string | null {
|
||||||
|
return this.dir
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Every file a session can resume (start_session's own files and
|
||||||
|
* hand-named ones such as mcp-notes.drawio), newest first. Empty when
|
||||||
|
* saving is off or the folder does not exist yet.
|
||||||
|
*/
|
||||||
|
list(): { sessionId: string; path: string; savedAt: Date }[] {
|
||||||
|
return this.files(/^mcp-[a-z0-9-]{1,64}\.drawio$/).map((file) => ({
|
||||||
|
sessionId: file.name.slice(0, -".drawio".length),
|
||||||
|
path: file.path,
|
||||||
|
savedAt: file.mtime,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The files in the folder whose name matches, newest first */
|
||||||
|
private files(pattern: RegExp) {
|
||||||
|
const dir = this.dir
|
||||||
|
if (!dir || !existsSync(dir)) return []
|
||||||
|
return readdirSync(dir)
|
||||||
|
.filter((name) => pattern.test(name))
|
||||||
|
.map((name) => {
|
||||||
|
const path = join(dir, name)
|
||||||
|
return { name, path, mtime: statSync(path).mtime }
|
||||||
|
})
|
||||||
|
.sort((a, b) => b.mtime.getTime() - a.mtime.getTime())
|
||||||
|
}
|
||||||
|
|
||||||
// Saved files that could not be read back: never written over, since
|
// Saved files that could not be read back: never written over, since
|
||||||
// the session then shows something else than what they hold. Cleared
|
// the session then shows something else than what they hold. Cleared
|
||||||
// once the file is read, or is surely gone (a folder without permission
|
// once the file is read, or is surely gone (a folder without permission
|
||||||
@@ -132,30 +216,66 @@ export class Autosaver {
|
|||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
const isNew = !existsSync(path)
|
const isNew = !existsSync(path)
|
||||||
// A blank page the browser shows before any drawing: nothing to keep
|
// A blank page the browser shows before any drawing: nothing to
|
||||||
if (isNew && isBlank(entry.xml)) return
|
// keep (unless the diagram was cleared before its first save)
|
||||||
|
if (
|
||||||
|
isNew &&
|
||||||
|
isBlank(entry.xml) &&
|
||||||
|
this.history(sessionId).every((e) => isBlank(e.xml))
|
||||||
|
)
|
||||||
|
return
|
||||||
mkdirSync(this.dir, { recursive: true })
|
mkdirSync(this.dir, { recursive: true })
|
||||||
// Write to a temporary file first so a crash never leaves half a file
|
// Write to a temporary file first so a crash never leaves half a file
|
||||||
writeFileSync(`${path}.tmp`, entry.xml, "utf-8")
|
writeFileSync(`${path}.tmp`, entry.xml, "utf-8")
|
||||||
renameSync(`${path}.tmp`, path)
|
renameSync(`${path}.tmp`, path)
|
||||||
|
this.writeHistory(sessionId)
|
||||||
if (isNew) this.removeOldest()
|
if (isNew) this.removeOldest()
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
log.warn(`Auto-save failed for ${path}: ${error}`)
|
log.warn(`Auto-save failed for ${path}: ${error}`)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// What each session's History file holds (entry count and last id):
|
||||||
|
// draw.io autosaves often, and the file is only rewritten when the
|
||||||
|
// entries changed
|
||||||
|
private historyKeys = new Map<string, string>()
|
||||||
|
|
||||||
|
// Saved with the diagram, after the same delay, so the History entry
|
||||||
|
// that follows every tool write is included. The thumbnails are left
|
||||||
|
// out (large); the History grid shows the entry's number instead. Who
|
||||||
|
// made an entry goes with it: the shell's cards tell a clear of the
|
||||||
|
// canvas from a restore of one by it.
|
||||||
|
private writeHistory(sessionId: string): void {
|
||||||
|
const path = this.historyPathFor(sessionId)
|
||||||
|
const entries = this.history(sessionId)
|
||||||
|
if (!path || entries.length === 0) return
|
||||||
|
const key = `${entries.length}:${entries[entries.length - 1].id}`
|
||||||
|
if (this.historyKeys.get(sessionId) === key) return
|
||||||
|
try {
|
||||||
|
const saved: SavedHistoryEntry[] = entries.map((e) => ({
|
||||||
|
xml: e.xml,
|
||||||
|
...(e.source && { source: e.source }),
|
||||||
|
}))
|
||||||
|
const json = JSON.stringify(saved)
|
||||||
|
writeFileSync(`${path}.tmp`, json, "utf-8")
|
||||||
|
renameSync(`${path}.tmp`, path)
|
||||||
|
this.historyKeys.set(sessionId, key)
|
||||||
|
} catch (error) {
|
||||||
|
log.warn(`Saving the History failed for ${path}: ${error}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private removeOldest(): void {
|
private removeOldest(): void {
|
||||||
if (!this.dir) return
|
|
||||||
const dir = this.dir
|
|
||||||
// Only our own session files (mcp-<time in base 36>-<random>, made
|
// Only our own session files (mcp-<time in base 36>-<random>, made
|
||||||
// by start_session): DRAWIO_DATA_DIR may be a folder that also holds
|
// by start_session): DRAWIO_DATA_DIR may be a folder that also holds
|
||||||
// the user's diagrams
|
// the user's diagrams
|
||||||
const files = readdirSync(dir)
|
const own = this.files(/^mcp-[0-9a-z]{8}-[0-9a-z]{1,6}\.drawio$/)
|
||||||
.filter((f) => /^mcp-[0-9a-z]{8}-[0-9a-z]{1,6}\.drawio$/.test(f))
|
for (const { name, path } of own.slice(this.maxFiles)) {
|
||||||
.map((f) => ({ f, mtime: statSync(join(dir, f)).mtimeMs }))
|
unlinkSync(path)
|
||||||
.sort((a, b) => b.mtime - a.mtime)
|
// Its History goes with it (there may be none), and is written
|
||||||
for (const { f } of files.slice(this.maxFiles)) {
|
// again if the session saves once more
|
||||||
unlinkSync(join(dir, f))
|
rmSync(path.replace(/\.drawio$/, ".history.json"), { force: true })
|
||||||
|
this.historyKeys.delete(name.slice(0, -".drawio".length))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -40,7 +40,7 @@
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<iframe id="drawio" src="{{DRAWIO_URL}}/?embed=1&proto=json&spin=1&libraries=1&noSaveBtn=1&noExitBtn=1&saveAndExit=0&themes=1&dark=auto" title="draw.io editor"></iframe>
|
<iframe id="drawio" src="{{DRAWIO_URL}}?embed=1&proto=json&spin=1&libraries=1&noSaveBtn=1&noExitBtn=1&saveAndExit=0&themes=1&{{DRAWIO_PARAMS}}" title="draw.io editor"></iframe>
|
||||||
</div>
|
</div>
|
||||||
<div id="notice" role="status"></div>
|
<div id="notice" role="status"></div>
|
||||||
<div id="history-modal">
|
<div id="history-modal">
|
||||||
@@ -85,7 +85,9 @@
|
|||||||
</div>
|
</div>
|
||||||
<script>
|
<script>
|
||||||
const sessionId = {{SESSION_JSON}};
|
const sessionId = {{SESSION_JSON}};
|
||||||
const DRAWIO_ORIGIN = {{ORIGIN_JSON}};
|
// Empty when draw.io is served from this origin
|
||||||
|
const DRAWIO_ORIGIN = {{ORIGIN_JSON}} || location.origin;
|
||||||
|
let API_TOKEN = {{TOKEN_JSON}};
|
||||||
</script>
|
</script>
|
||||||
<script>
|
<script>
|
||||||
{{SCRIPT}}
|
{{SCRIPT}}
|
||||||
|
|||||||
@@ -1,4 +1,25 @@
|
|||||||
const iframe = document.getElementById('drawio');
|
const iframe = document.getElementById('drawio');
|
||||||
|
// Every API request carries the token the server wrote into this page. A 403
|
||||||
|
// means another MCP process (with its own token) now answers on this port:
|
||||||
|
// read its token from a fresh copy of this page and retry once, so the next
|
||||||
|
// poll can recover the session instead of being refused forever.
|
||||||
|
let tokenRefresh = null;
|
||||||
|
function refreshToken() {
|
||||||
|
if (!tokenRefresh) {
|
||||||
|
tokenRefresh = fetch(location.href, { cache: 'no-store' })
|
||||||
|
.then((r) => r.text())
|
||||||
|
.then((html) => { const m = html.match(/API_TOKEN = "([0-9a-f]+)"/); if (m) API_TOKEN = m[1]; })
|
||||||
|
.catch(() => {})
|
||||||
|
.finally(() => { tokenRefresh = null; });
|
||||||
|
}
|
||||||
|
return tokenRefresh;
|
||||||
|
}
|
||||||
|
async function api(url, init = {}) {
|
||||||
|
const send = () => fetch(url, { ...init, headers: { ...(init.headers || {}), 'X-Drawio-Token': API_TOKEN } });
|
||||||
|
let r = await send();
|
||||||
|
if (r.status === 403) { await refreshToken(); r = await send(); }
|
||||||
|
return r;
|
||||||
|
}
|
||||||
let currentVersion = 0, isReady = false, pendingXml = null, lastXml = null;
|
let currentVersion = 0, isReady = false, pendingXml = null, lastXml = null;
|
||||||
// The server state this tab is in step with (see stateId in http-server.ts);
|
// The server state this tab is in step with (see stateId in http-server.ts);
|
||||||
// null until the first poll
|
// null until the first poll
|
||||||
@@ -82,7 +103,7 @@ window.addEventListener('message', (e) => {
|
|||||||
// result: a poll answered before that still sees the
|
// result: a poll answered before that still sees the
|
||||||
// request and would start the same export again.
|
// request and would start the same export again.
|
||||||
const seq = msg.message.mcpExport;
|
const seq = msg.message.mcpExport;
|
||||||
fetch('/api/state', {
|
api('/api/state', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({ sessionId, exportData: d, exportId: mcpExportId })
|
body: JSON.stringify({ sessionId, exportData: d, exportId: mcpExportId })
|
||||||
@@ -126,7 +147,7 @@ window.addEventListener('message', (e) => {
|
|||||||
const t = thumbExport;
|
const t = thumbExport;
|
||||||
if (!t || msg.message.thumbExport !== t.n || latestXml !== t.xml) return;
|
if (!t || msg.message.thumbExport !== t.n || latestXml !== t.xml) return;
|
||||||
thumbExport = null;
|
thumbExport = null;
|
||||||
fetch('/api/history-svg', {
|
api('/api/history-svg', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({ sessionId, svg, stateId: t.stateId, version: t.version })
|
body: JSON.stringify({ sessionId, svg, stateId: t.stateId, version: t.version })
|
||||||
@@ -184,7 +205,7 @@ async function pushState(xml, svg = '', baseVersion = currentVersion, source = '
|
|||||||
if (!sessionId) return;
|
if (!sessionId) return;
|
||||||
pushesInFlight.push(xml);
|
pushesInFlight.push(xml);
|
||||||
try {
|
try {
|
||||||
const r = await fetch('/api/state', {
|
const r = await api('/api/state', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({ sessionId, xml, svg, baseVersion, source, stateId: sid })
|
body: JSON.stringify({ sessionId, xml, svg, baseVersion, source, stateId: sid })
|
||||||
@@ -237,6 +258,8 @@ async function pushState(xml, svg = '', baseVersion = currentVersion, source = '
|
|||||||
// process restarted. Decide whose diagram wins.
|
// process restarted. Decide whose diagram wins.
|
||||||
function recoverState(s) {
|
function recoverState(s) {
|
||||||
stateId = s.stateId;
|
stateId = s.stateId;
|
||||||
|
// History entries got new ids with the new state: an open list is stale
|
||||||
|
if (historyModal.classList.contains('open')) cancelBtn.onclick();
|
||||||
// The old state's pending work is gone with it
|
// The old state's pending work is gone with it
|
||||||
const projectionShown = projectionExportActive;
|
const projectionShown = projectionExportActive;
|
||||||
projectionExportActive = false;
|
projectionExportActive = false;
|
||||||
@@ -280,7 +303,7 @@ async function poll() {
|
|||||||
if (!sessionId) return;
|
if (!sessionId) return;
|
||||||
const seq = ++pollSeq;
|
const seq = ++pollSeq;
|
||||||
try {
|
try {
|
||||||
const r = await fetch('/api/state?sessionId=' + encodeURIComponent(sessionId));
|
const r = await api('/api/state?sessionId=' + encodeURIComponent(sessionId));
|
||||||
if (!r.ok) return;
|
if (!r.ok) return;
|
||||||
const s = await r.json();
|
const s = await r.json();
|
||||||
// An older answer than one already handled (the interval, the 409
|
// An older answer than one already handled (the interval, the 409
|
||||||
@@ -298,11 +321,13 @@ async function poll() {
|
|||||||
// The tab's own push still on its way is not loaded back: the
|
// The tab's own push still on its way is not loaded back: the
|
||||||
// canvas may have moved on since (an undo), and its answer follows
|
// canvas may have moved on since (an undo), and its answer follows
|
||||||
const ownPush = pushesInFlight.includes(s.xml);
|
const ownPush = pushesInFlight.includes(s.xml);
|
||||||
|
let justLoaded = false; // this poll put a new version on the canvas
|
||||||
if ((forceReload || (s.version > currentVersion && !projectionExportActive && !ownPush)) && s.xml) {
|
if ((forceReload || (s.version > currentVersion && !projectionExportActive && !ownPush)) && s.xml) {
|
||||||
forceReload = false;
|
forceReload = false;
|
||||||
projectionExportActive = false;
|
projectionExportActive = false;
|
||||||
currentVersion = s.version;
|
currentVersion = s.version;
|
||||||
loadDiagram(s.xml, true);
|
loadDiagram(s.xml, true);
|
||||||
|
justLoaded = true;
|
||||||
}
|
}
|
||||||
// Handle sync request - server needs fresh state. After the load
|
// Handle sync request - server needs fresh state. After the load
|
||||||
// above, so draw.io exports what it just loaded; never while a
|
// above, so draw.io exports what it just loaded; never while a
|
||||||
@@ -352,6 +377,10 @@ async function poll() {
|
|||||||
// Let draw.io render the loaded page before exporting
|
// Let draw.io render the loaded page before exporting
|
||||||
// (same proven settle delay as the AI-preview path).
|
// (same proven settle delay as the AI-preview path).
|
||||||
setTimeout(fireExport, 600);
|
setTimeout(fireExport, 600);
|
||||||
|
} else if (justLoaded) {
|
||||||
|
// A write with a screenshot: give the new diagram's external
|
||||||
|
// icon images a moment to load before the PNG is taken
|
||||||
|
setTimeout(fireExport, 600);
|
||||||
} else {
|
} else {
|
||||||
fireExport();
|
fireExport();
|
||||||
}
|
}
|
||||||
@@ -453,18 +482,24 @@ const historyGrid = document.getElementById('history-grid');
|
|||||||
const historyEmpty = document.getElementById('history-empty');
|
const historyEmpty = document.getElementById('history-empty');
|
||||||
const restoreBtn = document.getElementById('restore-btn');
|
const restoreBtn = document.getElementById('restore-btn');
|
||||||
const cancelBtn = document.getElementById('cancel-btn');
|
const cancelBtn = document.getElementById('cancel-btn');
|
||||||
let historyData = [], selectedId = null;
|
// historyStateId: the server state the list belongs to, sent with a restore
|
||||||
|
let historyData = [], historyStateId = null, selectedId = null;
|
||||||
|
|
||||||
historyBtn.onclick = async () => {
|
historyBtn.onclick = async () => {
|
||||||
if (!sessionId) return;
|
if (!sessionId) return;
|
||||||
|
// A list for a state the server recreated meanwhile has old ids
|
||||||
|
const sid = stateId;
|
||||||
try {
|
try {
|
||||||
const r = await fetch('/api/history?sessionId=' + encodeURIComponent(sessionId));
|
const r = await api('/api/history?sessionId=' + encodeURIComponent(sessionId));
|
||||||
if (r.ok) {
|
if (r.ok) {
|
||||||
const d = await r.json();
|
const d = await r.json();
|
||||||
|
if (sid !== stateId) return;
|
||||||
historyData = d.entries || [];
|
historyData = d.entries || [];
|
||||||
|
historyStateId = sid;
|
||||||
renderHistory();
|
renderHistory();
|
||||||
}
|
}
|
||||||
} catch {}
|
} catch {}
|
||||||
|
if (sid !== stateId) return;
|
||||||
historyModal.classList.add('open');
|
historyModal.classList.add('open');
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -515,12 +550,13 @@ restoreBtn.onclick = async () => {
|
|||||||
restoreBtn.disabled = true;
|
restoreBtn.disabled = true;
|
||||||
restoreBtn.textContent = 'Restoring...';
|
restoreBtn.textContent = 'Restoring...';
|
||||||
try {
|
try {
|
||||||
const r = await fetch('/api/restore', {
|
const r = await api('/api/restore', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({ sessionId, id: selectedId })
|
body: JSON.stringify({ sessionId, id: selectedId, stateId: historyStateId })
|
||||||
});
|
});
|
||||||
if (r.ok) { cancelBtn.onclick(); await poll(); }
|
if (r.ok) { cancelBtn.onclick(); await poll(); }
|
||||||
|
else if (r.status === 409) { cancelBtn.onclick(); showNotice('History changed on the server. Please open it again.'); }
|
||||||
else { showNotice('Restore failed. Please try again.'); }
|
else { showNotice('Restore failed. Please try again.'); }
|
||||||
} catch { showNotice('Restore failed. Please try again.'); }
|
} catch { showNotice('Restore failed. Please try again.'); }
|
||||||
restoreBtn.textContent = 'Restore';
|
restoreBtn.textContent = 'Restore';
|
||||||
|
|||||||
@@ -0,0 +1,124 @@
|
|||||||
|
/**
|
||||||
|
* The cells the user has selected in the preview, as the page reads them
|
||||||
|
* from the editor (shell/use-mcp-sync.ts) and the get_selection tool
|
||||||
|
* reports them. The shape is shared with the shell; only plain data, so the
|
||||||
|
* server can take it from a POST as it is.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export interface SelectedCellInfo {
|
||||||
|
id: string
|
||||||
|
/** The label as text, HTML removed and long ones cut */
|
||||||
|
label: string
|
||||||
|
edge: boolean
|
||||||
|
source?: string
|
||||||
|
target?: string
|
||||||
|
/** The cell's geometry as the XML has it (relative to its parent) */
|
||||||
|
geometry?: { x: number; y: number; width: number; height: number }
|
||||||
|
/** The container the cell is in, when it is not on the layer itself */
|
||||||
|
parent?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SelectionAnswer {
|
||||||
|
/** The page on screen */
|
||||||
|
pageId: string | null
|
||||||
|
pageName: string | null
|
||||||
|
cells: SelectedCellInfo[]
|
||||||
|
/** The page cannot reach the editor (an external draw.io) */
|
||||||
|
unavailable?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A POST body's selection, if it has the shape above; else null */
|
||||||
|
export function parseSelectionAnswer(value: unknown): SelectionAnswer | null {
|
||||||
|
if (!value || typeof value !== "object") return null
|
||||||
|
const given = value as Record<string, unknown>
|
||||||
|
if (given.unavailable === true) {
|
||||||
|
return { pageId: null, pageName: null, cells: [], unavailable: true }
|
||||||
|
}
|
||||||
|
if (!Array.isArray(given.cells)) return null
|
||||||
|
const text = (v: unknown) => (typeof v === "string" ? v : null)
|
||||||
|
const cells: SelectedCellInfo[] = []
|
||||||
|
for (const raw of given.cells) {
|
||||||
|
if (!raw || typeof raw !== "object") continue
|
||||||
|
const c = raw as Record<string, unknown>
|
||||||
|
if (typeof c.id !== "string" || !c.id) continue
|
||||||
|
const cell: SelectedCellInfo = {
|
||||||
|
id: c.id,
|
||||||
|
label: text(c.label) ?? "",
|
||||||
|
edge: c.edge === true,
|
||||||
|
}
|
||||||
|
if (text(c.source)) cell.source = c.source as string
|
||||||
|
if (text(c.target)) cell.target = c.target as string
|
||||||
|
if (text(c.parent)) cell.parent = c.parent as string
|
||||||
|
const g = c.geometry as Record<string, unknown> | undefined
|
||||||
|
if (
|
||||||
|
g &&
|
||||||
|
typeof g === "object" &&
|
||||||
|
[g.x, g.y, g.width, g.height].every(
|
||||||
|
(n) => typeof n === "number" && Number.isFinite(n),
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
cell.geometry = {
|
||||||
|
x: g.x as number,
|
||||||
|
y: g.y as number,
|
||||||
|
width: g.width as number,
|
||||||
|
height: g.height as number,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
cells.push(cell)
|
||||||
|
}
|
||||||
|
return { pageId: text(given.pageId), pageName: text(given.pageName), cells }
|
||||||
|
}
|
||||||
|
|
||||||
|
/** describeSelection details at most this many cells; the rest by id only */
|
||||||
|
export const MAX_LISTED_CELLS = 100
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The get_selection result text. `externalDrawio` names the draw.io origin
|
||||||
|
* when the preview loads it from another origin, where the page cannot
|
||||||
|
* read the editor. A selection of a whole large diagram would fill the
|
||||||
|
* model's context with labels and geometry: the first cells get them, the
|
||||||
|
* rest are listed by id alone (an edit on the selection needs every id).
|
||||||
|
*/
|
||||||
|
export function describeSelection(
|
||||||
|
answer: SelectionAnswer,
|
||||||
|
externalDrawio: string | null,
|
||||||
|
): string {
|
||||||
|
if (answer.unavailable) {
|
||||||
|
return externalDrawio
|
||||||
|
? `The preview loads draw.io from ${externalDrawio} (another origin), where the selection cannot be read. Ask the user which shapes they mean, or call get_diagram.`
|
||||||
|
: "The preview could not read the editor's selection. Ask the user which shapes they mean, or call get_diagram."
|
||||||
|
}
|
||||||
|
const page = answer.pageName
|
||||||
|
? `page "${answer.pageName}"${answer.pageId ? ` (page_id="${answer.pageId}")` : ""}`
|
||||||
|
: "the page on screen"
|
||||||
|
if (answer.cells.length === 0) {
|
||||||
|
return `Nothing is selected on ${page}. Ask the user to select the shapes they mean in the preview, or call get_diagram.`
|
||||||
|
}
|
||||||
|
const listed = answer.cells.slice(0, MAX_LISTED_CELLS)
|
||||||
|
const lines = listed.map((cell) => {
|
||||||
|
const label = cell.label ? `"${cell.label}"` : "(no label)"
|
||||||
|
if (cell.edge) {
|
||||||
|
const ends =
|
||||||
|
cell.source || cell.target
|
||||||
|
? ` from ${cell.source ? `"${cell.source}"` : "nothing"} to ${cell.target ? `"${cell.target}"` : "nothing"}`
|
||||||
|
: " (not connected)"
|
||||||
|
return `- id="${cell.id}" edge ${label}${ends}`
|
||||||
|
}
|
||||||
|
const g = cell.geometry
|
||||||
|
const where = g
|
||||||
|
? ` at x=${g.x} y=${g.y} w=${g.width} h=${g.height}`
|
||||||
|
: ""
|
||||||
|
const parent = cell.parent ? ` inside "${cell.parent}"` : ""
|
||||||
|
return `- id="${cell.id}" shape ${label}${where}${parent}`
|
||||||
|
})
|
||||||
|
const rest = answer.cells.slice(MAX_LISTED_CELLS)
|
||||||
|
if (rest.length > 0) {
|
||||||
|
lines.push(
|
||||||
|
`- and ${rest.length} more cells, listed by id only: ${rest.map((c) => `"${c.id}"`).join(", ")}`,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
const count =
|
||||||
|
answer.cells.length === 1 ? "1 cell" : `${answer.cells.length} cells`
|
||||||
|
const target = answer.pageId ? ` (page_id="${answer.pageId}")` : ""
|
||||||
|
return `${count} selected on ${page}:\n${lines.join("\n")}\n\nUse these ids with edit_diagram${target}; call get_diagram for their full XML.`
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -0,0 +1,71 @@
|
|||||||
|
/**
|
||||||
|
* Short XML references returned by get_drawing_guide with a topic. Each
|
||||||
|
* snippet is the bare mxCell elements of one page, as create_new_diagram
|
||||||
|
* accepts them, and is checked by tests/xml-reference.test.ts.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { indent } from "./xml-examples.ts"
|
||||||
|
|
||||||
|
const TABLE_ROW_STYLE =
|
||||||
|
"shape=tableRow;horizontal=0;startSize=0;swimlaneHead=0;swimlaneBody=0;strokeColor=inherit;top=0;left=0;bottom=0;right=0;collapsible=0;dropTarget=0;fillColor=none;points=[[0,0.5],[1,0.5]];portConstraint=eastwest;"
|
||||||
|
|
||||||
|
const TABLE_CELL_STYLE =
|
||||||
|
"shape=partialRectangle;html=1;whiteSpace=wrap;connectable=0;strokeColor=inherit;overflow=hidden;fillColor=none;top=0;left=0;bottom=0;right=0;pointerEvents=1;"
|
||||||
|
|
||||||
|
const TABLES_EXAMPLE = `<mxCell id="table1" style="shape=table;startSize=0;container=1;collapsible=0;childLayout=tableLayout;" vertex="1" parent="1">
|
||||||
|
<mxGeometry x="40" y="40" width="240" height="80" as="geometry"/>
|
||||||
|
</mxCell>
|
||||||
|
<mxCell id="row1" style="${TABLE_ROW_STYLE}" vertex="1" parent="table1">
|
||||||
|
<mxGeometry y="0" width="240" height="40" as="geometry"/>
|
||||||
|
</mxCell>
|
||||||
|
<mxCell id="cell11" value="Name" style="${TABLE_CELL_STYLE}fontStyle=1;" vertex="1" parent="row1">
|
||||||
|
<mxGeometry x="0" y="0" width="120" height="40" as="geometry"/>
|
||||||
|
</mxCell>
|
||||||
|
<mxCell id="cell12" value="Role" style="${TABLE_CELL_STYLE}fontStyle=1;" vertex="1" parent="row1">
|
||||||
|
<mxGeometry x="120" y="0" width="120" height="40" as="geometry"/>
|
||||||
|
</mxCell>
|
||||||
|
<mxCell id="row2" style="${TABLE_ROW_STYLE}" vertex="1" parent="table1">
|
||||||
|
<mxGeometry y="40" width="240" height="40" as="geometry"/>
|
||||||
|
</mxCell>
|
||||||
|
<mxCell id="cell21" value="Alice" style="${TABLE_CELL_STYLE}" vertex="1" parent="row2">
|
||||||
|
<mxGeometry x="0" y="0" width="120" height="40" as="geometry"/>
|
||||||
|
</mxCell>
|
||||||
|
<mxCell id="cell22" value="Admin" style="${TABLE_CELL_STYLE}" vertex="1" parent="row2">
|
||||||
|
<mxGeometry x="120" y="0" width="120" height="40" as="geometry"/>
|
||||||
|
</mxCell>`
|
||||||
|
|
||||||
|
const LAYERS_EXAMPLE = `<mxCell id="L2" value="Layer 2" parent="0"/>
|
||||||
|
<mxCell id="2" value="On the default layer" 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="3" value="On Layer 2" style="rounded=1;whiteSpace=wrap;html=1;" vertex="1" parent="L2">
|
||||||
|
<mxGeometry x="200" y="40" width="120" height="60" as="geometry"/>
|
||||||
|
</mxCell>`
|
||||||
|
|
||||||
|
const GROUPS_EXAMPLE = `<mxCell id="group1" style="group" vertex="1" connectable="0" parent="1">
|
||||||
|
<mxGeometry x="40" y="40" width="260" height="60" as="geometry"/>
|
||||||
|
</mxCell>
|
||||||
|
<mxCell id="2" value="A" style="rounded=1;whiteSpace=wrap;html=1;" vertex="1" parent="group1">
|
||||||
|
<mxGeometry x="0" y="0" width="120" height="60" as="geometry"/>
|
||||||
|
</mxCell>
|
||||||
|
<mxCell id="3" value="B" style="rounded=1;whiteSpace=wrap;html=1;" vertex="1" parent="group1">
|
||||||
|
<mxGeometry x="140" y="0" width="120" height="60" as="geometry"/>
|
||||||
|
</mxCell>`
|
||||||
|
|
||||||
|
export const XML_REFERENCE: Record<"tables" | "layers" | "groups", string> = {
|
||||||
|
tables: `## Tables
|
||||||
|
A table is three levels of cells, as draw.io's sidebar inserts them: the table container (style "shape=table;startSize=0;container=1;collapsible=0;childLayout=tableLayout;", parent="1", geometry wide enough for all columns), one row per table row (style as below, parent = table id, width = table width, height = row height, y = row offset), and one cell per column inside each row (style as below, parent = row id, x = column offset, y = 0, width = column width, height = row height; the cell text is the value). A header row can use fontStyle=1 on its cells. Example with 2 rows and 2 columns:
|
||||||
|
|
||||||
|
${indent(TABLES_EXAMPLE)}
|
||||||
|
`,
|
||||||
|
layers: `## Layers
|
||||||
|
A layer is the one case of a cell with parent="0" and no vertex or edge attribute; id="1" is the default layer. Add a layer cell and give shapes parent="<layer id>" to put them on it:
|
||||||
|
|
||||||
|
${indent(LAYERS_EXAMPLE)}
|
||||||
|
`,
|
||||||
|
groups: `## Groups
|
||||||
|
A group is a container with style="group" (parent="1", geometry enclosing its children). Children use parent="<group id>" and coordinates relative to the group; moving the group moves them:
|
||||||
|
|
||||||
|
${indent(GROUPS_EXAMPLE)}
|
||||||
|
`,
|
||||||
|
}
|
||||||
@@ -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]
|
||||||
|
const nested =
|
||||||
|
"Invalid XML: Found nested mxCell tags. Cells should be siblings, not nested inside other mxCell elements."
|
||||||
|
const isLabelOrGeometry =
|
||||||
|
/\sas\s*=\s*["'](valueLabel|geometry)["']/.test(tag)
|
||||||
if (tag.startsWith("</mxCell>")) {
|
if (tag.startsWith("</mxCell>")) {
|
||||||
if (cellStack.length > 0) cellStack.pop()
|
if (cellStack.length > 0) cellStack.pop()
|
||||||
} else if (!tag.endsWith("/>")) {
|
} else if (tag.endsWith("/>")) {
|
||||||
const isLabelOrGeometry =
|
// A self-closing cell inside an open one is nested too (or the
|
||||||
/\sas\s*=\s*["'](valueLabel|geometry)["']/.test(tag)
|
// open one lacks its </mxCell>; the auto-fix sorts that out)
|
||||||
if (!isLabelOrGeometry) {
|
if (cellStack.length > 0 && !isLabelOrGeometry) return nested
|
||||||
cellStack.push(cellMatch.index)
|
} else if (!isLabelOrGeometry) {
|
||||||
if (cellStack.length > 1) {
|
cellStack.push(cellMatch.index)
|
||||||
return "Invalid XML: Found nested mxCell tags. Cells should be siblings, not nested inside other mxCell elements."
|
if (cellStack.length > 1) return nested
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
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++
|
||||||
pendingCloseRemoval++
|
// 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++
|
||||||
|
}
|
||||||
}
|
}
|
||||||
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)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,99 @@
|
|||||||
|
/**
|
||||||
|
* Tests for the user's instructions.md appended to the drawing guide
|
||||||
|
* (src/custom-instructions.ts).
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { mkdirSync, mkdtempSync, writeFileSync } from "node:fs"
|
||||||
|
import { homedir, tmpdir } from "node:os"
|
||||||
|
import { join } from "node:path"
|
||||||
|
import { afterEach, describe, expect, it } from "vitest"
|
||||||
|
import {
|
||||||
|
CUSTOM_INSTRUCTIONS_FILE,
|
||||||
|
customInstructionsDir,
|
||||||
|
guideWithCustomInstructions,
|
||||||
|
readCustomInstructions,
|
||||||
|
} from "../src/custom-instructions.ts"
|
||||||
|
import { DRAWING_GUIDE } from "../src/drawing-guide.ts"
|
||||||
|
|
||||||
|
const GUIDE = "# Drawing guide\n\nKeep edges orthogonal."
|
||||||
|
const tempDir = () => mkdtempSync(join(tmpdir(), "mcp-instructions-"))
|
||||||
|
const writeRules = (dir: string, text: string) =>
|
||||||
|
writeFileSync(join(dir, CUSTOM_INSTRUCTIONS_FILE), text)
|
||||||
|
|
||||||
|
describe("readCustomInstructions", () => {
|
||||||
|
it("gives an empty string when there is no file", () => {
|
||||||
|
const dir = tempDir()
|
||||||
|
expect(readCustomInstructions(dir)).toBe("")
|
||||||
|
expect(guideWithCustomInstructions(GUIDE, dir)).toBe(GUIDE)
|
||||||
|
})
|
||||||
|
|
||||||
|
it("appends the file's content under its own heading", () => {
|
||||||
|
const dir = tempDir()
|
||||||
|
writeRules(dir, "\nAlways draw in minimal style.\n\n")
|
||||||
|
expect(readCustomInstructions(dir)).toBe(
|
||||||
|
"Always draw in minimal style.",
|
||||||
|
)
|
||||||
|
expect(guideWithCustomInstructions(GUIDE, dir)).toBe(
|
||||||
|
`${GUIDE}\n\n## Custom Instructions\nAlways draw in minimal style.`,
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
it("cuts the content at 5000 characters", () => {
|
||||||
|
const dir = tempDir()
|
||||||
|
writeRules(dir, "x".repeat(6000))
|
||||||
|
expect(readCustomInstructions(dir)).toBe("x".repeat(5000))
|
||||||
|
})
|
||||||
|
|
||||||
|
it("gives an empty string when the file cannot be read", () => {
|
||||||
|
const dir = tempDir()
|
||||||
|
// A folder in place of the file: readFileSync fails with EISDIR
|
||||||
|
mkdirSync(join(dir, CUSTOM_INSTRUCTIONS_FILE))
|
||||||
|
expect(readCustomInstructions(dir)).toBe("")
|
||||||
|
expect(guideWithCustomInstructions(GUIDE, dir)).toBe(GUIDE)
|
||||||
|
})
|
||||||
|
|
||||||
|
it("reads the file again on every call", () => {
|
||||||
|
const dir = tempDir()
|
||||||
|
writeRules(dir, "First rule.")
|
||||||
|
expect(readCustomInstructions(dir)).toBe("First rule.")
|
||||||
|
writeRules(dir, "Second rule.")
|
||||||
|
expect(readCustomInstructions(dir)).toBe("Second rule.")
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe("customInstructionsDir", () => {
|
||||||
|
const original = process.env.DRAWIO_DATA_DIR
|
||||||
|
afterEach(() => {
|
||||||
|
if (original === undefined) delete process.env.DRAWIO_DATA_DIR
|
||||||
|
else process.env.DRAWIO_DATA_DIR = original
|
||||||
|
})
|
||||||
|
|
||||||
|
it("honours DRAWIO_DATA_DIR", () => {
|
||||||
|
const dir = tempDir()
|
||||||
|
writeRules(dir, "Use blue fill #dae8fc.")
|
||||||
|
process.env.DRAWIO_DATA_DIR = dir
|
||||||
|
expect(customInstructionsDir()).toBe(dir)
|
||||||
|
expect(guideWithCustomInstructions(GUIDE)).toBe(
|
||||||
|
`${GUIDE}\n\n## Custom Instructions\nUse blue fill #dae8fc.`,
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
it("expands ~, which JSON configs pass on as it is", () => {
|
||||||
|
process.env.DRAWIO_DATA_DIR = "~/drawio-saves"
|
||||||
|
expect(customInstructionsDir()).toBe(join(homedir(), "drawio-saves"))
|
||||||
|
})
|
||||||
|
|
||||||
|
it("falls back to the home folder when DRAWIO_DATA_DIR is off or unset", () => {
|
||||||
|
const home = join(homedir(), ".next-ai-drawio")
|
||||||
|
process.env.DRAWIO_DATA_DIR = "off"
|
||||||
|
expect(customInstructionsDir()).toBe(home)
|
||||||
|
delete process.env.DRAWIO_DATA_DIR
|
||||||
|
expect(customInstructionsDir()).toBe(home)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe("DRAWING_GUIDE", () => {
|
||||||
|
it("stays within its own budget; instructions.md adds up to 5000 more", () => {
|
||||||
|
expect(DRAWING_GUIDE.length).toBeLessThanOrEqual(15000)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,582 @@
|
|||||||
|
/**
|
||||||
|
* The canvas shell as start_session opens it by default: the built server
|
||||||
|
* (dist/index.js, or the copy MCP_SERVER_ENTRY names: CI installs the packed
|
||||||
|
* tarball in an empty directory) runs over stdio as an MCP client would
|
||||||
|
* start it, and headless Chromium opens the URL it returns.
|
||||||
|
*/
|
||||||
|
import { mkdtempSync, readFileSync, rmSync } from "node:fs"
|
||||||
|
import { tmpdir } from "node:os"
|
||||||
|
import { dirname, join } from "node:path"
|
||||||
|
import { fileURLToPath } from "node:url"
|
||||||
|
import { Client } from "@modelcontextprotocol/sdk/client/index.js"
|
||||||
|
import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js"
|
||||||
|
import { expect, type Page, test } from "@playwright/test"
|
||||||
|
|
||||||
|
const pkg = join(dirname(fileURLToPath(import.meta.url)), "../..")
|
||||||
|
|
||||||
|
let client: Client
|
||||||
|
let dataDir: string
|
||||||
|
let shellUrl: string
|
||||||
|
|
||||||
|
test.beforeAll(async () => {
|
||||||
|
dataDir = mkdtempSync(join(tmpdir(), "mcp-shell-e2e-"))
|
||||||
|
const transport = new StdioClientTransport({
|
||||||
|
command: process.execPath,
|
||||||
|
args: [process.env.MCP_SERVER_ENTRY ?? join(pkg, "dist/index.js")],
|
||||||
|
env: {
|
||||||
|
...process.env,
|
||||||
|
DRAWIO_DATA_DIR: dataDir,
|
||||||
|
// Away from the web app's dev server and other test runs
|
||||||
|
PORT: String(6100 + Math.floor(Math.random() * 400)),
|
||||||
|
BROWSER: "none",
|
||||||
|
},
|
||||||
|
stderr: "pipe",
|
||||||
|
})
|
||||||
|
client = new Client({ name: "shell-e2e", version: "0.0.1" })
|
||||||
|
await client.connect(transport)
|
||||||
|
const result = await client.callTool({
|
||||||
|
name: "start_session",
|
||||||
|
arguments: {},
|
||||||
|
})
|
||||||
|
const text = (result.content as { type: string; text?: string }[])
|
||||||
|
.map((c) => c.text ?? "")
|
||||||
|
.join("\n")
|
||||||
|
const match = text.match(/Browser URL: (\S+)/)
|
||||||
|
if (!match) throw new Error(`start_session gave no URL:\n${text}`)
|
||||||
|
shellUrl = match[1]
|
||||||
|
})
|
||||||
|
|
||||||
|
test.afterAll(async () => {
|
||||||
|
await client?.close()
|
||||||
|
rmSync(dataDir, { recursive: true, force: true })
|
||||||
|
})
|
||||||
|
|
||||||
|
test("start_session opens the shell, which loads the bundled draw.io", async ({
|
||||||
|
page,
|
||||||
|
}) => {
|
||||||
|
expect(shellUrl).toMatch(/^http:\/\/localhost:\d+\/shell\/\?mcp=mcp-/)
|
||||||
|
const errors: string[] = []
|
||||||
|
page.on("console", (message) => {
|
||||||
|
if (message.type() === "error") errors.push(message.text())
|
||||||
|
})
|
||||||
|
page.on("pageerror", (error) => errors.push(error.message))
|
||||||
|
|
||||||
|
await page.goto(shellUrl)
|
||||||
|
const iframe = page.locator("iframe[title='draw.io']")
|
||||||
|
await expect(iframe).toHaveAttribute(
|
||||||
|
"src",
|
||||||
|
/^http:\/\/localhost:\d+\/drawio\/index\.html\?embed=1&proto=json&configure=1&/,
|
||||||
|
)
|
||||||
|
// The cover goes once draw.io sent "init" and loaded the first document
|
||||||
|
await expect(page.getByTestId("canvas-loading")).toHaveCount(0, {
|
||||||
|
timeout: 60_000,
|
||||||
|
})
|
||||||
|
const frame = page.frameLocator("iframe[title='draw.io']")
|
||||||
|
await expect(frame.locator(".geDiagramContainer")).toBeVisible()
|
||||||
|
|
||||||
|
// The shell's own texts and state: the first poll connects it
|
||||||
|
await expect(page.getByTestId("shell-status")).toHaveText(
|
||||||
|
"Connected to the MCP server",
|
||||||
|
)
|
||||||
|
await expect(page.getByTestId("shell-session")).toHaveText(
|
||||||
|
shellUrl.slice(-8),
|
||||||
|
)
|
||||||
|
expect(errors).toEqual([])
|
||||||
|
})
|
||||||
|
|
||||||
|
// --- The sync with the server (shell/use-mcp-sync.ts) ---
|
||||||
|
|
||||||
|
// Each test draws labels of its own: the previous test's diagram is on the
|
||||||
|
// canvas when a test starts, and a label shared with it would be visible
|
||||||
|
// before the test's own write arrives
|
||||||
|
const box = (id: string, label: string, x: number) =>
|
||||||
|
`<mxCell id="${id}" value="${label}" x="${x}" y="40" w="120" h="60"/>`
|
||||||
|
|
||||||
|
const toolText = async (name: string, args: Record<string, unknown> = {}) => {
|
||||||
|
const result = await client.callTool({ name, arguments: args })
|
||||||
|
return (result.content as { type: string; text?: string }[])
|
||||||
|
.map((c) => c.text ?? "")
|
||||||
|
.join("\n")
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The shell with the bundled draw.io ready and in step with the server */
|
||||||
|
async function openShell(page: Page) {
|
||||||
|
await page.goto(shellUrl)
|
||||||
|
await expect(page.getByTestId("canvas-loading")).toHaveCount(0, {
|
||||||
|
timeout: 60_000,
|
||||||
|
})
|
||||||
|
await expect(page.getByTestId("shell-status")).toHaveAttribute(
|
||||||
|
"data-status",
|
||||||
|
"connected",
|
||||||
|
)
|
||||||
|
return page.frameLocator("iframe[title='draw.io']")
|
||||||
|
}
|
||||||
|
|
||||||
|
test("a diagram the MCP draws appears in the shell", async ({ page }) => {
|
||||||
|
const frame = await openShell(page)
|
||||||
|
await toolText("create_new_diagram", {
|
||||||
|
xml: `${box("2", "Alpha", 40)}\n${box("3", "Beta", 240)}\n<mxCell id="4" source="2" target="3"/>`,
|
||||||
|
})
|
||||||
|
await expect(frame.getByText("Alpha")).toBeVisible({ timeout: 10_000 })
|
||||||
|
await expect(frame.getByText("Beta")).toBeVisible()
|
||||||
|
})
|
||||||
|
|
||||||
|
test("an edit is marked on the canvas, and one Ctrl+Z takes it back for the server too", async ({
|
||||||
|
page,
|
||||||
|
}) => {
|
||||||
|
const frame = await openShell(page)
|
||||||
|
await toolText("create_new_diagram", {
|
||||||
|
xml: `${box("2", "Node A", 40)}\n${box("3", "Node B", 240)}`,
|
||||||
|
})
|
||||||
|
await expect(frame.getByText("Node A")).toBeVisible({ timeout: 10_000 })
|
||||||
|
await toolText("edit_diagram", {
|
||||||
|
operations: [
|
||||||
|
{ operation: "add", cell_id: "5", new_xml: box("5", "Gamma", 440) },
|
||||||
|
],
|
||||||
|
})
|
||||||
|
await expect(frame.getByText("Gamma")).toBeVisible({ timeout: 10_000 })
|
||||||
|
// The editor bridge outlines the changed shape (editor-bridge.ts
|
||||||
|
// HIGHLIGHT_OUTLINE); the write went on the canvas in place
|
||||||
|
await expect(frame.locator('[stroke="#a86b00"]')).toHaveCount(1)
|
||||||
|
|
||||||
|
await frame
|
||||||
|
.locator(".geDiagramContainer")
|
||||||
|
.click({ position: { x: 10, y: 10 } })
|
||||||
|
await page.keyboard.press("Control+z")
|
||||||
|
await expect(frame.getByText("Gamma")).toHaveCount(0)
|
||||||
|
await expect(frame.getByText("Node A")).toBeVisible()
|
||||||
|
// The undo is pushed: the server's diagram has no Gamma (get_diagram
|
||||||
|
// also names the removed cell in its notes, so look at the XML)
|
||||||
|
await expect
|
||||||
|
.poll(async () => (await toolText("get_diagram")).includes('id="5"'), {
|
||||||
|
timeout: 10_000,
|
||||||
|
})
|
||||||
|
.toBe(false)
|
||||||
|
expect(await toolText("get_diagram")).toContain('value="Node A"')
|
||||||
|
})
|
||||||
|
|
||||||
|
test("a change made in the editor is pushed, and get_diagram returns it", async ({
|
||||||
|
page,
|
||||||
|
}) => {
|
||||||
|
const frame = await openShell(page)
|
||||||
|
await toolText("create_new_diagram", { xml: box("2", "Solo", 40) })
|
||||||
|
await expect(frame.getByText("Solo")).toBeVisible({ timeout: 10_000 })
|
||||||
|
// draw.io keeps no global editor; in embed mode Draw.loadPlugin hands
|
||||||
|
// it to the callback at once
|
||||||
|
await frame.locator("body").evaluate((body) => {
|
||||||
|
const w = body.ownerDocument.defaultView as any
|
||||||
|
w.Draw.loadPlugin((ui: any) => {
|
||||||
|
const graph = ui.editor.graph
|
||||||
|
graph.getModel().beginUpdate()
|
||||||
|
try {
|
||||||
|
graph.insertVertex(
|
||||||
|
graph.getDefaultParent(),
|
||||||
|
"manual-1",
|
||||||
|
"Manual",
|
||||||
|
40,
|
||||||
|
200,
|
||||||
|
120,
|
||||||
|
60,
|
||||||
|
"rounded=1;",
|
||||||
|
)
|
||||||
|
} finally {
|
||||||
|
graph.getModel().endUpdate()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
|
await expect(frame.getByText("Manual")).toBeVisible()
|
||||||
|
await expect
|
||||||
|
.poll(
|
||||||
|
async () =>
|
||||||
|
(await toolText("get_diagram")).includes('id="manual-1"'),
|
||||||
|
{ timeout: 10_000 },
|
||||||
|
)
|
||||||
|
.toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
test("screenshot_diagram renders the canvas of the shell", async ({ page }) => {
|
||||||
|
const frame = await openShell(page)
|
||||||
|
await toolText("create_new_diagram", {
|
||||||
|
xml: `${box("2", "Shot A", 40)}\n${box("3", "Shot B", 240)}`,
|
||||||
|
})
|
||||||
|
await expect(frame.getByText("Shot B")).toBeVisible({ timeout: 10_000 })
|
||||||
|
const result = await client.callTool({
|
||||||
|
name: "screenshot_diagram",
|
||||||
|
arguments: {},
|
||||||
|
})
|
||||||
|
const image = (
|
||||||
|
result.content as { type: string; data?: string; mimeType?: string }[]
|
||||||
|
).find((c) => c.type === "image")
|
||||||
|
expect(result.isError ?? false).toBe(false)
|
||||||
|
expect(image?.mimeType).toBe("image/png")
|
||||||
|
expect(image?.data?.length ?? 0).toBeGreaterThan(1000)
|
||||||
|
})
|
||||||
|
|
||||||
|
test("exporting another page shows it for a moment, then the user's page comes back", async ({
|
||||||
|
page,
|
||||||
|
}) => {
|
||||||
|
const frame = await openShell(page)
|
||||||
|
await toolText("create_new_diagram", { xml: box("2", "First page", 40) })
|
||||||
|
await expect(frame.getByText("First page")).toBeVisible({ timeout: 10_000 })
|
||||||
|
await toolText("add_page", {
|
||||||
|
name: "Second",
|
||||||
|
xml: `<mxGraphModel><root><mxCell id="0"/><mxCell id="1" parent="0"/><mxCell id="s1" value="OnSecond" style="rounded=1;" vertex="1" parent="1"><mxGeometry x="40" y="40" width="120" height="60" as="geometry"/></mxCell></root></mxGraphModel>`,
|
||||||
|
})
|
||||||
|
await expect(frame.getByText("Second")).toBeVisible({ timeout: 10_000 })
|
||||||
|
// SVG has no page option in draw.io: the server sends a one-page
|
||||||
|
// projection, which the shell shows only for the export
|
||||||
|
const file = join(dataDir, "second.svg")
|
||||||
|
const text = await toolText("export_diagram", {
|
||||||
|
path: file,
|
||||||
|
page_name: "Second",
|
||||||
|
})
|
||||||
|
expect(text).toContain("exported successfully")
|
||||||
|
expect(readFileSync(file, "utf8")).toContain("OnSecond")
|
||||||
|
await expect(frame.getByText("First page")).toBeVisible({ timeout: 10_000 })
|
||||||
|
await expect(frame.locator(".geTabContainer .geActivePage")).toHaveText(
|
||||||
|
"Page-1",
|
||||||
|
)
|
||||||
|
// The projection was never pushed as the document
|
||||||
|
const xml = await toolText("get_diagram")
|
||||||
|
expect(xml).toContain('value="First page"')
|
||||||
|
expect(xml).toContain('name="Second"')
|
||||||
|
})
|
||||||
|
|
||||||
|
test("a screenshot of another page leaves the page on screen alone", async ({
|
||||||
|
page,
|
||||||
|
}) => {
|
||||||
|
const frame = await openShell(page)
|
||||||
|
await toolText("create_new_diagram", { xml: box("2", "Front page", 40) })
|
||||||
|
await expect(frame.getByText("Front page")).toBeVisible({ timeout: 10_000 })
|
||||||
|
await toolText("add_page", {
|
||||||
|
name: "Backside",
|
||||||
|
xml: `<mxGraphModel><root><mxCell id="0"/><mxCell id="1" parent="0"/><mxCell id="b1" value="OnBack" style="rounded=1;" vertex="1" parent="1"><mxGeometry x="40" y="40" width="120" height="60" as="geometry"/></mxCell></root></mxGraphModel>`,
|
||||||
|
})
|
||||||
|
const tabs = frame.locator(".geTabContainer")
|
||||||
|
await expect(tabs.getByText("Backside", { exact: true })).toBeVisible({
|
||||||
|
timeout: 10_000,
|
||||||
|
})
|
||||||
|
const activeTab = tabs.locator(".geActivePage")
|
||||||
|
await expect(activeTab).toHaveText("Page-1")
|
||||||
|
// The page on screen, as the editor reports it to the server
|
||||||
|
const before = await toolText("get_selection")
|
||||||
|
expect(before).toContain('page "Page-1"')
|
||||||
|
|
||||||
|
// PNG: draw.io renders the other page by its id, off screen; the shell
|
||||||
|
// never shows it as a projection, so the page on screen does not change
|
||||||
|
const png = async (args: Record<string, unknown>) => {
|
||||||
|
const result = await client.callTool({
|
||||||
|
name: "screenshot_diagram",
|
||||||
|
arguments: args,
|
||||||
|
})
|
||||||
|
const image = (
|
||||||
|
result.content as {
|
||||||
|
type: string
|
||||||
|
data?: string
|
||||||
|
mimeType?: string
|
||||||
|
}[]
|
||||||
|
).find((c) => c.type === "image")
|
||||||
|
expect(result.isError ?? false).toBe(false)
|
||||||
|
expect(image?.mimeType).toBe("image/png")
|
||||||
|
expect(image?.data?.length ?? 0).toBeGreaterThan(1000)
|
||||||
|
return image?.data ?? ""
|
||||||
|
}
|
||||||
|
const back = await png({ page_name: "Backside" })
|
||||||
|
await expect(activeTab).toHaveText("Page-1")
|
||||||
|
await expect(frame.getByText("Front page")).toBeVisible()
|
||||||
|
expect(await toolText("get_selection")).toContain('page "Page-1"')
|
||||||
|
// Another picture than the page on screen
|
||||||
|
expect(await png({})).not.toBe(back)
|
||||||
|
})
|
||||||
|
|
||||||
|
test("get_selection reports the cells selected in the editor", async ({
|
||||||
|
page,
|
||||||
|
}) => {
|
||||||
|
const frame = await openShell(page)
|
||||||
|
await toolText("create_new_diagram", {
|
||||||
|
xml: `${box("2", "Pick me", 40)}\n${box("3", "Leave me", 240)}\n<mxCell id="4" value="link" source="2" target="3"/>`,
|
||||||
|
})
|
||||||
|
await expect(frame.getByText("Pick me")).toBeVisible({ timeout: 10_000 })
|
||||||
|
const nothing = await toolText("get_selection")
|
||||||
|
expect(nothing).toContain("Nothing is selected")
|
||||||
|
expect(nothing).toContain('page "Page-1"')
|
||||||
|
|
||||||
|
// Select a shape and the edge in the editor, as a click would
|
||||||
|
await frame.locator("body").evaluate((body) => {
|
||||||
|
const w = body.ownerDocument.defaultView as any
|
||||||
|
w.Draw.loadPlugin((ui: any) => {
|
||||||
|
const graph = ui.editor.graph
|
||||||
|
graph.setSelectionCells([
|
||||||
|
graph.model.getCell("2"),
|
||||||
|
graph.model.getCell("4"),
|
||||||
|
])
|
||||||
|
})
|
||||||
|
})
|
||||||
|
const text = await toolText("get_selection")
|
||||||
|
expect(text).toContain('2 cells selected on page "Page-1"')
|
||||||
|
expect(text).toContain('id="2" shape "Pick me" at x=40 y=40 w=120 h=60')
|
||||||
|
expect(text).toContain('id="4" edge "link" from "2" to "3"')
|
||||||
|
expect(text).not.toContain('id="3"')
|
||||||
|
|
||||||
|
// A shape in a container is reported with it, also after the user
|
||||||
|
// entered the container (Ctrl+Shift+End), where it is the default parent
|
||||||
|
await toolText("create_new_diagram", {
|
||||||
|
xml: `<mxCell id="box" value="Box" style="swimlane;" vertex="1" parent="1"><mxGeometry x="40" y="40" width="300" height="200" as="geometry"/></mxCell>\n<mxCell id="kid" value="Kid" style="rounded=1;" vertex="1" parent="box"><mxGeometry x="20" y="30" width="120" height="60" as="geometry"/></mxCell>`,
|
||||||
|
})
|
||||||
|
await expect(frame.getByText("Kid")).toBeVisible({ timeout: 10_000 })
|
||||||
|
await frame.locator("body").evaluate((body) => {
|
||||||
|
const w = body.ownerDocument.defaultView as any
|
||||||
|
w.Draw.loadPlugin((ui: any) => {
|
||||||
|
const graph = ui.editor.graph
|
||||||
|
graph.enterGroup(graph.model.getCell("box"))
|
||||||
|
graph.setSelectionCell(graph.model.getCell("kid"))
|
||||||
|
})
|
||||||
|
})
|
||||||
|
const inside = await toolText("get_selection")
|
||||||
|
expect(inside).toContain(
|
||||||
|
'id="kid" shape "Kid" at x=20 y=30 w=120 h=60 inside "box"',
|
||||||
|
)
|
||||||
|
await frame.locator("body").evaluate((body) => {
|
||||||
|
const w = body.ownerDocument.defaultView as any
|
||||||
|
w.Draw.loadPlugin((ui: any) => ui.editor.graph.exitGroup())
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
test("the download button saves the diagram as a file", async ({ page }) => {
|
||||||
|
const frame = await openShell(page)
|
||||||
|
await toolText("create_new_diagram", { xml: box("2", "Keep me", 40) })
|
||||||
|
await expect(frame.getByText("Keep me")).toBeVisible({ timeout: 10_000 })
|
||||||
|
await page.getByTestId("download").click()
|
||||||
|
const dialog = page.getByRole("dialog")
|
||||||
|
await expect(dialog).toBeVisible()
|
||||||
|
// The dialog's utility classes are in the shell's stylesheet (shell.css
|
||||||
|
// names the shared component with @source): the formats in two columns
|
||||||
|
const columns = await dialog
|
||||||
|
.getByRole("radiogroup")
|
||||||
|
.evaluate((el) => getComputedStyle(el).gridTemplateColumns)
|
||||||
|
expect(columns.split(" ")).toHaveLength(2)
|
||||||
|
// .drawio is the default format
|
||||||
|
const download = page.waitForEvent("download")
|
||||||
|
await dialog.getByRole("button", { name: "Export" }).click()
|
||||||
|
const file = await download
|
||||||
|
expect(file.suggestedFilename()).toMatch(
|
||||||
|
/^diagram-\d{4}-\d{2}-\d{2}\.drawio$/,
|
||||||
|
)
|
||||||
|
// draw.io's own export of the document (compressed pages, as the web
|
||||||
|
// app saves them; load_diagram opens them)
|
||||||
|
const saved = readFileSync(await file.path(), "utf8")
|
||||||
|
expect(saved).toMatch(/^<mxfile\b/)
|
||||||
|
expect(saved).toContain('name="Page-1"')
|
||||||
|
})
|
||||||
|
|
||||||
|
test("the download waits for a page export's projection, and saves every page", async ({
|
||||||
|
page,
|
||||||
|
}) => {
|
||||||
|
const frame = await openShell(page)
|
||||||
|
await toolText("create_new_diagram", { xml: box("2", "Stay here", 40) })
|
||||||
|
await expect(frame.getByText("Stay here")).toBeVisible({ timeout: 10_000 })
|
||||||
|
await toolText("add_page", {
|
||||||
|
name: "Elsewhere",
|
||||||
|
xml: `<mxGraphModel><root><mxCell id="0"/><mxCell id="1" parent="0"/><mxCell id="e1" value="OnElsewhere" style="rounded=1;" vertex="1" parent="1"><mxGeometry x="40" y="40" width="120" height="60" as="geometry"/></mxCell></root></mxGraphModel>`,
|
||||||
|
})
|
||||||
|
await expect(
|
||||||
|
frame
|
||||||
|
.locator(".geTabContainer")
|
||||||
|
.getByText("Elsewhere", { exact: true }),
|
||||||
|
).toBeVisible({ timeout: 10_000 })
|
||||||
|
await page.getByTestId("download").click()
|
||||||
|
const dialog = page.getByRole("dialog")
|
||||||
|
await expect(dialog).toBeVisible()
|
||||||
|
// The export's result is held up for 2 s: the one-page projection of
|
||||||
|
// the other page is surely still on the canvas when Export is clicked
|
||||||
|
await page.route("**/api/state", async (route) => {
|
||||||
|
const request = route.request()
|
||||||
|
if (
|
||||||
|
request.method() === "POST" &&
|
||||||
|
(request.postData() ?? "").includes('"exportData"')
|
||||||
|
) {
|
||||||
|
await new Promise((r) => setTimeout(r, 2000))
|
||||||
|
}
|
||||||
|
await route.continue()
|
||||||
|
})
|
||||||
|
const exported = toolText("export_diagram", {
|
||||||
|
path: join(dataDir, "elsewhere.svg"),
|
||||||
|
page_name: "Elsewhere",
|
||||||
|
})
|
||||||
|
await expect(frame.getByText("OnElsewhere")).toBeVisible({
|
||||||
|
timeout: 10_000,
|
||||||
|
})
|
||||||
|
const download = page.waitForEvent("download")
|
||||||
|
await dialog.getByRole("button", { name: "Export" }).click()
|
||||||
|
// Saved once the user's document is back: both pages, not the projection
|
||||||
|
const saved = readFileSync(await (await download).path(), "utf8")
|
||||||
|
expect(saved.match(/<diagram\b/g)).toHaveLength(2)
|
||||||
|
expect(saved).toContain('name="Page-1"')
|
||||||
|
expect(saved).toContain('name="Elsewhere"')
|
||||||
|
expect(await exported).toContain("exported successfully")
|
||||||
|
})
|
||||||
|
|
||||||
|
// --- Version cards from the server's History (shell/node-versions-source.ts) ---
|
||||||
|
|
||||||
|
test("the version cards follow the server's History; undo, redo and restore go through it", async ({
|
||||||
|
page,
|
||||||
|
}) => {
|
||||||
|
// A session of its own, so History holds only this test's versions
|
||||||
|
const started = await toolText("start_session")
|
||||||
|
const url = started.match(/Browser URL: (\S+)/)?.[1]
|
||||||
|
if (!url) throw new Error(`start_session gave no URL:\n${started}`)
|
||||||
|
await page.goto(url)
|
||||||
|
await expect(page.getByTestId("canvas-loading")).toHaveCount(0, {
|
||||||
|
timeout: 60_000,
|
||||||
|
})
|
||||||
|
await expect(page.getByTestId("shell-status")).toHaveAttribute(
|
||||||
|
"data-status",
|
||||||
|
"connected",
|
||||||
|
)
|
||||||
|
const frame = page.frameLocator("iframe[title='draw.io']")
|
||||||
|
const cards = page.getByTestId("version-card")
|
||||||
|
const latestCard = cards.last()
|
||||||
|
const getDiagram = () => toolText("get_diagram")
|
||||||
|
|
||||||
|
// Three writes, three cards; the newest can be undone
|
||||||
|
await toolText("create_new_diagram", { xml: box("a", "Card A", 40) })
|
||||||
|
await expect(frame.getByText("Card A")).toBeVisible({ timeout: 10_000 })
|
||||||
|
await expect(cards).toHaveCount(1, { timeout: 10_000 })
|
||||||
|
await expect(cards.first()).toContainText("Drew the diagram")
|
||||||
|
await toolText("edit_diagram", {
|
||||||
|
operations: [
|
||||||
|
{
|
||||||
|
operation: "add",
|
||||||
|
cell_id: "b",
|
||||||
|
new_xml: box("b", "Card B", 240),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
})
|
||||||
|
await expect(frame.getByText("Card B")).toBeVisible({ timeout: 10_000 })
|
||||||
|
await toolText("edit_diagram", {
|
||||||
|
operations: [
|
||||||
|
{
|
||||||
|
operation: "add",
|
||||||
|
cell_id: "c",
|
||||||
|
new_xml: box("c", "Card C", 440),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
})
|
||||||
|
await expect(frame.getByText("Card C")).toBeVisible({ timeout: 10_000 })
|
||||||
|
await expect(cards).toHaveCount(3, { timeout: 10_000 })
|
||||||
|
await expect(latestCard).toContainText("Changed the diagram")
|
||||||
|
await expect(latestCard.getByTestId("version-undo")).toHaveText(
|
||||||
|
/Undo change/,
|
||||||
|
)
|
||||||
|
|
||||||
|
// Undo: the server restores the version before; the card says so
|
||||||
|
await latestCard.getByTestId("version-undo").click()
|
||||||
|
await expect(frame.getByText("Card C")).toHaveCount(0, { timeout: 10_000 })
|
||||||
|
await expect(frame.getByText("Card B")).toBeVisible()
|
||||||
|
await expect(latestCard).toContainText("Change undone")
|
||||||
|
await expect(latestCard.getByTestId("version-undo")).toHaveText(
|
||||||
|
/Redo change/,
|
||||||
|
)
|
||||||
|
// A restore adds a History entry, not a card
|
||||||
|
await expect(cards).toHaveCount(3)
|
||||||
|
await expect
|
||||||
|
.poll(async () => (await getDiagram()).includes('id="c"'), {
|
||||||
|
timeout: 10_000,
|
||||||
|
})
|
||||||
|
.toBe(false)
|
||||||
|
|
||||||
|
// Redo: the newest version is back on the canvas and the server
|
||||||
|
await latestCard.getByTestId("version-undo").click()
|
||||||
|
await expect(frame.getByText("Card C")).toBeVisible({ timeout: 10_000 })
|
||||||
|
await expect(latestCard).toContainText("Changed the diagram")
|
||||||
|
await expect(latestCard.getByTestId("version-undo")).toHaveText(
|
||||||
|
/Undo change/,
|
||||||
|
)
|
||||||
|
await expect
|
||||||
|
.poll(async () => (await getDiagram()).includes('id="c"'), {
|
||||||
|
timeout: 10_000,
|
||||||
|
})
|
||||||
|
.toBe(true)
|
||||||
|
|
||||||
|
// A hand edit: no version is on the canvas, so the newest card offers
|
||||||
|
// a restore, not an undo
|
||||||
|
await frame.locator("body").evaluate((body) => {
|
||||||
|
const w = body.ownerDocument.defaultView as any
|
||||||
|
w.Draw.loadPlugin((ui: any) => {
|
||||||
|
const graph = ui.editor.graph
|
||||||
|
graph.getModel().beginUpdate()
|
||||||
|
try {
|
||||||
|
graph.insertVertex(
|
||||||
|
graph.getDefaultParent(),
|
||||||
|
"manual-card",
|
||||||
|
"By hand",
|
||||||
|
40,
|
||||||
|
200,
|
||||||
|
120,
|
||||||
|
60,
|
||||||
|
"rounded=1;",
|
||||||
|
)
|
||||||
|
} finally {
|
||||||
|
graph.getModel().endUpdate()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
|
await expect
|
||||||
|
.poll(async () => (await getDiagram()).includes('id="manual-card"'), {
|
||||||
|
timeout: 10_000,
|
||||||
|
})
|
||||||
|
.toBe(true)
|
||||||
|
await expect(latestCard.getByTestId("version-restore")).toBeVisible()
|
||||||
|
await expect(cards).toHaveCount(3)
|
||||||
|
|
||||||
|
// Restoring the first version keeps the hand edit as a version of its
|
||||||
|
// own, and get_diagram agrees with the canvas
|
||||||
|
await cards.first().getByTestId("version-restore").click()
|
||||||
|
await expect(frame.getByText("Card B")).toHaveCount(0, { timeout: 10_000 })
|
||||||
|
await expect(frame.getByText("By hand")).toHaveCount(0)
|
||||||
|
await expect(frame.getByText("Card A")).toBeVisible()
|
||||||
|
await expect(cards).toHaveCount(4, { timeout: 10_000 })
|
||||||
|
await expect(cards.nth(3)).toContainText("Changed by hand")
|
||||||
|
// The first version is on the canvas: its restore button rests
|
||||||
|
await expect(cards.first().getByTestId("version-restore")).toBeDisabled()
|
||||||
|
await expect
|
||||||
|
.poll(getDiagram, { timeout: 10_000 })
|
||||||
|
.toContain('value="Card A"')
|
||||||
|
const xml = await getDiagram()
|
||||||
|
expect(xml).not.toContain('id="b"')
|
||||||
|
expect(xml).not.toContain('id="manual-card"')
|
||||||
|
// The MCP tool sees the same versions: the hand edit is one step back
|
||||||
|
expect(await toolText("restore_version", { steps_back: 1 })).toContain(
|
||||||
|
"Restored the version 1 step(s) back",
|
||||||
|
)
|
||||||
|
await expect(frame.getByText("By hand")).toBeVisible({ timeout: 10_000 })
|
||||||
|
await expect(cards).toHaveCount(4)
|
||||||
|
})
|
||||||
|
|
||||||
|
test("the theme toggle switches the page and draw.io, and is remembered", async ({
|
||||||
|
page,
|
||||||
|
}) => {
|
||||||
|
await page.goto(shellUrl)
|
||||||
|
await expect(page.getByTestId("canvas-loading")).toHaveCount(0, {
|
||||||
|
timeout: 60_000,
|
||||||
|
})
|
||||||
|
const html = page.locator("html")
|
||||||
|
const frameBody = page
|
||||||
|
.frameLocator("iframe[title='draw.io']")
|
||||||
|
.locator("body")
|
||||||
|
await expect(html).not.toHaveClass(/dark/)
|
||||||
|
await expect(frameBody).not.toHaveClass(/geDarkMode/)
|
||||||
|
|
||||||
|
await page.getByTestId("theme-toggle").click()
|
||||||
|
await expect(html).toHaveClass(/dark/)
|
||||||
|
// Same-origin draw.io switches in place, without a reload
|
||||||
|
await expect(frameBody).toHaveClass(/geDarkMode/)
|
||||||
|
expect(
|
||||||
|
await page.evaluate(() => localStorage.getItem("mcp:dark-mode")),
|
||||||
|
).toBe("true")
|
||||||
|
|
||||||
|
await page.reload()
|
||||||
|
await expect(html).toHaveClass(/dark/)
|
||||||
|
await page.getByTestId("theme-toggle").click()
|
||||||
|
await expect(html).not.toHaveClass(/dark/)
|
||||||
|
})
|
||||||
@@ -19,6 +19,7 @@ beforeAll(() => {
|
|||||||
import {
|
import {
|
||||||
checkEditGate,
|
checkEditGate,
|
||||||
contentFingerprint,
|
contentFingerprint,
|
||||||
|
describeChanges,
|
||||||
markPageSeen,
|
markPageSeen,
|
||||||
} from "../src/edit-gate.ts"
|
} from "../src/edit-gate.ts"
|
||||||
|
|
||||||
@@ -168,3 +169,139 @@ describe("markPageSeen", () => {
|
|||||||
expect(markPageSeen("", live, { page_id: "A" })).toBe(live)
|
expect(markPageSeen("", live, { page_id: "A" })).toBe(live)
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
describe("describeChanges", () => {
|
||||||
|
const BOX2 = `<mxCell id="box2" value="World" vertex="1" parent="1"><mxGeometry x="200" y="40" width="120" height="60" as="geometry"/></mxCell>`
|
||||||
|
const XML_A_PLUS_BOX2 = XML_A.replace("</root>", `${BOX2}</root>`)
|
||||||
|
const PAGE_2 = `<diagram id="p2" name="Page-2"><mxGraphModel><root><mxCell id="0"/><mxCell id="1" parent="0"/></root></mxGraphModel></diagram>`
|
||||||
|
const XML_A_TWO_PAGES = XML_A.replace("</mxfile>", `${PAGE_2}</mxfile>`)
|
||||||
|
|
||||||
|
it("names a moved cell with its label", () => {
|
||||||
|
const summary = describeChanges(XML_A, XML_B)
|
||||||
|
expect(summary).toBe(
|
||||||
|
'Since you last saw it, the user changed the diagram: modified box1 ("Hello").',
|
||||||
|
)
|
||||||
|
expect(summary).toContain("modified box1")
|
||||||
|
})
|
||||||
|
|
||||||
|
it("names a deleted cell", () => {
|
||||||
|
expect(describeChanges(XML_A_PLUS_BOX2, XML_A)).toContain(
|
||||||
|
'removed box2 ("World")',
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
it("names an added cell", () => {
|
||||||
|
expect(describeChanges(XML_A, XML_A_PLUS_BOX2)).toContain(
|
||||||
|
'added box2 ("World")',
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
it("is empty for draw.io's re-serialisation of the same content", () => {
|
||||||
|
expect(describeChanges(XML_A, XML_A_RESERIALIZED)).toBe("")
|
||||||
|
})
|
||||||
|
|
||||||
|
it("is empty when the model has seen nothing yet", () => {
|
||||||
|
expect(describeChanges("", XML_B)).toBe("")
|
||||||
|
})
|
||||||
|
|
||||||
|
it("is empty when one side does not parse", () => {
|
||||||
|
expect(describeChanges(XML_A, "not xml at all")).toBe("")
|
||||||
|
})
|
||||||
|
|
||||||
|
it("reports a renamed page", () => {
|
||||||
|
const renamed = XML_A.replace('name="Page-1"', 'name="Renamed"')
|
||||||
|
expect(describeChanges(XML_A, renamed)).toBe(
|
||||||
|
'Since you last saw it, the user changed the diagram. Page 1 renamed to "Renamed".',
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
// A bare <mxGraphModel> push carries no page name, so the invented
|
||||||
|
// "Page-1" must not read as a rename
|
||||||
|
it("never reports a rename against a bare mxGraphModel", () => {
|
||||||
|
const seenRenamed = XML_A.replace('name="Page-1"', 'name="Arch"')
|
||||||
|
expect(describeChanges(seenRenamed, XML_A_BARE)).toBe("")
|
||||||
|
const bareMoved = XML_A_BARE.replace('x="40" y="40"', 'x="300" y="200"')
|
||||||
|
const summary = describeChanges(seenRenamed, bareMoved)
|
||||||
|
expect(summary).toContain("modified box1")
|
||||||
|
expect(summary).not.toContain("renamed")
|
||||||
|
})
|
||||||
|
|
||||||
|
it("strips HTML from labels and reads UserObject labels", () => {
|
||||||
|
const html = XML_A.replace(
|
||||||
|
'value="Hello"',
|
||||||
|
'value="<b>Hello</b>"',
|
||||||
|
)
|
||||||
|
expect(describeChanges(XML_A, html)).toContain(
|
||||||
|
'modified box1 ("Hello")',
|
||||||
|
)
|
||||||
|
const userObject = XML_A.replace(
|
||||||
|
"</root>",
|
||||||
|
`<UserObject id="u1" label="<i>Link</i>" link="https://x.y"><mxCell style="rounded=1;" vertex="1" parent="1"><mxGeometry x="0" y="0" width="80" height="40" as="geometry"/></mxCell></UserObject></root>`,
|
||||||
|
)
|
||||||
|
expect(describeChanges(XML_A, userObject)).toContain(
|
||||||
|
'added u1 ("Link")',
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
it("keeps the words of an HTML label apart and decodes its spaces", () => {
|
||||||
|
const lines = XML_A.replace(
|
||||||
|
'value="Hello"',
|
||||||
|
'value="Line1<br>Line2&nbsp;end &amp; more"',
|
||||||
|
)
|
||||||
|
expect(describeChanges(XML_A, lines)).toContain(
|
||||||
|
'modified box1 ("Line1 Line2 end & more")',
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
it("cuts long labels at 30 characters", () => {
|
||||||
|
const long = "a".repeat(40)
|
||||||
|
const summary = describeChanges(
|
||||||
|
XML_A,
|
||||||
|
XML_A.replace('value="Hello"', `value="${long}"`),
|
||||||
|
)
|
||||||
|
expect(summary).toContain(`("${"a".repeat(30)}...")`)
|
||||||
|
})
|
||||||
|
|
||||||
|
it("lists at most 20 cells and counts the rest", () => {
|
||||||
|
const many = Array.from(
|
||||||
|
{ length: 25 },
|
||||||
|
(_, i) => `<mxCell id="n${i}" vertex="1" parent="1"/>`,
|
||||||
|
).join("")
|
||||||
|
const summary = describeChanges(
|
||||||
|
XML_A,
|
||||||
|
XML_A.replace("</root>", `${many}</root>`),
|
||||||
|
)
|
||||||
|
expect(summary).toContain("added n0, n1,")
|
||||||
|
expect(summary).toContain("n19 and 5 more.")
|
||||||
|
expect(summary).not.toContain("n20")
|
||||||
|
})
|
||||||
|
|
||||||
|
it("reports added and removed pages", () => {
|
||||||
|
expect(describeChanges(XML_A, XML_A_TWO_PAGES)).toBe(
|
||||||
|
"Since you last saw it, the user changed the diagram: page 2 added.",
|
||||||
|
)
|
||||||
|
expect(describeChanges(XML_A_TWO_PAGES, XML_A)).toBe(
|
||||||
|
"Since you last saw it, the user changed the diagram: page 2 removed.",
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
it("names the page of a cell change in a multi-page document", () => {
|
||||||
|
const movedOnPage1 = XML_A_TWO_PAGES.replace(
|
||||||
|
'x="40" y="40"',
|
||||||
|
'x="300" y="200"',
|
||||||
|
)
|
||||||
|
expect(describeChanges(XML_A_TWO_PAGES, movedOnPage1)).toContain(
|
||||||
|
'page 1 modified box1 ("Hello")',
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
it("falls back to a generic clause when only the cell order changed", () => {
|
||||||
|
const reordered = XML_A_PLUS_BOX2.replace(BOX2, "").replace(
|
||||||
|
'<mxCell id="box1"',
|
||||||
|
`${BOX2}<mxCell id="box1"`,
|
||||||
|
)
|
||||||
|
expect(describeChanges(XML_A_PLUS_BOX2, reordered)).toBe(
|
||||||
|
"Since you last saw it, the user changed the diagram: the order of the cells changed.",
|
||||||
|
)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user