Compare commits

..

22 Commits

Author SHA1 Message Date
renovate[bot]
312ad0701b fix(deps): update minor and patch dependencies 2026-09-01 01:28:43 +00:00
Dayuan Jiang
155ef4f7ac fix: raise the output budget so reasoning models reach the tool call (#927)
* fix: raise the output budget so reasoning models reach the tool call

A reasoning model spends the output budget in order: thinking first, then prose,
then the tool call. With 16000 the thinking alone can consume all of it, so the
turn ends with finishReason "length" before display_diagram is ever called. The
canvas stays empty and nothing surfaces in the UI, because no tool call means no
tool error, and the client never reads finishReason.

Measured on openrouter deepseek/deepseek-v4-flash, the model from the report:
- max_tokens=800 with reasoning on returns reasoning_tokens=800, empty content,
  finish_reason length. So reasoning is billed against this budget, not exempt.
- refining an existing diagram (19k chars of XML in the input) produced 49142
  chars of reasoning, zero tool calls, finishReason "length" at 16000
- the same request at 40000 finished and called edit_diagram with 12 operations

64000 cannot just be sent to every model: bedrock claude-3-haiku caps at 4096,
nova-lite at 10000, and the openrouter deepseek-r1 endpoint counts input and
output against one 64000 ceiling. All three name the real limit in the 400, so
parse it and retry once. Verified: nova-lite logs "64000 rejected, retrying with
10000" and then completes its tool call.

Also expose the budget in Settings. It is sent as a header rather than read from
env only, so desktop users can raise it themselves without an env file.

vercel.json goes back to the 300s it had before #238 traded it for $2-4/month.
That is now Vercel's own default, and billing pauses while the function waits on
the model, so the saving that motivated 120s no longer applies. edgeone.json is
left alone: its 120 may be that platform's actual ceiling.

* fix: only reinterpret an error as a budget rejection when it says so

Review of the first commit found the retry could fire on errors that have
nothing to do with the budget, which would replace a readable provider error
with a truncated response: exactly the symptom this PR exists to remove.

- Drop the generic "lower than N" pattern. For the Bedrock message it was dead
  code, since "model limit of N" matches first with the same number. Left live,
  it would read a number out of any message shaped like "must be lower than 2".
- Skip errors whose status is not 400 or 422, so auth and rate-limit failures
  are never reinterpreted.
- Require the parsed ceiling to be at least 1024. Below that a diagram cannot
  come out whole, so retrying would hide the error behind broken XML.
- Validate MAX_OUTPUT_TOKENS from env the same way as the header, so a stray
  "-1" falls back instead of reaching the provider.

Adds tests for the retry wrapper itself, which had none: it retries once with
the named ceiling, leaves a 401 alone, does not retry when the ceiling is not
smaller, propagates a second rejection, and preserves the other call options.

Re-verified against the live APIs: bedrock nova-lite still logs "64000 rejected,
retrying with 10000" and completes its tool call, and deepseek-v4-flash still
finishes normally at 64000.
2026-08-22 20:47:10 +09:00
nb213
12903cd516 docs: acknowledge Atlas Cloud sponsorship (#919)
* docs: acknowledge Atlas Cloud sponsorship

Signed-off-by: binyangzhu000-sudo <224954946+binyangzhu000-sudo@users.noreply.github.com>

* docs: add Atlas Cloud logo assets

Signed-off-by: binyangzhu000-sudo <224954946+binyangzhu000-sudo@users.noreply.github.com>

---------

Signed-off-by: binyangzhu000-sudo <224954946+binyangzhu000-sudo@users.noreply.github.com>
Co-authored-by: binyangzhu000-sudo <224954946+binyangzhu000-sudo@users.noreply.github.com>
2026-08-22 08:43:45 +09:00
Dayuan Jiang
96bca2b37b fix: always send maxOutputTokens; default 16000 (#915)
Unset does not mean the model's maximum — the provider fills in its own, and
Bedrock's is 4096 (measured: converse with no inferenceConfig on
us.anthropic.claude-opus-5 returns stopReason=max_tokens at exactly 4096).

A 30-cell diagram is ~3000 tokens of XML, so anything larger arrived as truncated
JSON and nothing reached the canvas. Small diagrams fitted, which made it look
intermittent.
2026-08-10 17:07:30 +09:00
NgoQuocViet2001
fd758b9e87 fix: preserve multi-page diagrams after export (#895)
* fix: preserve multi-page diagrams after export

* fix: keep chartXML sourced from autosave to preserve multi-page state

The export event's data.xml (xmlsvg format) contains compressed <diagram>
payloads, which would break applyDiagramOperations/replaceNodes consumers
that need plain <root> elements. Instead of writing export results into
chartXML, stop overwriting it entirely: autosave already delivers the full
uncompressed multi-page document, and loadDiagram covers AI-driven updates.

Also stop overwriting chartXMLRef with the page-only export before sending
a chat message, so session persistence never sees single-page XML.

Keep the data.xml preference for .drawio file downloads (compressed pages
are a valid drawio format).

---------

Co-authored-by: dayuan.jiang <jdy.toh@gmail.com>
2026-08-08 12:30:22 +09:00
Dayuan Jiang
81da9fad83 ci: exclude public/ from Biome and disable automerge for Biome updates (#912)
Biome 2.5.7 (auto-merged by Renovate in #904 with failing checks) started
parsing SVG files, breaking lint on generated assets in public/. Exclude
the whole public/ directory instead of just *.svg so future parser
changes can't hit generated files again.

Biome minor updates can also introduce new lint rules for source code,
so require manual review for its Renovate PRs instead of automerge.
2026-08-08 12:30:07 +09:00
Dayuan Jiang
6493652ff0 fix: prevent model selector label overflow (#910)
* fix: constrain model selector label width

* ci: ignore generated SVG assets in Biome
2026-08-08 10:31:46 +09:00
renovate[bot]
be8f26d6b1 fix(deps): update minor and patch dependencies (#904)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-08-07 00:40:42 +00:00
Kobi Hikri
cd02b2de92 ci: attach provenance and SBOM attestations to the published image (#902)
* ci: attach provenance and SBOM attestations to the published image

* ci: restore trailing newline at end of file
2026-08-06 11:03:08 +09:00
renovate[bot]
6e653942b0 chore(deps): update radix ui packages (#903)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-08-01 09:06:40 +00:00
nb213
c8463aefa7 Add Atlas Cloud provider support (#896)
* Add Atlas Cloud provider support

* fix: restore files removed by Atlas provider PR

---------

Co-authored-by: binyangzhu000-sudo <224954946+binyangzhu000-sudo@users.noreply.github.com>
2026-07-30 23:27:18 +09:00
Dayuan Jiang
4b07228320 feat(mcp): add load_diagram tool to load .drawio files into the session (#893)
* feat(mcp): add load_diagram tool to load .drawio files into the session

Loading a file previously required the agent to read the file itself and
pass the entire XML through create_new_diagram - wasteful for large
diagrams and impossible for draw.io's compressed save format.

load_diagram takes a file path; the server reads it, decompresses any
compressed pages (base64 -> raw deflate -> URI-decode, per page), and
replaces the session document. The loaded XML is deliberately NOT marked
as seen by the edit gate: the model only supplied a path, so it must
call get_diagram once before editing.

* chore(mcp): version 0.2.3

* fix(mcp): report package.json version in the MCP handshake

The McpServer metadata version was a separate hardcoded string that
never matched the published version (stuck at 0.1.2, then 0.3.0 while
npm shipped 0.2.x). Read it from package.json at startup instead —
works from both src/ (tsx) and dist/ (published build).
2026-07-12 19:54:42 +09:00
NgoQuocViet2001
f3a85558d8 fix(mcp): replace edit_diagram 30s time gate with content comparison (#890)
* fix(mcp): keep diagram context valid during edits

Closes #885

* fix(mcp): replace edit_diagram time gate with content comparison

The 30s wall-clock gate rejected slow-but-correct clients (#885).
Instead of a timeout, remember the exact state-store XML the model
last saw (get_diagram / create_new_diagram / edit_diagram / page CRUD)
and reject edit_diagram only when the live browser state differs -
i.e. the user made edits the model hasn't seen yet. Slow reasoning
no longer trips the gate, while unseen manual edits still do.

* docs(mcp): align edit_diagram/get_diagram descriptions with content-based gate

The 'You MUST call get_diagram BEFORE this tool' requirement and the
'Skipping get_diagram WILL cause user's changes to be LOST' warning no
longer match server behavior: a stale edit is rejected with no side
effects, never silently applied. Describe the freshness check instead,
and direct get_diagram usage at its real purpose - learning the current
diagram content when the model doesn't already know it.

* fix(mcp): compare diagram content structurally in the edit gate

draw.io re-serialises the document when pushing state back (attribute
order, pretty-printing, regenerated diagram ids, viewport attributes,
mxfile host), so byte comparison could flag an unchanged diagram as
stale. Fingerprint what a user can actually change instead - page set,
page names, and each page's root cell tree with sorted attributes -
keeping byte equality as the fast path. A bare mxGraphModel now also
fingerprints identically to its single-page mxfile wrapping.

* fix(mcp): don't compare page names against bare mxGraphModel pushes

A bare <mxGraphModel> pushed by the embed/sync path carries no page name,
so normalizeToMxfile invents "Page-1" — falsely reading any custom page
name as a content change and re-triggering the stale rejection on every
edit. When either side of the gate comparison is a bare mxGraphModel,
fingerprint cell trees only; full-mxfile comparisons still detect renames.

* chore(mcp): bump version to 0.2.2

* chore(mcp): sync package-lock.json version to 0.2.2

---------

Co-authored-by: dayuan.jiang <jdy.toh@gmail.com>
2026-07-12 15:33:20 +09:00
Dayuan Jiang
4f09d9461a ci: auto-publish mcp-server to npm via OIDC trusted publishing (#891)
Publishes @next-ai-drawio/mcp-server when packages/mcp-server changes on
main and the package.json version isn't on npm yet. Uses npm trusted
publishing (OIDC) - no token secret, no OTP, works with the strictest
2FA setting.
2026-07-12 15:33:07 +09:00
CharlesJay01
4984be82a1 feat: add MiMo (Xiaomi) as AI provider (#887)
* feat: add MiMo (Xiaomi) as AI provider

* fix: correct MiMo default base URL, suggested models, and reasoning support

- Default base URL was the China Token Plan endpoint (tp- keys only);
  switch to https://api.xiaomimimo.com/v1 which works with standard
  pay-as-you-go sk- keys. Token Plan users can override in settings.
- Replace deprecated mimo-v2-flash suggestion with mimo-v2.5
  (v2 series was deprecated on 2026-06-30).
- Use createDeepSeek instead of createOpenAI so reasoning_content is
  passed back during multi-turn tool calls (MiMo returns 400 without
  it), matching the existing Kimi implementation.
- Add mimo to SINGLE_SYSTEM_PROVIDERS so system messages are merged.
- Fold validate-model case into the shared OpenAI-compatible group.
- Drop the Bot icon special case; models.dev serves a real xiaomi logo
  via PROVIDER_LOGO_MAP.
- Document MIMO_API_KEY/MIMO_BASE_URL in env.example and
  docs/{en,cn,ja}/ai-providers.md.

* feat: show base URL hint for MiMo in provider settings

MiMo has two endpoints tied to key type: pay-as-you-go keys (sk-...)
use the default api.xiaomimimo.com/v1, while Token Plan keys (tp-...)
require token-plan-cn.xiaomimimo.com/v1. Surface this under the Base
URL field like the existing MiniMax hint, in all four locales.

---------

Co-authored-by: mapengfei <mapengfei@srsj.com>
Co-authored-by: dayuan.jiang <jdy.toh@gmail.com>
2026-07-12 09:10:12 +09:00
Dayuan Jiang
5bfd7b2468 fix: SSRF in /api/parse-url via DNS bypass and redirects (#878)
* fix: resolve DNS before SSRF check and block redirects in parse-url

isPrivateUrl() did string-only hostname matching and never resolved DNS,
so a public-looking name that maps to an internal IP (e.g.
127-0-0-1.sslip.io -> 127.0.0.1) passed the check while fetch/extract
later resolved it and reached internal services (GHSA-wqcv-5qvx-vx75).

- isPrivateUrl is now async: it keeps the fast string/literal-IP path,
  then resolves the hostname via DNS and rejects if any address is private.
- parse-url now fetches the page itself with redirect: "error" and parses
  via extractFromHtml(), since article-extractor follows redirects
  internally and drops a redirect option, which allowed a public URL to
  302 to an internal host.
- Update validate-model call site to await; add regression tests.

* fix: preserve charset detection and block CGNAT range in parse-url SSRF fix

Follow-up to the multi-reviewer review of the SSRF fix:

- Restore charset handling lost when switching from extract() to
  response.text(): non-UTF-8 pages (Shift_JIS/GBK/EUC/Big5, common on CJK
  sites this project targets) decoded as mojibake. Now read the body as
  bytes, detect charset from Content-Type / <meta charset>, and decode
  with TextDecoder before extractFromHtml.
- Wrap extractFromHtml in try/catch: it throws (not returns null) on
  empty/non-HTML bodies, which previously surfaced as a 500 instead of the
  intended 400.
- Add 100.64.0.0/10 (RFC 6598 CGNAT) to isPrivateIp; it is routable inside
  some cloud internal networks and was a residual SSRF target.
- Add tests for CGNAT, its boundaries, 0.0.0.0, and DNS-resolved IPv6.
2026-06-28 12:41:29 +09:00
Dayuan Jiang
80baf43827 fix: remove name-based image-input detection (#874) (#877)
supportsImageInput() guessed multimodal capability from the model id
string. The heuristic misfired on newer models (e.g. kimi-k3.6, qwen36),
either wrongly rejecting images for capable models or letting them through.

The AI SDK does not emit a warning when an OpenAI-compatible endpoint
silently drops an image, so the guess was the only signal — but an
unreliable one. Drop the detection entirely and let the real provider
error surface instead (already translated to a friendly message in
chat-panel.tsx). Validation falls back to "valid" on any model error.

- Remove supportsImageInput() and its pre-send check in chat route
- Drop the vision-capability throw in getValidationModel()
- Remove the corresponding unit tests
2026-06-28 00:26:23 +09:00
Siddhant Shekhar
5c884766a8 feat(mcp): add multi-page (mxfile) support to MCP server (#862)
* feat(mcp): add multi-page (mxfile) support

The MCP server's write path could only address a single drawio page even
though the underlying .drawio file format and the embedded editor both
natively support multi-page documents. A user asking for "a second page
with a CNN diagram" would hit the validator with the error
"Expected closing tag </root> but found </mxCell>" because the validator
assumed input was a bare <mxGraphModel> and could not walk past the
<mxfile><diagram>...</diagram></mxfile> wrapper.

This patch closes the gap end to end:

* New helper module `pages.ts` centralises page CRUD (normalize, parse,
  list, find, add, rename, delete) so every layer agrees that the
  canonical in-memory shape is always <mxfile>. normalizeToMxfile and
  addPageToDoc both strip any leading <?xml ?> declaration before
  embedding a fragment inside <diagram> (the declaration is only valid
  at document start). addPageToDoc explicitly rejects full <mxfile>
  inputs so a caller cannot accidentally nest a document inside a page.
* `xml-validation.ts` now detects an <mxfile> root and scopes the
  duplicate-id check per <diagram>. The legacy regex check would
  otherwise reject every multi-page doc, because cells "0" and "1"
  repeat in each page's <root> by design. The DOM-parse path is gated
  by a cheap regex pre-check so legacy bare <mxGraphModel> callers
  don't pay any extra cost. The autoFix duplicate-id rename step is
  also guarded against mxfile inputs — renaming those sentinel cells
  would silently break drawio's parent references.
* `diagram-operations.ts` accepts an optional PageSelector. For
  <mxfile> input it resolves the page first and scopes all
  querySelectorAll calls to that page's <root>, so a delete on page 2's
  cell "2" no longer touches page 1's cell "2".
* `create_new_diagram` accepts either a bare <mxGraphModel> (legacy,
  auto-wrapped into a single-page mxfile) or a full <mxfile> with N
  diagrams. All existing single-page callers keep working unchanged.
* `edit_diagram`, `get_diagram`, and `export_diagram` gain optional
  `page_id` / `page_name` / `page_index` parameters. When omitted they
  target the first page — the "active by convention" default. Tool
  handlers with all-optional input schemas coalesce missing arguments
  via `input ?? {}` so a no-args MCP invocation can't crash on
  destructure before reaching the session-existence check.
* New tools: `list_pages`, `add_page`, `rename_page`, `delete_page`.
* Page-targeted PNG/SVG export uses a "load + export + restore" dance:
  the server projects the target page into a single-page <mxfile>,
  pushes it into the transient state so the browser reloads the iframe
  with just that page, waits for drawio to render (~3s), triggers the
  export, captures the data, and then restores the original multi-page
  document. The dance is wrapped in `try/finally` so the restore runs
  unconditionally — even if an exception is thrown mid-dance, the
  user's multi-tab view is recovered before the function returns.
  The earlier attempt to use drawio's `selectPage` postMessage was a
  no-op because drawio's JSON embed protocol does not expose that
  action — silently exporting whatever tab happened to be active. The
  load-export-restore approach trades a brief visible tab-flicker for
  correctness: the exported image is guaranteed to match the requested
  page.
* Tool description strings reflect the multi-page semantics so the LLM
  client learns the new contract.
* Package version bumped 0.2.0 → 0.3.0 (additive surface — four new
  tools, three extended input schemas, canonical XML shape change).
* CI: `.github/workflows/test.yml` gains an explicit install + vitest
  run for the mcp-server package so the new multi-page invariants are
  covered by automation, not just local runs.

Backward compatibility: every existing single-page caller continues to
work without modification. The session.xml shape is normalised on every
write, removing the wrapper-injection hack from the .drawio download
path.

Tests: 43 unit tests under `packages/mcp-server/tests/multi-page.test.ts`
pin the validator's mxfile path, the page-scoped operations, the XML
declaration-prefix handling for both normalizeToMxfile and addPageToDoc,
addPageToDoc's rejection of full <mxfile> inputs, the single-page
projection used by export_diagram (a direct regression test for the
selectPage bug — two distinct page selectors must produce visually
different projections), and the Transformer + CNN motivating scenario.
A `tests/smoke.mjs` smoke test drives the built `dist/index.js` over
JSON-RPC and asserts all 9 tools register with the right input schemas.
Root vitest suite (107 tests) still green.

* fix(mcp): rewrite page-targeted export browser-side; harden edit/get

The page-targeted PNG/SVG export never worked: export_diagram swapped the
live session to a single-page projection, slept 3s, then wrote the export
flag onto a state object that setState() had already replaced in the store
Map — so the browser never saw the request and every such export timed out.
The swap+restore also clobbered concurrent edits.

Move the projection entirely browser-side: requestExport() hands a single
-page <mxfile> to the bridge via state.exportXml; the bridge loads it,
lets draw.io render, exports, then reloads the user's real document. The
canonical session state is never mutated, so there is no restore race and
no fixed-delay guessing. The export poll now re-reads the live store entry
each tick instead of a captured reference. autosave is suppressed and the
version-bump reload is skipped while a projection is on screen; if no real
document was captured, restore forces a server reload rather than leaving
the iframe stuck on the projection.

Also:
- edit_diagram now returns isError on a page-level failure (selector matched
  no page / page has no <root>) instead of reporting success-with-warnings
  and persisting a no-op; the pre-edit history snapshot is taken only after
  that gate so a failed edit leaves no phantom undo entry.
- edit_diagram/get_diagram re-normalise browser-pushed xml to mxfile so a
  bare <mxGraphModel> can't silently strip a multi-page document.
- get_diagram now errors (instead of silently returning the full doc) when a
  selector is given but the session isn't a parseable mxfile.
- page_id / page_name / add_page.id get .min(1) so empty strings can't
  silently target the first page.
- Extract pages.ts:projectPage(), collapsing three copies of the
  parse→find→serialise projection logic in index.ts.
- Replace the never-in-CI tests/smoke.mjs with tests/server-wiring.test.ts,
  which boots the server from source via tsx and runs under the existing
  vitest CI step.

* chore(mcp): set version to 0.2.1 for release

---------

Co-authored-by: dayuan.jiang <jdy.toh@gmail.com>
2026-06-16 09:15:50 +09:00
Dayuan Jiang
8e42dd9da8 feat: support comma-separated AI_MODEL for quick multi-model setup (#870)
Users expected setting AI_MODEL to a comma-separated list to expose
multiple models in the picker, but the value was used verbatim as a
single model id, leaving the picker with only the "Server Default"
fallback.

Add a third-priority fallback in loadEnvServerModelsConfig: when
AI_MODELS_CONFIG and ai-models.json are both absent, AI_MODEL contains
a comma, and AI_PROVIDER is set to a known provider, synthesize an
equivalent ServerModelsConfig with the provider's models trimmed,
deduplicated, and the first marked as default.

Also makes getAIModel and getValidationModel pick the first comma-split
value when falling back to AI_MODEL, so requests started before the
client picker hydrates still resolve to a real model id.

Docs (en/cn/ja) and env.example updated; tests cover the new fallback
plus the no-comma / no-AI_PROVIDER negative cases.
2026-06-15 14:27:55 +09:00
YOYO-do
0f9699843f feat: add AIHubMix provider (#865)
* feat: add AIHubMix provider

* feat: load AIHubMix models dynamically

* feat: polish AIHubMix model setup

* feat: send AIHubMix app code

* docs: remove redundant AIHubMix recommendation

---------

Co-authored-by: LL <13697272357@163.com>
2026-06-15 13:54:18 +09:00
renovate[bot]
988034cc3e chore(deps): update dependency esbuild to v0.28.1 [security] (#867)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-06-14 17:37:54 +00:00
Dayuan Jiang
8bc9871829 ci: pin Biome to 2.4.13 in auto-format workflow (#869)
CI used npx @biomejs/biome@latest, which drifted to 2.5.0 and failed
the format job (deprecated config fields + stricter parsing of existing
files like public/resnet50.svg) on unrelated PRs. Pin to the version
already in package.json so CI matches local and pre-commit runs.
2026-06-15 00:44:55 +09:00
58 changed files with 7898 additions and 2272 deletions

View File

@@ -33,6 +33,11 @@
"matchPackagePatterns": ["@ai-sdk/*", "ai", "next"],
"groupName": "Core framework packages",
"automerge": false
},
{
"matchPackageNames": ["@biomejs/biome"],
"groupName": "Biome",
"automerge": false
}
],
"vulnerabilityAlerts": {

View File

@@ -58,6 +58,8 @@ jobs:
with:
context: .
push: ${{ github.event_name != 'pull_request' }}
provenance: mode=max
sbom: true
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
cache-from: type=gha
@@ -89,4 +91,3 @@ jobs:
docker pull ghcr.io/${REPO_LOWER}:latest
docker tag ghcr.io/${REPO_LOWER}:latest ${{ secrets.AWS_ACCOUNT_ID }}.dkr.ecr.ap-northeast-1.amazonaws.com/next-ai-draw-io:latest
docker push ${{ secrets.AWS_ACCOUNT_ID }}.dkr.ecr.ap-northeast-1.amazonaws.com/next-ai-draw-io:latest

67
.github/workflows/publish-mcp.yml vendored Normal file
View File

@@ -0,0 +1,67 @@
name: Publish MCP Server
# Publishes @next-ai-drawio/mcp-server to npm via OIDC trusted publishing
# (no token, no OTP). Triggers when packages/mcp-server changes on main;
# skips silently if the package.json version is already on npm — so a
# release is just "bump the version in a PR and merge".
on:
push:
branches:
- main
paths:
- "packages/mcp-server/**"
workflow_dispatch:
permissions:
contents: read
id-token: write # OIDC token for npm trusted publishing
concurrency:
group: publish-mcp
cancel-in-progress: false
jobs:
publish:
runs-on: ubuntu-latest
defaults:
run:
working-directory: packages/mcp-server
steps:
- name: Checkout
uses: actions/checkout@v6
- name: Setup Node.js
uses: actions/setup-node@v6
with:
node-version: 24
cache: "npm"
cache-dependency-path: packages/mcp-server/package-lock.json
registry-url: "https://registry.npmjs.org"
# Trusted publishing requires npm >= 11.5.1
- name: Update npm
run: npm install -g npm@latest
- name: Check if version is already published
id: version
run: |
LOCAL=$(node -p "require('./package.json').version")
if npm view "@next-ai-drawio/mcp-server@${LOCAL}" version >/dev/null 2>&1; then
echo "Version ${LOCAL} already on npm - nothing to publish"
echo "publish=false" >> "$GITHUB_OUTPUT"
else
echo "Version ${LOCAL} not on npm - publishing"
echo "publish=true" >> "$GITHUB_OUTPUT"
fi
- name: Install dependencies
if: steps.version.outputs.publish == 'true'
run: npm ci
- name: Test
if: steps.version.outputs.publish == 'true'
run: npm test
- name: Publish to npm
if: steps.version.outputs.publish == 'true'
run: npm publish

View File

@@ -28,6 +28,16 @@ jobs:
- name: Run unit tests
run: npm run test -- --run
# The MCP server package ships its own vitest because its DOM polyfill
# (linkedom) needs `environment: node`, while the root vitest uses jsdom
# for the Next.js app. Install + run its tests separately so CI catches
# multi-page mxfile regressions.
- name: Install MCP server dependencies
run: npm --prefix packages/mcp-server ci
- name: Run MCP server unit tests
run: npm --prefix packages/mcp-server test
e2e:
name: E2E Tests
runs-on: ubuntu-latest

View File

@@ -21,6 +21,17 @@ A Next.js web application that integrates AI capabilities with draw.io diagrams.
> 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!
<p align="center">
<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).
https://github.com/user-attachments/assets/9d60a3e8-4a1c-4b5e-acbb-26af2d3eabd1
@@ -211,11 +222,13 @@ See the [Next.js deployment documentation](https://nextjs.org/docs/app/building-
- 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.
@@ -224,7 +237,7 @@ All providers except AWS Bedrock and OpenRouter support custom endpoints.
### 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.
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
@@ -252,6 +265,8 @@ Diagrams are represented as XML that can be rendered in draw.io. The AI processe
**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:

View File

@@ -0,0 +1,61 @@
import { NextResponse } from "next/server"
import {
AIHUBMIX_MODELS_ENDPOINT,
extractAihubmixModelIds,
} from "@/lib/aihubmix-models"
import { SUGGESTED_MODELS } from "@/lib/types/model-config"
const SUCCESS_CACHE_CONTROL =
"public, max-age=300, s-maxage=3600, stale-while-revalidate=86400"
function fallbackResponse() {
return NextResponse.json(
{
models: SUGGESTED_MODELS.aihubmix || [],
source: "fallback",
},
{
headers: {
"Cache-Control": "no-store",
},
},
)
}
export async function GET() {
try {
const response = await fetch(AIHUBMIX_MODELS_ENDPOINT, {
next: { revalidate: 3600 },
})
if (!response.ok) {
console.warn(
`[aihubmix-models] Failed to fetch models: ${response.status}`,
)
return fallbackResponse()
}
const payload = await response.json()
const models = extractAihubmixModelIds(payload)
if (models.length === 0) {
console.warn("[aihubmix-models] Model list response was empty")
return fallbackResponse()
}
return NextResponse.json(
{
models,
source: "aihubmix",
},
{
headers: {
"Cache-Control": SUCCESS_CACHE_CONTROL,
},
},
)
} catch (error) {
console.warn("[aihubmix-models] Failed to load models:", error)
return fallbackResponse()
}
}

View File

@@ -15,7 +15,6 @@ import { z } from "zod"
import {
getAIModel,
SINGLE_SYSTEM_PROVIDERS,
supportsImageInput,
supportsPromptCaching,
} from "@/lib/ai-providers"
import { findCachedResponse } from "@/lib/cached-responses"
@@ -35,11 +34,17 @@ import {
setTraceOutput,
wrapWithObserve,
} from "@/lib/langfuse"
import {
resolveMaxOutputTokens,
withOutputTokenLimitFallback,
} from "@/lib/output-token-limit"
import { findServerModelById } from "@/lib/server-model-config"
import { getSystemPrompt } from "@/lib/system-prompts"
import { getUserIdFromRequest } from "@/lib/user-id"
export const maxDuration = 120
// 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,
// which is also where Node's response-body timeout on the upstream stream lands.
// Helper function to create cached stream response
function createCachedStreamResponse(xml: string): Response {
@@ -242,13 +247,22 @@ async function handleChatRequest(req: Request): Promise<Response> {
// Get AI model with optional client overrides
const {
model,
model: baseModel,
providerOptions,
headers,
modelId,
provider: resolvedProvider,
} = getAIModel(clientOverrides)
// Retry with a smaller budget if the provider rejects the requested one
const model = withOutputTokenLimitFallback(baseModel)
// User setting wins over server env, so desktop users can raise it themselves
const maxOutputTokens = resolveMaxOutputTokens(
req.headers.get("x-max-output-tokens"),
)
console.log(`[maxOutputTokens] ${maxOutputTokens}`)
// Check if model supports prompt caching
const shouldCache = supportsPromptCaching(modelId)
console.log(
@@ -266,16 +280,10 @@ async function handleChatRequest(req: Request): Promise<Response> {
lastUserMessage?.parts?.filter((part: any) => part.type === "file") ||
[]
// Check if user is sending images to a model that doesn't support them
// AI SDK silently drops unsupported parts, so we need to catch this early
if (fileParts.length > 0 && !supportsImageInput(modelId)) {
return Response.json(
{
error: `The model "${modelId}" does not support image input. Please use a vision-capable model (e.g., GPT-4o, Claude, Gemini) or remove the image.`,
},
{ status: 400 },
)
}
// Note: we used to pre-emptively reject images for models we guessed were
// text-only (by name matching). That heuristic misfired on newer models
// (see issue #874), so we now let the request through and surface the real
// provider error if the model genuinely can't accept images.
// User input only - XML is now in a separate cached system message
const formattedUserInput = `User input:
@@ -500,9 +508,9 @@ IMPORTANT: The "Current diagram XML" is the SINGLE SOURCE OF TRUTH for what's on
const result = streamText({
model,
abortSignal: req.signal,
...(process.env.MAX_OUTPUT_TOKENS && {
maxOutputTokens: parseInt(process.env.MAX_OUTPUT_TOKENS, 10),
}),
// Must be sent: unset means the provider's own default, and Bedrock's is
// 4096, enough for a small diagram, so larger ones were cut off mid-attribute.
maxOutputTokens,
stopWhen: stepCountIs(5),
// Repair truncated tool calls when maxOutputTokens is reached mid-JSON
experimental_repairToolCall: async ({ toolCall, error }) => {

View File

@@ -1,4 +1,4 @@
import { extract } from "@extractus/article-extractor"
import { extractFromHtml } from "@extractus/article-extractor"
import { NextResponse } from "next/server"
import TurndownService from "turndown"
import { isPrivateUrl } from "@/lib/ssrf-protection"
@@ -7,6 +7,31 @@ const MAX_CONTENT_LENGTH = 150000 // Match PDF limit
const EXTRACT_TIMEOUT_MS = 15000
const USER_AGENT = "Mozilla/5.0 (compatible; NextAIDrawio/1.0)"
// Detect the page's charset so non-UTF-8 pages (Shift_JIS/GBK/EUC/Big5, common
// on CJK sites) are decoded correctly. Response.text() always assumes UTF-8 and
// would produce mojibake; the article-extractor library does the same detection
// when it fetches the page itself, which we no longer rely on.
function detectCharset(
contentType: string | null,
buffer: ArrayBuffer,
): string {
// 1. HTTP Content-Type header charset (most authoritative).
const headerCharset = contentType?.match(/charset=([^;]+)/i)?.[1]?.trim()
// 2. <meta charset> / <meta http-equiv> in the first bytes of the document.
const head = new TextDecoder("utf-8").decode(buffer.slice(0, 4096))
const metaCharset =
head.match(/<meta[^>]+charset=["']?\s*([\w-]+)/i)?.[1] ||
head.match(/<meta[^>]+content=["'][^"']*charset=([\w-]+)/i)?.[1]
const charset = (headerCharset || metaCharset || "utf-8").toLowerCase()
// TextDecoder throws on unknown encoding labels; fall back to UTF-8.
try {
new TextDecoder(charset)
return charset
} catch {
return "utf-8"
}
}
export async function POST(req: Request) {
try {
const { url } = await req.json()
@@ -31,21 +56,31 @@ export async function POST(req: Request) {
// SSRF protection: parse-url has no use case for fetching internal
// hosts, so private URLs are always rejected. ALLOW_PRIVATE_URLS only
// governs LLM provider baseUrl overrides (validate-model, chat).
if (isPrivateUrl(url)) {
if (await isPrivateUrl(url)) {
return NextResponse.json(
{ error: "Cannot access private/internal URLs" },
{ status: 400 },
)
}
const headController = new AbortController()
const headTimeout = setTimeout(() => headController.abort(), 3000)
// Fetch the page ourselves so we control redirect handling. The
// article-extractor library follows redirects internally and ignores a
// `redirect` option, which would let a public URL 302 to an internal
// host and bypass the SSRF check above. `redirect: "error"` rejects any
// redirect outright.
const controller = new AbortController()
const timeoutId = setTimeout(() => {
controller.abort()
}, EXTRACT_TIMEOUT_MS)
let html: string
try {
const headResponse = await fetch(url, {
method: "HEAD",
const response = await fetch(url, {
headers: { "User-Agent": USER_AGENT },
signal: headController.signal,
redirect: "error",
signal: controller.signal,
})
const contentType = headResponse.headers.get("content-type")
const contentType = response.headers.get("content-type")
if (contentType?.includes("application/pdf")) {
return NextResponse.json(
{
@@ -54,27 +89,17 @@ export async function POST(req: Request) {
{ status: 422 },
)
}
} catch (err) {
console.warn(
"HEAD pre-check failed, proceeding with extraction:",
err,
)
} finally {
clearTimeout(headTimeout)
}
// Extract article content with timeout to avoid tying up server resources
const controller = new AbortController()
const timeoutId = setTimeout(() => {
controller.abort()
}, EXTRACT_TIMEOUT_MS)
if (!response.ok) {
return NextResponse.json(
{ error: "Could not fetch URL content" },
{ status: 400 },
)
}
let article
try {
article = await extract(url, undefined, {
headers: { "User-Agent": USER_AGENT },
signal: controller.signal,
})
const buffer = await response.arrayBuffer()
const charset = detectCharset(contentType, buffer)
html = new TextDecoder(charset).decode(buffer)
} catch (err: any) {
if (err?.name === "AbortError") {
return NextResponse.json(
@@ -82,11 +107,25 @@ export async function POST(req: Request) {
{ status: 504 },
)
}
throw err
// Redirects are rejected with a TypeError ("failed to fetch" /
// "unexpected redirect") when redirect: "error" is set.
return NextResponse.json(
{ error: "Could not fetch URL content" },
{ status: 400 },
)
} finally {
clearTimeout(timeoutId)
}
// extractFromHtml throws (not returns null) on empty/non-HTML bodies,
// so map any parse error to the same 400 as the no-content case.
let article: Awaited<ReturnType<typeof extractFromHtml>>
try {
article = await extractFromHtml(html, url)
} catch {
article = null
}
if (!article || !article.content) {
return NextResponse.json(
{ error: "Could not extract content from URL" },

View File

@@ -5,11 +5,16 @@ import { createGateway } from "@ai-sdk/gateway"
import { createGoogleGenerativeAI } from "@ai-sdk/google"
import { createVertex } from "@ai-sdk/google-vertex"
import { createOpenAI } from "@ai-sdk/openai"
import { createAihubmix } from "@aihubmix/ai-sdk-provider"
import { createOpenRouter } from "@openrouter/ai-sdk-provider"
import { generateText } from "ai"
import { NextResponse } from "next/server"
import { createOllama } from "ollama-ai-provider-v2"
import { normalizeMiniMaxBaseURL } from "@/lib/ai-providers"
import {
AIHUBMIX_APP_CODE,
isAihubmixStandardBaseURL,
normalizeMiniMaxBaseURL,
} from "@/lib/ai-providers"
import { allowPrivateUrls, isPrivateUrl } from "@/lib/ssrf-protection"
import { PROVIDER_INFO, type ProviderName } from "@/lib/types/model-config"
@@ -51,7 +56,7 @@ export async function POST(req: Request) {
}
// SECURITY: Block SSRF attacks via custom baseUrl
if (baseUrl && !allowPrivateUrls() && isPrivateUrl(baseUrl)) {
if (baseUrl && !allowPrivateUrls() && (await isPrivateUrl(baseUrl))) {
return NextResponse.json(
{ valid: false, error: "Invalid base URL" },
{ status: 400 },
@@ -153,6 +158,28 @@ export async function POST(req: Request) {
break
}
case "aihubmix": {
const defaultBaseURL = PROVIDER_INFO.aihubmix.defaultBaseUrl
if (
isAihubmixStandardBaseURL(baseUrl) ||
baseUrl === defaultBaseURL
) {
const aihubmix = createAihubmix({
apiKey,
appCode: AIHUBMIX_APP_CODE,
})
model = aihubmix(modelId)
} else {
const aihubmixCompatible = createOpenAI({
apiKey,
baseURL: baseUrl,
})
model = aihubmixCompatible.chat(modelId)
}
break
}
case "deepseek": {
if (baseUrl || apiKey) {
const ds = createDeepSeek({
@@ -345,12 +372,14 @@ export async function POST(req: Request) {
break
}
// GLM, Qwen, Kimi, Qiniu, Novita - OpenAI compatible
// GLM, Qwen, Kimi, Qiniu, Novita, MiMo, Atlas Cloud - OpenAI compatible
case "glm":
case "qwen":
case "kimi":
case "qiniu":
case "novita": {
case "novita":
case "atlascloud":
case "mimo": {
const baseURL =
baseUrl ||
PROVIDER_INFO[provider as ProviderName]?.defaultBaseUrl ||

View File

@@ -6,7 +6,8 @@
"useIgnoreFile": true
},
"files": {
"ignoreUnknown": false
"ignoreUnknown": false,
"includes": ["**", "!public"]
},
"formatter": {
"enabled": true,

View File

@@ -178,6 +178,7 @@ export default function ChatPanel({
const [minimalStyle, setMinimalStyle] = useState(false)
const [vlmValidationEnabled, setVlmValidationEnabled] = useState(false)
const [customSystemMessage, setCustomSystemMessage] = useState("")
const [maxOutputTokens, setMaxOutputTokens] = useState("")
const [shouldFocusInput, setShouldFocusInput] = useState(false)
// Restore input from sessionStorage on mount (when ChatPanel remounts due to key change)
@@ -204,6 +205,14 @@ export default function ChatPanel({
}
}, [])
// Load output token budget from localStorage on mount
useEffect(() => {
const stored = localStorage.getItem(STORAGE_KEYS.maxOutputTokens)
if (stored !== null) {
setMaxOutputTokens(stored)
}
}, [])
// Check config on mount
useEffect(() => {
fetch(getApiEndpoint("/api/config"))
@@ -320,6 +329,13 @@ export default function ChatPanel({
localStorage.setItem(STORAGE_KEYS.customSystemMessage, value)
}, [])
// Handler for output token budget change (empty string = use server default)
const handleMaxOutputTokensChange = useCallback((value: string) => {
const digitsOnly = value.replace(/\D/g, "")
setMaxOutputTokens(digitsOnly)
localStorage.setItem(STORAGE_KEYS.maxOutputTokens, digitsOnly)
}, [])
// Ref to store the sendMessage function for use in callbacks
const sendMessageRef = useRef<typeof sendMessage | null>(null)
@@ -830,10 +846,6 @@ export default function ChatPanel({
let chartXml = await onFetchChart()
chartXml = formatXML(chartXml)
// Update ref directly to avoid race condition with React's async state update
// This ensures edit_diagram has the correct XML before AI responds
chartXMLRef.current = chartXml
// Build user text by concatenating input with pre-extracted text
// (Backend only reads first text part, so we must combine them)
const parts: any[] = []
@@ -1108,6 +1120,9 @@ export default function ChatPanel({
...(minimalStyle && {
"x-minimal-style": "true",
}),
...(maxOutputTokens && {
"x-max-output-tokens": maxOutputTokens,
}),
},
},
)
@@ -1452,6 +1467,8 @@ export default function ChatPanel({
onVlmValidationChange={handleVlmValidationChange}
customSystemMessage={customSystemMessage}
onCustomSystemMessageChange={handleCustomSystemMessageChange}
maxOutputTokens={maxOutputTokens}
onMaxOutputTokensChange={handleMaxOutputTokensChange}
onOpenModelConfig={() => setShowModelConfigDialog(true)}
/>

View File

@@ -54,6 +54,7 @@ import {
import { Switch } from "@/components/ui/switch"
import { useDictionary } from "@/hooks/use-dictionary"
import type { UseModelConfigReturn } from "@/hooks/use-model-config"
import { getApiEndpoint } from "@/lib/base-path"
import { formatMessage } from "@/lib/i18n/utils"
import type { ProviderConfig, ProviderName } from "@/lib/types/model-config"
import { PROVIDER_INFO, SUGGESTED_MODELS } from "@/lib/types/model-config"
@@ -132,6 +133,14 @@ export function ModelConfigDialog({
modelId: string
message: string
} | null>(null)
const [dynamicSuggestedModels, setDynamicSuggestedModels] = useState<
Partial<Record<ProviderName, string[]>>
>({})
const [loadedSuggestedProviders, setLoadedSuggestedProviders] = useState<
Partial<Record<ProviderName, boolean>>
>({})
const [loadingSuggestedProvider, setLoadingSuggestedProvider] =
useState<ProviderName | null>(null)
const {
config,
@@ -157,10 +166,68 @@ export function ModelConfigDialog({
}
}, [])
useEffect(() => {
if (
!open ||
selectedProvider?.provider !== "aihubmix" ||
loadedSuggestedProviders.aihubmix
) {
return
}
let cancelled = false
setLoadingSuggestedProvider("aihubmix")
fetch(getApiEndpoint("/api/aihubmix-models"))
.then((response) => {
if (!response.ok) {
throw new Error(`Failed to load models: ${response.status}`)
}
return response.json()
})
.then((data: { models?: unknown }) => {
if (cancelled || !Array.isArray(data.models)) {
return
}
const models = data.models.filter(
(model): model is string => typeof model === "string",
)
if (models.length > 0) {
setDynamicSuggestedModels((current) => ({
...current,
aihubmix: models,
}))
}
})
.catch((error) => {
console.warn("Failed to load AIHubMix models:", error)
})
.finally(() => {
if (cancelled) {
return
}
setLoadedSuggestedProviders((current) => ({
...current,
aihubmix: true,
}))
setLoadingSuggestedProvider(null)
})
return () => {
cancelled = true
}
}, [open, selectedProvider?.provider, loadedSuggestedProviders.aihubmix])
// Get suggested models for current provider
const suggestedModels = selectedProvider
? SUGGESTED_MODELS[selectedProvider.provider] || []
? dynamicSuggestedModels[selectedProvider.provider] ||
SUGGESTED_MODELS[selectedProvider.provider] ||
[]
: []
const isLoadingSuggestedModels =
selectedProvider?.provider === loadingSuggestedProvider
// Filter out already-added models from suggestions
const existingModelIds =
@@ -168,6 +235,11 @@ export function ModelConfigDialog({
const availableSuggestions = suggestedModels.filter(
(modelId) => !existingModelIds.includes(modelId),
)
const emptyStateSuggestions = selectedProvider
? (SUGGESTED_MODELS[selectedProvider.provider] || [])
.filter((modelId) => !existingModelIds.includes(modelId))
.slice(0, 4)
: []
// Handle adding a new provider
const handleAddProvider = (providerType: ProviderName) => {
@@ -773,21 +845,26 @@ export function ModelConfigDialog({
}
}}
disabled={
isLoadingSuggestedModels ||
availableSuggestions.length ===
0
0
}
>
<SelectTrigger className="w-28 h-8 rounded-lg hover:bg-interactive-hover">
<span className="text-xs">
{availableSuggestions.length ===
0
? dict
.modelConfig
.allAdded
: dict
.modelConfig
.suggested}
</span>
{isLoadingSuggestedModels ? (
<Loader2 className="h-3.5 w-3.5 animate-spin" />
) : (
<span className="text-xs">
{availableSuggestions.length ===
0
? dict
.modelConfig
.allAdded
: dict
.modelConfig
.suggested}
</span>
)}
</SelectTrigger>
<SelectContent className="max-h-72">
{availableSuggestions.map(
@@ -816,7 +893,12 @@ export function ModelConfigDialog({
0 ? (
<div className="p-6 text-center h-full flex flex-col items-center justify-center">
<div className="inline-flex items-center justify-center w-10 h-10 rounded-full bg-surface-2 mb-3">
<Sparkles className="h-5 w-5 text-muted-foreground" />
<ProviderLogo
provider={
selectedProvider.provider
}
className="size-5 text-muted-foreground"
/>
</div>
<p className="text-sm text-muted-foreground">
{
@@ -824,6 +906,36 @@ export function ModelConfigDialog({
.noModelsConfigured
}
</p>
{emptyStateSuggestions.length >
0 && (
<div className="mt-4 flex max-w-full flex-wrap items-center justify-center gap-2">
{emptyStateSuggestions.map(
(modelId) => (
<Button
key={
modelId
}
type="button"
variant="outline"
size="sm"
className="h-7 max-w-[220px] rounded-lg px-2 font-mono text-[11px]"
onClick={() =>
handleAddModel(
modelId,
)
}
>
<Plus className="h-3 w-3 shrink-0" />
<span className="truncate">
{
modelId
}
</span>
</Button>
),
)}
</div>
)}
</div>
) : (
<div className="divide-y divide-border-subtle">

View File

@@ -158,7 +158,7 @@ export function ModelSelector({
}, [])
return (
<div ref={wrapperRef} className="inline-block">
<div ref={wrapperRef} className="min-w-0 max-w-48">
<ModelSelectorRoot open={open} onOpenChange={setOpen}>
<ModelSelectorTrigger asChild>
<ButtonWithTooltip
@@ -167,7 +167,7 @@ export function ModelSelector({
size="sm"
disabled={disabled}
className={cn(
"hover:bg-accent gap-1.5 h-8 px-2 transition-[padding,background-color] duration-150 ease-in-out",
"h-8 min-w-0 max-w-full shrink overflow-hidden gap-1.5 px-2 transition-[padding,background-color] duration-150 ease-in-out hover:bg-accent",
!showLabel && "px-1.5 justify-center",
)}
// accessibility: expose label to screen readers
@@ -176,7 +176,7 @@ export function ModelSelector({
<Bot className="h-4 w-4 flex-shrink-0 text-muted-foreground" />
{/* show/hide visible label based on measured width */}
{showLabel ? (
<span className="text-xs truncate">
<span className="min-w-0 truncate text-xs">
{selectedModel
? selectedModel.modelId
: dict.modelConfig.default}

View File

@@ -249,6 +249,11 @@ export function ProviderCredentialsFields({
{dict.modelConfig.minimaxBaseUrlHint}
</p>
)}
{provider === "mimo" && (
<p className="text-xs text-muted-foreground">
{dict.modelConfig.mimoBaseUrlHint}
</p>
)}
</div>
</>
)}

View File

@@ -75,6 +75,8 @@ interface SettingsDialogProps {
onOpenModelConfig?: () => void
customSystemMessage?: string
onCustomSystemMessageChange?: (value: string) => void
maxOutputTokens?: string
onMaxOutputTokensChange?: (value: string) => void
}
export const STORAGE_ACCESS_CODE_KEY = "next-ai-draw-io-access-code"
@@ -101,6 +103,8 @@ function SettingsContent({
onOpenModelConfig,
customSystemMessage = "",
onCustomSystemMessageChange = () => {},
maxOutputTokens = "",
onMaxOutputTokensChange = () => {},
}: SettingsDialogProps) {
const dict = useDictionary()
const router = useRouter()
@@ -591,6 +595,24 @@ function SettingsContent({
/>
</div>
{/* Max Output Tokens */}
<SettingItem
label={dict.settings.maxOutputTokens}
description={dict.settings.maxOutputTokensDescription}
>
<Input
id="max-output-tokens"
type="text"
inputMode="numeric"
value={maxOutputTokens}
onChange={(e) =>
onMaxOutputTokensChange(e.target.value)
}
placeholder="64000"
className="h-9 w-28 text-sm"
/>
</SettingItem>
{/* Send Shortcut */}
<SettingItem
label={dict.settings.sendShortcut}

View File

@@ -2,7 +2,7 @@
import type React from "react"
import { createContext, useContext, useEffect, useRef, useState } from "react"
import type { DrawIoEmbedRef } from "react-drawio"
import type { DrawIoEmbedRef, EventExport } from "react-drawio"
import { toast } from "sonner"
import type { ExportFormat } from "@/components/save-dialog"
import { getApiEndpoint } from "@/lib/base-path"
@@ -22,7 +22,7 @@ interface DiagramContextType {
handleExportWithoutHistory: () => void
resolverRef: React.MutableRefObject<((value: string) => void) | null>
drawioRef: React.MutableRefObject<DrawIoEmbedRef | null>
handleDiagramExport: (data: any) => void
handleDiagramExport: (data: EventExport) => void
handleDiagramAutoSave: (data: { xml?: string }) => void
clearDiagram: () => void
saveDiagramToFile: (
@@ -83,7 +83,7 @@ export function DiagramProvider({ children }: { children: React.ReactNode }) {
// Track if we're expecting an export for file save (stores raw export data)
const saveResolverRef = useRef<{
resolver: ((data: string) => void) | null
resolver: ((data: string, fullDiagramXML?: string) => void) | null
format: ExportFormat | null
}>({ resolver: null, format: null })
@@ -204,7 +204,7 @@ export function DiagramProvider({ children }: { children: React.ReactNode }) {
return null
}
const handleDiagramExport = (data: any) => {
const handleDiagramExport = (data: EventExport) => {
// Handle PNG export for VLM validation
if (pngResolverRef.current && data.data?.startsWith("data:image/png")) {
pngResolverRef.current(data.data)
@@ -215,7 +215,7 @@ export function DiagramProvider({ children }: { children: React.ReactNode }) {
// Handle save to file if requested (process raw data before extraction)
if (saveResolverRef.current.resolver) {
const format = saveResolverRef.current.format
saveResolverRef.current.resolver(data.data)
saveResolverRef.current.resolver(data.data, data.xml)
saveResolverRef.current = { resolver: null, format: null }
// For non-xmlsvg formats, skip XML extraction as it will fail
// Only drawio (which uses xmlsvg internally) has the content attribute
@@ -225,8 +225,11 @@ export function DiagramProvider({ children }: { children: React.ReactNode }) {
}
}
// Don't write chartXML here: exports don't change the diagram, and
// data.xml from xmlsvg exports has compressed <diagram> payloads that
// would break edit_diagram/display_diagram. Autosave keeps chartXML
// up to date with the full uncompressed multi-page document (#879).
const extractedXML = extractDiagramXML(data.data)
setChartXML(extractedXML)
setLatestSvg(data.data)
// Only add to history if this was a user-initiated export
@@ -288,14 +291,16 @@ export function DiagramProvider({ children }: { children: React.ReactNode }) {
// Set up the resolver before triggering export
saveResolverRef.current = {
resolver: (exportData: string) => {
resolver: (exportData: string, fullDiagramXML?: string) => {
let fileContent: string | Blob
let mimeType: string
let extension: string
if (format === "drawio") {
// Extract XML from SVG for .drawio format
const xml = extractDiagramXML(exportData)
// Prefer the complete document from the export event so all pages are saved.
const xml = fullDiagramXML?.trim()
? fullDiagramXML
: extractDiagramXML(exportData)
let xmlContent = xml
if (!xml.includes("<mxfile")) {
xmlContent = `<mxfile><diagram name="Page-1" id="page-1">${xml}</diagram></mxfile>`

View File

@@ -204,6 +204,7 @@ npm run dev
- Azure OpenAI
- Ollama
- OpenRouter
- AIHubMix
- DeepSeek
- SiliconFlow
- ModelScope
@@ -216,7 +217,7 @@ npm run dev
### 服务端多模型配置
管理员可以配置多个服务端模型,让所有用户无需提供个人 API Key 即可使用。通过 `AI_MODELS_CONFIG` 环境变量JSON 字符串)或 `ai-models.json` 文件配置。
管理员可以配置多个服务端模型,让所有用户无需提供个人 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。

View File

@@ -46,6 +46,21 @@ AI_MODEL=gpt-4o
OPENAI_BASE_URL=https://your-custom-endpoint/v1
```
### AIHubMix
AIHubMix 通过单个 API Key 聚合 Claude、GPT、Gemini、DeepSeek 等模型。
```bash
AIHUBMIX_API_KEY=your_api_key
AI_MODEL=claude-sonnet-4-5-20250929
```
可选的自定义端点:
```bash
AIHUBMIX_BASE_URL=https://aihubmix.com/v1
```
### Anthropic
```bash
@@ -293,6 +308,19 @@ AI_MODEL=your_model_id
QINIU_BASE_URL=https://your-custom-endpoint
```
### MiMo (小米)
```bash
MIMO_API_KEY=your_api_key
AI_MODEL=mimo-v2.5-pro
```
可选的自定义端点Token Plan 订阅用户请设置专属 Base URL
```bash
MIMO_BASE_URL=https://token-plan-cn.xiaomimimo.com/v1
```
## 自动检测
如果您只配置了**一个**提供商的 API 密钥,系统将自动检测并使用该提供商。无需设置 `AI_PROVIDER`
@@ -300,7 +328,7 @@ QINIU_BASE_URL=https://your-custom-endpoint
如果您配置了**多个** API 密钥,则必须显式设置 `AI_PROVIDER`
```bash
AI_PROVIDER=google # 或openai, anthropic, deepseek, siliconflow, doubao, azure, bedrock, openrouter, ollama, gateway, sglang, modelscope, minimax, glm, qwen, kimi, qiniu
AI_PROVIDER=google # 或openai, anthropic, aihubmix, deepseek, siliconflow, doubao, azure, bedrock, openrouter, ollama, gateway, sglang, modelscope, minimax, glm, qwen, kimi, qiniu, mimo
```
## 服务端多模型配置
@@ -321,6 +349,17 @@ AI_MODELS_CONFIG='{"providers":[{"name":"OpenAI","provider":"openai","models":["
在项目根目录创建 `ai-models.json` 文件(或通过 `AI_MODELS_CONFIG_PATH` 指定路径)。
**方式三:`AI_MODEL` 用逗号分隔**(单 provider 的快速配置)
如果只需要暴露同一 provider 下的多个模型,可以直接在 `AI_MODEL` 里用逗号分隔。第一个模型会作为默认值。
```bash
AI_PROVIDER=doubao
AI_MODEL=doubao-seed-1-8-251215,doubao-seed-1-6-flash,doubao-seed-1-6-pro
```
这是等价 `ai-models.json` 的简写形式。如果需要配置多个 provider或自定义 `apiKeyEnv` / `baseUrlEnv`,请使用方式一或方式二。
### 配置示例
```json

View File

@@ -61,6 +61,21 @@ Optional custom endpoint (for OpenAI-compatible services):
OPENAI_BASE_URL=https://your-custom-endpoint/v1
```
### AIHubMix
AIHubMix provides access to Claude, GPT, Gemini, DeepSeek, and other models through a single API key.
```bash
AIHUBMIX_API_KEY=your_api_key
AI_MODEL=claude-sonnet-4-5-20250929
```
Optional custom endpoint:
```bash
AIHUBMIX_BASE_URL=https://aihubmix.com/v1
```
### Anthropic
```bash
@@ -308,6 +323,19 @@ Optional custom endpoint:
QINIU_BASE_URL=https://your-custom-endpoint
```
### MiMo (Xiaomi)
```bash
MIMO_API_KEY=your_api_key
AI_MODEL=mimo-v2.5-pro
```
Optional custom endpoint (Token Plan subscribers should set their dedicated Base URL):
```bash
MIMO_BASE_URL=https://token-plan-cn.xiaomimimo.com/v1
```
## Auto-Detection
If you only configure **one** provider's API key, the system will automatically detect and use that provider. No need to set `AI_PROVIDER`.
@@ -315,7 +343,7 @@ If you only configure **one** provider's API key, the system will automatically
If you configure **multiple** API keys, you must explicitly set `AI_PROVIDER`:
```bash
AI_PROVIDER=google # or: openai, anthropic, deepseek, siliconflow, doubao, azure, bedrock, openrouter, ollama, gateway, sglang, modelscope, minimax, glm, qwen, kimi, qiniu
AI_PROVIDER=google # or: openai, anthropic, aihubmix, deepseek, siliconflow, doubao, azure, bedrock, openrouter, ollama, gateway, sglang, modelscope, minimax, glm, qwen, kimi, qiniu, mimo
```
## Server-Side Multi-Model Configuration
@@ -336,6 +364,17 @@ AI_MODELS_CONFIG='{"providers":[{"name":"OpenAI","provider":"openai","models":["
Create an `ai-models.json` file in the project root (or set `AI_MODELS_CONFIG_PATH` to a custom location).
**Option 3: Comma-separated `AI_MODEL`** (quick setup, single provider)
If you only need multiple models from one provider, list them in `AI_MODEL` separated by commas. The first model is treated as the default.
```bash
AI_PROVIDER=doubao
AI_MODEL=doubao-seed-1-8-251215,doubao-seed-1-6-flash,doubao-seed-1-6-pro
```
This is shorthand for the equivalent `ai-models.json`. For multiple providers or custom `apiKeyEnv` / `baseUrlEnv`, use Option 1 or 2 instead.
### Example Configuration
```json

View File

@@ -203,6 +203,7 @@ Next.jsアプリをデプロイする最も簡単な方法は、Next.jsの作成
- Azure OpenAI
- Ollama
- OpenRouter
- AIHubMix
- DeepSeek
- SiliconFlow
- ModelScope
@@ -215,7 +216,7 @@ AWS BedrockとOpenRouter以外のすべてのプロバイダーはカスタム
### サーバーサイドマルチモデル設定
管理者は、ユーザーが個人のAPIキーを提供することなく利用できる複数のサーバーサイドモデルを設定できます。`AI_MODELS_CONFIG` 環境変数JSON文字列または `ai-models.json` ファイルで設定します。
管理者は、ユーザーが個人の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を推奨します。

View File

@@ -46,6 +46,21 @@ AI_MODEL=gpt-4o
OPENAI_BASE_URL=https://your-custom-endpoint/v1
```
### AIHubMix
AIHubMix は、単一の API キーで Claude、GPT、Gemini、DeepSeek などのモデルへのアクセスを提供します。
```bash
AIHUBMIX_API_KEY=your_api_key
AI_MODEL=claude-sonnet-4-5-20250929
```
任意のカスタムエンドポイント:
```bash
AIHUBMIX_BASE_URL=https://aihubmix.com/v1
```
### Anthropic
```bash
@@ -293,6 +308,19 @@ AI_MODEL=your_model_id
QINIU_BASE_URL=https://your-custom-endpoint
```
### MiMo (Xiaomi)
```bash
MIMO_API_KEY=your_api_key
AI_MODEL=mimo-v2.5-pro
```
オプションのカスタムエンドポイントToken Plan 加入者は専用の Base URL を設定してください):
```bash
MIMO_BASE_URL=https://token-plan-cn.xiaomimimo.com/v1
```
## 自動検出
**1つ**のプロバイダーの API キーのみを設定した場合、システムはそのプロバイダーを自動的に検出して使用します。`AI_PROVIDER` を設定する必要はありません。
@@ -300,7 +328,7 @@ QINIU_BASE_URL=https://your-custom-endpoint
**複数**の API キーを設定する場合は、`AI_PROVIDER` を明示的に設定する必要があります:
```bash
AI_PROVIDER=google # または: openai, anthropic, deepseek, siliconflow, doubao, azure, bedrock, openrouter, ollama, gateway, sglang, modelscope, minimax, glm, qwen, kimi, qiniu
AI_PROVIDER=google # または: openai, anthropic, aihubmix, deepseek, siliconflow, doubao, azure, bedrock, openrouter, ollama, gateway, sglang, modelscope, minimax, glm, qwen, kimi, qiniu, mimo
```
## サーバーサイドマルチモデル設定
@@ -321,6 +349,17 @@ AI_MODELS_CONFIG='{"providers":[{"name":"OpenAI","provider":"openai","models":["
プロジェクトルートに `ai-models.json` ファイルを作成します(または `AI_MODELS_CONFIG_PATH` でパスを指定)。
**方法3`AI_MODEL` をカンマ区切りで指定**(単一プロバイダーの簡易設定)
同一プロバイダー内の複数モデルだけを公開したい場合は、`AI_MODEL` にカンマ区切りで列挙できます。最初のモデルがデフォルトになります。
```bash
AI_PROVIDER=doubao
AI_MODEL=doubao-seed-1-8-251215,doubao-seed-1-6-flash,doubao-seed-1-6-pro
```
これは等価な `ai-models.json` の簡易表記です。複数のプロバイダーや、カスタム `apiKeyEnv` / `baseUrlEnv` を使う場合は、方法1または方法2を使ってください。
### 設定例
```json

View File

@@ -1,12 +1,21 @@
# AI Provider Configuration
# AI_PROVIDER: Which provider to use
# Options: bedrock, openai, anthropic, google, vertexai, azure, ollama, openrouter, deepseek, siliconflow, gateway, novita
# Options: bedrock, openai, anthropic, google, vertexai, azure, ollama, openrouter, aihubmix, deepseek, siliconflow, gateway, novita
# Default: bedrock
AI_PROVIDER=bedrock
# AI_MODEL: The model ID for your chosen provider (REQUIRED)
# Tip: For a single-provider quick multi-model setup, list comma-separated model IDs.
# The first one becomes the default and the rest appear in the model picker.
# For multiple providers or custom apiKeyEnv/baseUrlEnv, use AI_MODELS_CONFIG / ai-models.json instead.
# Example: AI_MODEL=doubao-seed-1-8-251215,doubao-seed-1-6-flash,doubao-seed-1-6-pro
AI_MODEL=global.anthropic.claude-sonnet-4-5-20250929-v1:0
# Output limit, all providers (default: 64000). Shared by reasoning and the diagram XML,
# so a thinking model can spend it all before the tool call. Users can override it in Settings.
# If a model's own ceiling is lower, the request is retried with that ceiling automatically.
# MAX_OUTPUT_TOKENS=64000
# AWS Bedrock Configuration
# AWS_REGION=us-east-1
# AWS_ACCESS_KEY_ID=your-access-key-id
@@ -69,6 +78,10 @@ AI_MODEL=global.anthropic.claude-sonnet-4-5-20250929-v1:0
# OPENROUTER_API_KEY=sk-or-v1-...
# OPENROUTER_BASE_URL=https://openrouter.ai/api/v1 # Optional: Custom endpoint
# AIHubMix Configuration
# AIHUBMIX_API_KEY=your-aihubmix-api-key
# AIHUBMIX_BASE_URL=https://aihubmix.com/v1 # Optional: Custom endpoint
# DeepSeek Configuration
# DEEPSEEK_API_KEY=sk-...
# DEEPSEEK_BASE_URL=https://api.deepseek.com/v1 # Optional: Custom endpoint
@@ -181,3 +194,13 @@ AI_MODEL=global.anthropic.claude-sonnet-4-5-20250929-v1:0
# Get your API key from: https://novita.ai/dashboard/key
# NOVITA_API_KEY=your_novita_api_key
# NOVITA_BASE_URL=https://api.novita.ai/openai # Optional, default
# MiMo (Xiaomi) Configuration (Optional)
# Get your API key from: https://platform.xiaomimimo.com/
# MIMO_API_KEY=your_mimo_api_key
# MIMO_BASE_URL=https://api.xiaomimimo.com/v1 # Optional, default. Token Plan users: https://token-plan-cn.xiaomimimo.com/v1
# Atlas Cloud Configuration (Optional)
# Get your API key from: https://www.atlascloud.ai/console/api-keys
# ATLASCLOUD_API_KEY=your_atlascloud_api_key
# ATLASCLOUD_BASE_URL=https://api.atlascloud.ai/v1 # Optional, default. LLM chat endpoint; media generation uses a separate API.

View File

@@ -6,6 +6,7 @@ import { createGateway, gateway } from "@ai-sdk/gateway"
import { createGoogleGenerativeAI, google } from "@ai-sdk/google"
import { createVertex } from "@ai-sdk/google-vertex"
import { createOpenAI, openai } from "@ai-sdk/openai"
import { aihubmix, createAihubmix } from "@aihubmix/ai-sdk-provider"
import { fromNodeProviderChain } from "@aws-sdk/credential-providers"
import { createOpenRouter } from "@openrouter/ai-sdk-provider"
import { createOllama, ollama } from "ollama-ai-provider-v2"
@@ -13,6 +14,8 @@ import { PROVIDER_INFO, type ProviderName } from "@/lib/types/model-config"
export type { ProviderName }
export const AIHUBMIX_APP_CODE = "MSBS9675"
interface ModelConfig {
model: any
providerOptions?: any
@@ -29,6 +32,7 @@ export const SINGLE_SYSTEM_PROVIDERS = new Set<ProviderName>([
"kimi",
"qiniu",
"novita",
"mimo",
])
/**
@@ -57,6 +61,18 @@ export function normalizeMiniMaxBaseURL(rawUrl: string): {
return { baseURL, isAnthropicCompatible }
}
export function isAihubmixStandardBaseURL(
rawUrl: string | null | undefined,
): boolean {
if (!rawUrl) return true
const baseURL = rawUrl.replace(/\/+$/, "")
return (
baseURL === "https://aihubmix.com" ||
baseURL === "https://aihubmix.com/v1"
)
}
export interface ClientOverrides {
provider?: string | null
baseUrl?: string | null
@@ -86,6 +102,7 @@ const ALLOWED_CLIENT_PROVIDERS: ProviderName[] = [
"azure",
"bedrock",
"openrouter",
"aihubmix",
"deepseek",
"siliconflow",
"sglang",
@@ -100,6 +117,8 @@ const ALLOWED_CLIENT_PROVIDERS: ProviderName[] = [
"kimi",
"minimax",
"novita",
"mimo",
"atlascloud",
]
// Bedrock provider options for Anthropic beta features
@@ -513,6 +532,7 @@ function buildProviderOptions(
case "deepseek":
case "openrouter":
case "aihubmix":
case "siliconflow":
case "sglang":
case "gateway":
@@ -523,7 +543,9 @@ function buildProviderOptions(
case "qwen":
case "kimi":
case "qiniu":
case "novita": {
case "novita":
case "atlascloud":
case "mimo": {
// These providers don't have reasoning configs in AI SDK yet
// Gateway passes through to underlying providers which handle their own configs
break
@@ -546,6 +568,7 @@ export const PROVIDER_ENV_VARS: Record<ProviderName, string | null> = {
azure: "AZURE_API_KEY",
ollama: null, // No credentials needed for local Ollama
openrouter: "OPENROUTER_API_KEY",
aihubmix: "AIHUBMIX_API_KEY",
deepseek: "DEEPSEEK_API_KEY",
siliconflow: "SILICONFLOW_API_KEY",
sglang: "SGLANG_API_KEY",
@@ -559,6 +582,8 @@ export const PROVIDER_ENV_VARS: Record<ProviderName, string | null> = {
kimi: "KIMI_API_KEY",
minimax: "MINIMAX_API_KEY",
novita: "NOVITA_API_KEY",
mimo: "MIMO_API_KEY",
atlascloud: "ATLASCLOUD_API_KEY",
}
/**
@@ -662,7 +687,7 @@ function validateProviderCredentials(
* Get the AI model based on environment variables
*
* Environment variables:
* - AI_PROVIDER: The provider to use (bedrock, openai, anthropic, google, azure, ollama, openrouter, deepseek, siliconflow, sglang, gateway, modelscope)
* - AI_PROVIDER: The provider to use (bedrock, openai, anthropic, google, azure, ollama, openrouter, aihubmix, deepseek, siliconflow, sglang, gateway, modelscope)
* - AI_MODEL: The model ID/name for the selected provider
*
* Provider-specific env vars:
@@ -674,6 +699,7 @@ function validateProviderCredentials(
* - AWS_REGION, AWS_ACCESS_KEY_ID, AWS_SECRET_ACCESS_KEY: AWS Bedrock credentials
* - OLLAMA_BASE_URL: Ollama server URL (optional, defaults to https://ollama.com/api)
* - OPENROUTER_API_KEY: OpenRouter API key
* - AIHUBMIX_API_KEY: AIHubMix API key
* - DEEPSEEK_API_KEY: DeepSeek API key
* - DEEPSEEK_BASE_URL: DeepSeek endpoint (optional)
* - SILICONFLOW_API_KEY: SiliconFlow API key
@@ -710,8 +736,10 @@ export function getAIModel(overrides?: ClientOverrides): ModelConfig {
(overrides?.provider === "vertexai" && overrides?.vertexApiKey))
)
// Use client override if provided, otherwise fall back to env vars
const modelId = overrides?.modelId || process.env.AI_MODEL
// Use client override if provided, otherwise fall back to env vars.
// AI_MODEL may be comma-separated (multi-model fallback); pick the first.
const envModel = process.env.AI_MODEL?.split(",")[0]?.trim() || undefined
const modelId = overrides?.modelId || envModel
if (!modelId) {
if (isClientOverride) {
@@ -761,6 +789,7 @@ export function getAIModel(overrides?: ClientOverrides): ModelConfig {
`- GOOGLE_GENERATIVE_AI_API_KEY for Google\n` +
`- AWS_ACCESS_KEY_ID for Bedrock\n` +
`- OPENROUTER_API_KEY for OpenRouter\n` +
`- AIHUBMIX_API_KEY for AIHubMix\n` +
`- AZURE_API_KEY for Azure\n` +
`- SILICONFLOW_API_KEY for SiliconFlow\n` +
`- SGLANG_API_KEY for SGLang\n` +
@@ -1003,6 +1032,42 @@ export function getAIModel(overrides?: ClientOverrides): ModelConfig {
break
}
case "aihubmix": {
const apiKey = resolveApiKey(overrides, "AIHUBMIX_API_KEY")
const serverBaseUrl = resolveBaseUrlEnv(
overrides,
"AIHUBMIX_BASE_URL",
)
const baseURL = resolveBaseURL(
overrides?.apiKey,
overrides?.baseUrl,
serverBaseUrl,
PROVIDER_INFO.aihubmix.defaultBaseUrl,
)
const defaultBaseURL = PROVIDER_INFO.aihubmix.defaultBaseUrl
if (
isAihubmixStandardBaseURL(baseURL) ||
baseURL === defaultBaseURL
) {
const aihubmixProvider =
overrides?.apiKey || apiKey
? createAihubmix({
apiKey,
appCode: AIHUBMIX_APP_CODE,
})
: aihubmix
model = aihubmixProvider(modelId)
} else {
const aihubmixCompatibleProvider = createOpenAI({
apiKey,
baseURL,
})
model = aihubmixCompatibleProvider.chat(modelId)
}
break
}
case "deepseek": {
const apiKey = resolveApiKey(overrides, "DEEPSEEK_API_KEY")
const serverBaseUrl = resolveBaseUrlEnv(
@@ -1288,10 +1353,28 @@ export function getAIModel(overrides?: ClientOverrides): ModelConfig {
break
}
case "mimo": {
const apiKey = resolveApiKey(overrides, "MIMO_API_KEY")
const baseURL = resolveBaseURL(
overrides?.apiKey,
overrides?.baseUrl,
resolveBaseUrlEnv(overrides, "MIMO_BASE_URL"),
PROVIDER_INFO.mimo?.defaultBaseUrl,
)
// Use createDeepSeek to properly handle reasoning_content for MiMo
// thinking models (e.g., mimo-v2.5-pro). MiMo's API requires
// reasoning_content to be passed back during multi-turn tool calls
// (returns 400 otherwise), same convention as DeepSeek and Kimi.
const mimoProvider = createDeepSeek({ apiKey, baseURL })
model = mimoProvider(modelId)
break
}
case "glm":
case "qwen":
case "qiniu":
case "novita": {
case "novita":
case "atlascloud": {
const envVar = PROVIDER_ENV_VARS[provider]
if (!envVar) {
throw new Error(
@@ -1322,7 +1405,7 @@ export function getAIModel(overrides?: ClientOverrides): ModelConfig {
overrides?.apiKey,
overrides?.baseUrl,
resolveBaseUrlEnv(overrides, "KIMI_BASE_URL"),
PROVIDER_INFO["kimi"]?.defaultBaseUrl,
PROVIDER_INFO.kimi?.defaultBaseUrl,
)
// Use createDeepSeek to properly handle reasoning_content for Kimi
// thinking models (e.g., kimi-k2.6). Kimi's API uses the same
@@ -1335,7 +1418,7 @@ export function getAIModel(overrides?: ClientOverrides): ModelConfig {
default:
throw new Error(
`Unknown AI provider: ${provider}. Supported providers: bedrock, openai, anthropic, google, azure, ollama, openrouter, deepseek, siliconflow, sglang, gateway, edgeone, doubao, modelscope, glm, qwen, qiniu, kimi, minimax, novita`,
`Unknown AI provider: ${provider}. Supported providers: bedrock, openai, anthropic, google, azure, ollama, openrouter, aihubmix, deepseek, siliconflow, sglang, gateway, edgeone, doubao, modelscope, glm, qwen, qiniu, kimi, minimax, novita, mimo, atlascloud`,
)
}
@@ -1361,80 +1444,19 @@ export function supportsPromptCaching(modelId: string): boolean {
)
}
/**
* Check if a model supports image/vision input.
* Some models silently drop image parts without error (AI SDK warning only).
*/
export function supportsImageInput(modelId: string): boolean {
const lowerModelId = modelId.toLowerCase()
// Helper to check if model has vision capability indicator
const hasVisionIndicator =
lowerModelId.includes("vision") || lowerModelId.includes("vl")
// Models that DON'T support image/vision input (unless vision variant)
// Kimi K2 doesn't support images, but K2.5 does
// Only block kimi-k2 specifically, not other Kimi models
if (
(lowerModelId.includes("kimi-k2") ||
lowerModelId.includes("kimi_k2")) &&
!hasVisionIndicator &&
!lowerModelId.includes("2.5") &&
!lowerModelId.includes("k2.5")
) {
return false
}
// Moonshot text models (moonshot-v1 series are text-only)
if (lowerModelId.includes("moonshot-v1") && !hasVisionIndicator) {
return false
}
// MiniMax text models (MiniMax-M2.x series are text-only; M3 supports image input)
if (
lowerModelId.includes("minimax") &&
!hasVisionIndicator &&
!lowerModelId.includes("m3")
) {
return false
}
// DeepSeek text models (not vision variants)
if (lowerModelId.includes("deepseek") && !hasVisionIndicator) {
return false
}
// Qwen text models (not vision variants like qwen-vl)
// Qwen3.5 series (qwen3.5, qwen3.5-plus, qwen3.5-flash) natively support image input
// QvQ (Qwen Visual QA) models are vision models — exclude them even when prefixed with "qwen/"
if (
lowerModelId.includes("qwen") &&
!hasVisionIndicator &&
!lowerModelId.includes("qwen3.5") &&
!lowerModelId.includes("qvq")
) {
return false
}
// GLM text models (not vision variants)
// GLM vision models: glm-4v, glm-4v-9b, glm-4.1v-9b-thinking
if (lowerModelId.includes("glm") && !hasVisionIndicator) {
if (!/[\d.]v/.test(lowerModelId)) {
return false
}
}
// Default: assume model supports images
return true
}
/**
* Get the AI model for diagram validation.
* Uses VALIDATION_MODEL env var if set, otherwise falls back to AI_MODEL.
* Throws if the model doesn't support image input.
*
* Note: we no longer guess whether the model supports image input from its
* name — that heuristic misfired on newer models (see issue #874). If a
* configured validation model can't handle images, the API call simply errors
* and the validate-diagram route falls back to "valid".
*/
export function getValidationModel(): ReturnType<typeof getAIModel>["model"] {
const modelId = process.env.VALIDATION_MODEL || process.env.AI_MODEL
// AI_MODEL may be comma-separated (multi-model fallback); pick the first.
const envFallback = process.env.AI_MODEL?.split(",")[0]?.trim() || undefined
const modelId = process.env.VALIDATION_MODEL || envFallback
if (!modelId) {
throw new Error(
@@ -1442,12 +1464,6 @@ export function getValidationModel(): ReturnType<typeof getAIModel>["model"] {
)
}
if (!supportsImageInput(modelId)) {
throw new Error(
`Validation requires a vision-capable model. Model "${modelId}" does not support image input.`,
)
}
const { model } = getAIModel({ modelId })
return model
}

79
lib/aihubmix-models.ts Normal file
View File

@@ -0,0 +1,79 @@
export const AIHUBMIX_MODELS_ENDPOINT = "https://aihubmix.com/api/v1/models"
const NON_CHAT_MODEL_TYPES = new Set([
"embedding",
"image_generation",
"rerank",
"transcription",
"tts",
"video",
])
type AihubmixModelListPayload = {
data?: unknown
}
type AihubmixModelRecord = {
model_id?: unknown
types?: unknown
}
function getModelTypes(types: unknown): Set<string> {
if (typeof types !== "string") {
return new Set()
}
return new Set(
types
.split(",")
.map((type) => type.trim())
.filter(Boolean),
)
}
function isChatModel(record: AihubmixModelRecord): record is {
model_id: string
types: string
} {
if (typeof record.model_id !== "string" || !record.model_id.trim()) {
return false
}
const types = getModelTypes(record.types)
if (!types.has("llm")) {
return false
}
return !Array.from(NON_CHAT_MODEL_TYPES).some((type) => types.has(type))
}
export function extractAihubmixModelIds(payload: unknown): string[] {
const data = (payload as AihubmixModelListPayload)?.data
if (!Array.isArray(data)) {
return []
}
const seen = new Set<string>()
const modelIds: string[] = []
for (const item of data) {
if (!item || typeof item !== "object") {
continue
}
const record = item as AihubmixModelRecord
if (!isChatModel(record)) {
continue
}
const modelId = record.model_id.trim()
if (seen.has(modelId)) {
continue
}
seen.add(modelId)
modelIds.push(modelId)
}
return modelIds
}

View File

@@ -34,7 +34,8 @@
"glm": "GLM",
"qwen": "Qwen",
"kimi": "Kimi",
"qiniu": "Qiniu"
"qiniu": "Qiniu",
"mimo": "MiMo (Xiaomi)"
},
"chat": {
"placeholder": "Describe your diagram or upload a file...",
@@ -131,6 +132,8 @@
"customSystemMessage": "Custom System Message",
"customSystemMessageDescription": "Add custom instructions appended to the AI's system prompt.",
"customSystemMessagePlaceholder": "e.g., Always use blue color scheme for diagrams...",
"maxOutputTokens": "Max Output Tokens",
"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.",
"panelVisibility": "Lobby Panels",
"panelVisibilityDescription": "Choose which panels to show on the chat lobby.",
"showRecentChats": "Recent Chats",
@@ -371,6 +374,7 @@
"baseUrlWithExample": "Base URL (optional, e.g. {example})",
"customEndpoint": "Custom endpoint URL",
"minimaxBaseUrlHint": "Use /anthropic for Anthropic-compatible API (recommended), or /v1 for OpenAI-compatible API",
"mimoBaseUrlHint": "Default works with pay-as-you-go keys (sk-...). Token Plan subscribers (tp-... keys) must set https://token-plan-cn.xiaomimimo.com/v1",
"models": "Models",
"customModelId": "Custom model ID...",
"allAdded": "All added",

View File

@@ -34,7 +34,8 @@
"glm": "GLM",
"qwen": "Qwen",
"kimi": "Kimi",
"qiniu": "Qiniu"
"qiniu": "Qiniu",
"mimo": "MiMo (Xiaomi)"
},
"chat": {
"placeholder": "ダイアグラムを説明するか、ファイルをアップロード...",
@@ -131,6 +132,8 @@
"customSystemMessage": "カスタムシステムメッセージ",
"customSystemMessageDescription": "AIのシステムプロンプトに追加されるカスタム指示を入力します。",
"customSystemMessagePlaceholder": "例:ダイアグラムには常に青色のカラースキームを使用...",
"maxOutputTokens": "最大出力トークン数",
"maxOutputTokensDescription": "1回の応答の予算で、思考過程とダイアグラムの XML が共有します。AI が考え続けてダイアグラムが生成されない場合は大きくしてください。空欄ならデフォルト値を使います。",
"panelVisibility": "ロビーパネル",
"panelVisibilityDescription": "チャットロビーに表示するパネルを選択します。",
"showRecentChats": "最近のチャット",
@@ -325,6 +328,7 @@
"baseUrlWithExample": "ベース URLオプション、例: {example}",
"customEndpoint": "カスタムエンドポイント URL",
"minimaxBaseUrlHint": "/anthropic で Anthropic 互換 API推奨、または /v1 で OpenAI 互換 API を使用",
"mimoBaseUrlHint": "デフォルトは従量課金キーsk-...用です。Token Plan 加入者tp-... キー)は https://token-plan-cn.xiaomimimo.com/v1 を設定してください",
"models": "モデル",
"customModelId": "カスタムモデル ID...",
"allAdded": "すべて追加済み",

View File

@@ -34,7 +34,8 @@
"glm": "GLM",
"qwen": "Qwen",
"kimi": "Kimi",
"qiniu": "Qiniu"
"qiniu": "Qiniu",
"mimo": "MiMo (小米)"
},
"chat": {
"placeholder": "描述您的圖表或上傳檔案...",
@@ -131,6 +132,8 @@
"customSystemMessage": "自訂系統訊息",
"customSystemMessageDescription": "新增自訂指示,將附加到 AI 的系統提示末尾。",
"customSystemMessagePlaceholder": "例如:圖表始終使用藍色配色方案...",
"maxOutputTokens": "最大輸出 token 數",
"maxOutputTokensDescription": "單次回覆的額度,思考過程與圖表 XML 共用。若 AI 一直在思考卻沒有產生圖表,請將它調大。留空則使用預設值。",
"panelVisibility": "大廳面板",
"panelVisibilityDescription": "選擇在聊天大廳顯示哪些面板。",
"showRecentChats": "最近聊天",
@@ -371,6 +374,7 @@
"baseUrlWithExample": "基礎 URL可選例如 {example}",
"customEndpoint": "自訂端點 URL",
"minimaxBaseUrlHint": "使用 /anthropic 端點為 Anthropic 相容 API推薦或使用 /v1 端點為 OpenAI 相容 API",
"mimoBaseUrlHint": "預設地址適用於按量付費金鑰sk-...。Token Plan 訂閱用戶tp-... 金鑰)請設定為 https://token-plan-cn.xiaomimimo.com/v1",
"models": "模型",
"customModelId": "自訂模型 ID...",
"allAdded": "已全部新增",

View File

@@ -34,7 +34,8 @@
"glm": "GLM",
"qwen": "Qwen",
"kimi": "Kimi",
"qiniu": "Qiniu"
"qiniu": "Qiniu",
"mimo": "MiMo (小米)"
},
"chat": {
"placeholder": "描述您的图表或上传文件...",
@@ -131,6 +132,8 @@
"customSystemMessage": "自定义系统消息",
"customSystemMessageDescription": "添加自定义指令,将附加到 AI 的系统提示末尾。",
"customSystemMessagePlaceholder": "例如:图表始终使用蓝色配色方案...",
"maxOutputTokens": "最大输出 token 数",
"maxOutputTokensDescription": "单次回复的额度,思考过程和图表 XML 共用。如果 AI 一直在思考却没有生成图表,请把它调大。留空则使用默认值。",
"panelVisibility": "大厅面板",
"panelVisibilityDescription": "选择在聊天大厅显示哪些面板。",
"showRecentChats": "最近聊天",
@@ -371,6 +374,7 @@
"baseUrlWithExample": "基础 URL可选例如 {example}",
"customEndpoint": "自定义端点 URL",
"minimaxBaseUrlHint": "使用 /anthropic 端点为 Anthropic 兼容 API推荐或使用 /v1 端点为 OpenAI 兼容 API",
"mimoBaseUrlHint": "默认地址适用于按量付费密钥sk-...。Token Plan 订阅用户tp-... 密钥)请设置为 https://token-plan-cn.xiaomimimo.com/v1",
"models": "模型",
"customModelId": "自定义模型 ID...",
"allAdded": "已全部添加",

145
lib/output-token-limit.ts Normal file
View File

@@ -0,0 +1,145 @@
import { wrapLanguageModel } from "ai"
type WrappedModel = ReturnType<typeof wrapLanguageModel>
/**
* Default output budget for a chat turn.
*
* This has to cover thinking + prose + the tool call, because reasoning models
* spend it in that order. Measured on deepseek-v4-flash: refining an existing
* diagram burned 16000 tokens on thinking alone and the request ended with
* finishReason "length" before display_diagram was ever called (issue #924).
* 64000 leaves room for the plan and the XML in one turn.
*/
export const DEFAULT_MAX_OUTPUT_TOKENS = 64000
/** Ceiling for the user-supplied override, to catch typos like an extra zero. */
export const MAX_OUTPUT_TOKENS_LIMIT = 200000
/**
* Below this a diagram cannot come out whole, so a retry would just produce
* truncated XML instead of the provider's error. Better to surface the error.
*/
const MIN_USABLE_OUTPUT_TOKENS = 1024
/** Status codes that can carry a complaint about the requested budget. */
const BUDGET_REJECTION_STATUSES = new Set([400, 422])
function usableLimit(value: number): number | null {
return value >= MIN_USABLE_OUTPUT_TOKENS ? value : null
}
/**
* A budget this large exceeds what some models accept. Providers reject it with a
* 400 that names the real limit, so we parse the number out and retry once
* instead of failing the turn.
*
* Formats seen in the wild:
* - Bedrock: "The maximum tokens you requested exceeds the model limit of 4096."
* - OpenRouter: "This endpoint's maximum context length is 64000 tokens. However,
* you requested about 64025 tokens (25 of text input, 64000 in the output)."
* Note this one is an input+output ceiling, so the input has to be subtracted.
* - Anthropic: "max_tokens: 200000 > 64000, which is the maximum allowed..."
* - OpenAI: "This model supports at most 16384 completion tokens"
*
* Every pattern names tokens explicitly. A generic one (an earlier draft matched
* "lower than N") would reinterpret unrelated failures, and retrying on a bogus
* number turns a readable error into an empty diagram.
*/
export function parseOutputTokenLimit(error: unknown): number | null {
const err = error as {
message?: unknown
responseBody?: unknown
statusCode?: unknown
}
// An auth or rate-limit failure is not about the budget, so leave it alone.
if (
typeof err?.statusCode === "number" &&
!BUDGET_REJECTION_STATUSES.has(err.statusCode)
) {
return null
}
const text = [
typeof err?.message === "string" ? err.message : "",
typeof err?.responseBody === "string" ? err.responseBody : "",
].join(" ")
if (!text) return null
// Combined input+output ceiling: subtract the input the provider counted,
// plus a small margin because its estimate is approximate.
const context = text.match(/maximum context length is (\d+)/i)
if (context) {
const input = text.match(/(\d+) of text input/i)
return usableLimit(
Number(context[1]) - (input ? Number(input[1]) : 0) - 1024,
)
}
const output =
text.match(/model limit of (\d+)/i) ||
text.match(/> (\d+), which is the maximum/i) ||
text.match(/at most (\d+) completion tokens/i)
return output ? usableLimit(Number(output[1])) : null
}
/**
* Retry the stream once with a smaller budget when the provider rejects the
* requested one. Without this, raising the default breaks every model whose
* ceiling is below it (measured: bedrock claude-3-haiku 4096, nova-lite 10000,
* openrouter deepseek-r1 64000 shared with the input).
*/
export function withOutputTokenLimitFallback(
model: WrappedModel,
): WrappedModel {
return wrapLanguageModel({
model,
middleware: {
specificationVersion: "v3",
async wrapStream({ doStream, params, model: inner }) {
try {
return await doStream()
} catch (error) {
const limit = parseOutputTokenLimit(error)
const requested = params.maxOutputTokens
if (!limit || !requested || limit >= requested) throw error
console.warn(
`[maxOutputTokens] ${requested} rejected, retrying with ${limit}`,
)
return await inner.doStream({
...params,
maxOutputTokens: limit,
})
}
},
},
})
}
function validBudget(value: string | null | undefined): number | null {
const parsed = Number(value)
return Number.isInteger(parsed) &&
parsed > 0 &&
parsed <= MAX_OUTPUT_TOKENS_LIMIT
? parsed
: null
}
/**
* Resolve the output budget: user setting (sent as a header so it works in the
* desktop app too), then server env, then the default. Both sources go through
* the same validation, so a typo in either falls back instead of reaching the
* provider.
*/
export function resolveMaxOutputTokens(headerValue: string | null): number {
return (
validBudget(headerValue) ??
validBudget(process.env.MAX_OUTPUT_TOKENS) ??
DEFAULT_MAX_OUTPUT_TOKENS
)
}

View File

@@ -62,6 +62,53 @@ function getConfigPath(): string {
return path.join(process.cwd(), "ai-models.json")
}
/**
* Synthesize a config from a comma-separated AI_MODEL value (Priority 3 fallback).
* Lets users expose multiple models without authoring AI_MODELS_CONFIG / ai-models.json.
* Triggers only when AI_MODEL contains a comma AND AI_PROVIDER is set to a known provider.
*/
function configFromCommaSeparatedAiModel(): ServerModelsConfig | null {
const aiModel = process.env.AI_MODEL
if (!aiModel || !aiModel.includes(",")) return null
const aiProvider = process.env.AI_PROVIDER
if (!aiProvider) {
console.warn(
"[server-model-config] AI_MODEL contains commas but AI_PROVIDER is not set; " +
"skipping multi-model fallback. Set AI_PROVIDER, or use AI_MODELS_CONFIG / ai-models.json.",
)
return null
}
if (!(aiProvider in PROVIDER_INFO)) {
console.warn(
`[server-model-config] AI_PROVIDER="${aiProvider}" is not a known provider; skipping multi-model fallback.`,
)
return null
}
const models = Array.from(
new Set(
aiModel
.split(",")
.map((s) => s.trim())
.filter((s) => s.length > 0),
),
)
if (models.length === 0) return null
const providerName = aiProvider as ProviderName
return {
providers: [
{
name: PROVIDER_INFO[providerName]?.label || providerName,
provider: providerName,
models,
default: true,
},
],
}
}
export async function loadEnvServerModelsConfig(): Promise<ServerModelsConfig | null> {
// Priority 1: AI_MODELS_CONFIG env var (JSON string) - for cloud deployments
const envConfig = process.env.AI_MODELS_CONFIG
@@ -85,15 +132,17 @@ export async function loadEnvServerModelsConfig(): Promise<ServerModelsConfig |
const json = JSON.parse(jsonStr)
return ServerModelsConfigSchema.parse(json)
} catch (err: any) {
if (err?.code === "ENOENT") {
if (err?.code !== "ENOENT") {
console.error(
"[server-model-config] Failed to load ai-models.json:",
err,
)
return null
}
console.error(
"[server-model-config] Failed to load ai-models.json:",
err,
)
return null
}
// Priority 3: AI_MODEL with comma-separated values + AI_PROVIDER
return configFromCommaSeparatedAiModel()
}
export async function loadRawServerModelsConfig(): Promise<ServerModelsConfig | null> {

View File

@@ -2,80 +2,108 @@
* SSRF (Server-Side Request Forgery) protection utilities
*/
import { lookup } from "node:dns/promises"
/**
* Check if URL points to private/internal network
* Blocks: localhost, private IPs, link-local, AWS metadata service
* Check if an IP address (IPv4 or IPv6) belongs to a private/internal range.
* Works for both user-supplied literal IPs and DNS-resolved addresses.
*/
export function isPrivateUrl(urlString: string): boolean {
function isPrivateIp(ip: string): boolean {
const addr = ip.toLowerCase().replace(/^\[|\]$/g, "")
// IPv6
if (addr.includes(":")) {
if (addr === "::1" || addr === "::") return true
// unique-local (fc00::/7) and IPv4-mapped (::ffff:0:0/96)
if (
addr.startsWith("fc") ||
addr.startsWith("fd") ||
addr.startsWith("::ffff:")
) {
return true
}
// link-local (fe80::/10)
const linkLocal = addr.match(/^fe([0-9a-f]{2}):/)
if (linkLocal) {
const high = parseInt(linkLocal[1], 16)
if (high >= 0x80 && high <= 0xbf) return true
}
return false
}
// IPv4
const ipv4Match = addr.match(/^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/)
if (ipv4Match) {
const [, a, b] = ipv4Match.map(Number)
if (a === 10) return true // 10.0.0.0/8
if (a === 172 && b >= 16 && b <= 31) return true // 172.16.0.0/12
if (a === 192 && b === 168) return true // 192.168.0.0/16
if (a === 169 && b === 254) return true // 169.254.0.0/16 (link-local)
if (a === 127) return true // 127.0.0.0/8 (loopback)
if (a === 0) return true // 0.0.0.0/8
if (a === 100 && b >= 64 && b <= 127) return true // 100.64.0.0/10 (CGNAT, used by some cloud internal networks)
}
return false
}
/**
* String-only check against well-known private hostnames and literal IPs.
* Fast path that avoids a DNS lookup for obvious cases.
*/
function isPrivateHostname(hostname: string): boolean {
const host = hostname
.toLowerCase()
.replace(/^\[|\]$/g, "")
.replace(/\.$/, "")
if (
host === "localhost" ||
host === "127.0.0.1" ||
host === "::1" ||
host === "::"
) {
return true
}
if (host === "169.254.169.254" || host === "metadata.google.internal") {
return true
}
if (
host.endsWith(".local") ||
host.endsWith(".internal") ||
host.endsWith(".localhost")
) {
return true
}
// Literal IP supplied directly in the URL
return isPrivateIp(host)
}
/**
* Check if URL points to private/internal network.
* Blocks: localhost, private IPs, link-local, AWS metadata service.
*
* Resolves the hostname via DNS and validates every returned address, so
* public-looking names that map to internal IPs (e.g. "127-0-0-1.sslip.io")
* are caught even though they pass the string-only check.
*/
export async function isPrivateUrl(urlString: string): Promise<boolean> {
try {
const url = new URL(urlString)
// Strip a trailing dot so FQDN forms like "localhost." (which still
// resolve to 127.0.0.1) cannot bypass the equality checks below.
const hostname = url.hostname
.toLowerCase()
.replace(/^\[|\]$/g, "")
.replace(/\.$/, "")
// Block localhost
if (
hostname === "localhost" ||
hostname === "127.0.0.1" ||
hostname === "::1" ||
hostname === "::"
) {
return true
}
// Fast path: obvious string matches and literal IPs.
if (isPrivateHostname(hostname)) return true
// Block IPv6 unique-local (fc00::/7), link-local (fe80::/10),
// and IPv4-mapped (::ffff:0:0/96) hosts.
if (hostname.includes(":")) {
if (
hostname.startsWith("fc") ||
hostname.startsWith("fd") ||
hostname.startsWith("::ffff:")
) {
return true
}
const linkLocal = hostname.match(/^fe([0-9a-f]{2}):/)
if (linkLocal) {
const high = parseInt(linkLocal[1], 16)
if (high >= 0x80 && high <= 0xbf) return true
}
}
// Block AWS/cloud metadata endpoints
if (
hostname === "169.254.169.254" ||
hostname === "metadata.google.internal"
) {
return true
}
// Check for private IPv4 ranges
const ipv4Match = hostname.match(
/^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/,
)
if (ipv4Match) {
const [, a, b] = ipv4Match.map(Number)
if (a === 10) return true // 10.0.0.0/8
if (a === 172 && b >= 16 && b <= 31) return true // 172.16.0.0/12
if (a === 192 && b === 168) return true // 192.168.0.0/16
if (a === 169 && b === 254) return true // 169.254.0.0/16 (link-local)
if (a === 127) return true // 127.0.0.0/8 (loopback)
}
// Block common internal hostnames
if (
hostname.endsWith(".local") ||
hostname.endsWith(".internal") ||
hostname.endsWith(".localhost")
) {
return true
}
return false
// Resolve DNS and reject if any address is private.
const stripped = hostname.replace(/^\[|\]$/g, "").replace(/\.$/, "")
const addresses = await lookup(stripped, { all: true })
return addresses.some(({ address }) => isPrivateIp(address))
} catch {
return true // Invalid URL - block it
return true // Invalid URL or DNS failure - block it
}
}

View File

@@ -31,6 +31,9 @@ export const STORAGE_KEYS = {
// Custom system message
customSystemMessage: "next-ai-draw-io-custom-system-message",
// Output token budget per turn (empty = server default)
maxOutputTokens: "next-ai-draw-io-max-output-tokens",
// Panel visibility
showRecentChats: "next-ai-draw-io-show-recent-chats",
showMyTemplates: "next-ai-draw-io-show-my-templates",

View File

@@ -9,6 +9,7 @@ export type ProviderName =
| "bedrock"
| "ollama"
| "openrouter"
| "aihubmix"
| "deepseek"
| "siliconflow"
| "sglang"
@@ -22,6 +23,8 @@ export type ProviderName =
| "kimi"
| "minimax"
| "novita"
| "mimo"
| "atlascloud"
// Individual model configuration
export interface ModelConfig {
@@ -102,6 +105,7 @@ export const PROVIDER_LOGO_MAP: Record<string, string> = {
azure: "azure",
bedrock: "amazon-bedrock",
openrouter: "openrouter",
aihubmix: "aihubmix",
deepseek: "deepseek",
siliconflow: "siliconflow",
sglang: "openai", // SGLang is OpenAI-compatible
@@ -112,6 +116,8 @@ export const PROVIDER_LOGO_MAP: Record<string, string> = {
modelscope: "modelscope",
minimax: "minimax",
novita: "novita",
mimo: "xiaomi",
atlascloud: "openai",
}
// Provider metadata
@@ -145,6 +151,10 @@ export const PROVIDER_INFO: Record<
label: "OpenRouter",
defaultBaseUrl: "https://openrouter.ai/api/v1",
},
aihubmix: {
label: "AIHubMix",
defaultBaseUrl: "https://aihubmix.com/v1",
},
deepseek: {
label: "DeepSeek",
defaultBaseUrl: "https://api.deepseek.com/v1",
@@ -194,6 +204,14 @@ export const PROVIDER_INFO: Record<
label: "Novita AI",
defaultBaseUrl: "https://api.novita.ai/openai",
},
mimo: {
label: "MiMo (Xiaomi)",
defaultBaseUrl: "https://api.xiaomimimo.com/v1",
},
atlascloud: {
label: "Atlas Cloud",
defaultBaseUrl: "https://api.atlascloud.ai/v1",
},
}
// Suggested models per provider for quick add
@@ -317,6 +335,41 @@ export const SUGGESTED_MODELS: Partial<Record<ProviderName, string[]>> = {
// MiniMax
"minimax/minimax-m3",
],
aihubmix: [
// Fallback list. The settings UI loads the live model list from AIHubMix when available.
// Anthropic Claude
"claude-fable-5",
"claude-opus-4-8",
"claude-sonnet-4-6",
// OpenAI
"gpt-5.5",
"gpt-5.5-pro",
"gpt-5.4",
// Google Gemini
"gemini-3.5-flash",
"gemini-3.1-pro-preview",
"gemini-3-flash-preview",
// DeepSeek
"deepseek-v4-pro",
"deepseek-v4-flash",
// Qwen
"qwen3.7-max",
"qwen3-coder-next",
// Z.ai
"glm-5.1",
// Moonshot AI
"kimi-k2.6",
// MiniMax
"minimax-m3",
// xAI
"grok-4.3",
// Baidu
"ernie-5.1",
// Mistral
"mistral-large-3",
// Meta
"llama-4-maverick",
],
deepseek: [
"deepseek-v4-pro",
"deepseek-v4-flash",
@@ -396,6 +449,8 @@ export const SUGGESTED_MODELS: Partial<Record<ProviderName, string[]>> = {
"moonshotai/kimi-k2.6",
"deepseek/deepseek-v4-flash",
],
mimo: ["mimo-v2.5-pro", "mimo-v2.5"],
atlascloud: ["qwen/qwen3.5-flash", "deepseek-ai/deepseek-v4-pro"],
}
// Helper to generate UUID

3480
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -40,6 +40,7 @@
"@ai-sdk/google-vertex": "^4.0.16",
"@ai-sdk/openai": "^3.0.0",
"@ai-sdk/react": "^3.0.1",
"@aihubmix/ai-sdk-provider": "^2.1.0",
"@aws-sdk/client-dynamodb": "^3.957.0",
"@aws-sdk/credential-providers": "^3.943.0",
"@extractus/article-extractor": "^8.0.18",
@@ -51,7 +52,7 @@
"@opennextjs/cloudflare": "^1.17.1",
"@openrouter/ai-sdk-provider": "^2.0.0",
"@opentelemetry/api": "^1.9.0",
"@opentelemetry/exporter-trace-otlp-http": "^0.216.0",
"@opentelemetry/exporter-trace-otlp-http": "^0.222.0",
"@opentelemetry/sdk-trace-node": "^2.2.0",
"@radix-ui/react-alert-dialog": "^1.1.15",
"@radix-ui/react-collapsible": "^1.1.12",
@@ -108,7 +109,7 @@
},
"devDependencies": {
"@anthropic-ai/tokenizer": "^0.0.4",
"@biomejs/biome": "2.4.13",
"@biomejs/biome": "2.5.7",
"@playwright/test": "^1.57.0",
"@tailwindcss/postcss": "^4",
"@tailwindcss/typography": "^0.5.19",
@@ -128,7 +129,7 @@
"electron": "^39.2.7",
"electron-builder": "^26.0.12",
"esbuild": "^0.28.0",
"eslint": "9.39.4",
"eslint": "9.39.5",
"eslint-config-next": "16.1.6",
"husky": "^9.1.7",
"jsdom": "^27.4.0",

View File

@@ -116,9 +116,14 @@ Use the standard MCP configuration with:
|------|-------------|
| `start_session` | Opens browser with real-time diagram preview |
| `create_new_diagram` | Create a new diagram from XML (requires `xml` argument) |
| `load_diagram` | Load a `.drawio` file from disk into the session (handles compressed files) |
| `edit_diagram` | Edit diagram by ID-based operations (update/add/delete cells) |
| `get_diagram` | Get the current diagram XML |
| `export_diagram` | Save diagram to a `.drawio` file |
| `export_diagram` | Save diagram to a `.drawio`, `.png`, or `.svg` file |
| `list_pages` | List every page (tab) with id, name, index, and cell count |
| `add_page` | Append a new page without touching existing ones |
| `rename_page` | Rename a page |
| `delete_page` | Delete a page (refuses to delete the last one) |
## How It Works

File diff suppressed because it is too large Load Diff

View File

@@ -1,6 +1,6 @@
{
"name": "@next-ai-drawio/mcp-server",
"version": "0.2.0",
"version": "0.2.3",
"description": "MCP server for Next AI Draw.io - AI-powered diagram generation with real-time browser preview",
"type": "module",
"main": "dist/index.js",
@@ -11,6 +11,8 @@
"build": "tsc",
"dev": "tsx watch src/index.ts",
"start": "node dist/index.js",
"test": "vitest run",
"test:watch": "vitest",
"prepublishOnly": "npm run build"
},
"keywords": [
@@ -44,7 +46,8 @@
"devDependencies": {
"@types/node": "^24.0.0",
"tsx": "^4.19.0",
"typescript": "^5"
"typescript": "^5",
"vitest": "^4.1.8"
},
"engines": {
"node": ">=18"

View File

@@ -1,8 +1,14 @@
/**
* ID-based diagram operations
* Copied from lib/utils.ts to avoid cross-package imports
*
* The xmlContent argument may be either a bare <mxGraphModel> (legacy) or a
* full <mxfile> with one or more <diagram> pages. For mxfile inputs, an
* optional pageSelector identifies which page to edit; when omitted, the
* first page is targeted (the "active page by convention" — see pages.ts).
*/
import { findPageElement, hasPageSelector, type PageSelector } from "./pages.js"
export interface DiagramOperation {
operation: "update" | "add" | "delete"
cell_id: string
@@ -22,15 +28,18 @@ export interface ApplyOperationsResult {
/**
* Apply diagram operations (update/add/delete) using ID-based lookup.
* This replaces the text-matching approach with direct DOM manipulation.
*
* @param xmlContent - The full mxfile XML content
* @param operations - Array of operations to apply
* @returns Object with result XML and any errors
* @param xmlContent - The diagram XML. May be either a bare <mxGraphModel> or
* a full <mxfile> with one or more <diagram> children.
* @param operations - Array of operations to apply.
* @param pageSelector - Optional page selector for multi-page docs. Defaults
* to the first page.
* @returns Object with result XML (same shape as input) and any per-op errors.
*/
export function applyDiagramOperations(
xmlContent: string,
operations: DiagramOperation[],
pageSelector?: PageSelector,
): ApplyOperationsResult {
const errors: OperationError[] = []
@@ -53,22 +62,75 @@ export function applyDiagramOperations(
}
}
// Find the root element (inside mxGraphModel)
const root = doc.querySelector("root")
if (!root) {
return {
result: xmlContent,
errors: [
{
type: "update",
cellId: "",
message: "Could not find <root> element in XML",
},
],
// Locate the <root> element to operate on.
//
// - For <mxfile> input: resolve the page via pageSelector, then dive into
// its <root>. This scopes querySelectorAll calls below to one page so
// cells on other pages aren't accidentally matched.
// - For bare <mxGraphModel> input: use the document's only <root>.
let root: Element | null
if (doc.documentElement?.tagName === "mxfile") {
const found = findPageElement(doc as unknown as Document, pageSelector)
if (!found) {
const selDesc = hasPageSelector(pageSelector)
? ` matching selector ${JSON.stringify(pageSelector)}`
: ""
return {
result: xmlContent,
errors: [
{
type: "update",
cellId: "",
message: `Page${selDesc} not found in <mxfile>`,
},
],
}
}
root = found.element.querySelector("root")
if (!root) {
const pageId =
found.element.getAttribute("id") || `(index ${found.index})`
return {
result: xmlContent,
errors: [
{
type: "update",
cellId: "",
message: `Page "${pageId}" has no <root> element`,
},
],
}
}
} else {
if (hasPageSelector(pageSelector)) {
return {
result: xmlContent,
errors: [
{
type: "update",
cellId: "",
message:
"Page selector provided but document is not multi-page (no <mxfile> wrapper). Use create_new_diagram with a full <mxfile> first, or omit the page selector.",
},
],
}
}
root = doc.querySelector("root")
if (!root) {
return {
result: xmlContent,
errors: [
{
type: "update",
cellId: "",
message: "Could not find <root> element in XML",
},
],
}
}
}
// Build a map of cell IDs to elements
// Build a map of cell IDs to elements (scoped to the resolved page).
const cellMap = new Map<string, Element>()
root.querySelectorAll("mxCell").forEach((cell) => {
const id = cell.getAttribute("id")
@@ -208,7 +270,9 @@ export function applyDiagramOperations(
cellsToDelete.add(cellId)
// Find children (cells where parent === cellId)
const children = root.querySelectorAll(
// Scoped to `root` so other pages' cells with the same parent id
// (notably "1") are never touched.
const children = root!.querySelectorAll(
`mxCell[parent="${cellId}"]`,
)
children.forEach((child) => {

View File

@@ -0,0 +1,102 @@
/**
* Workflow gate for edit_diagram.
*
* Instead of a wall-clock timeout (the old 30s rule rejected slow-but-correct
* clients, see #885), we compare content: `lastSeenXml` is the state-store
* XML the model last saw (get_diagram) or wrote itself (create_new_diagram /
* edit_diagram / page CRUD). The store only changes on server writes or
* browser pushes (user autosave, sync exports), so if the live store still
* matches `lastSeenXml`, nothing happened that the model hasn't seen — the
* edit is safe no matter how much time passed.
*
* "Matches" is structural, not byte-for-byte: draw.io re-serialises the
* document when it pushes state back (different attribute order, pretty-
* printed whitespace, regenerated diagram ids, viewport attributes like
* dx/dy/pageWidth on <mxGraphModel>, a different mxfile host). None of that
* is a user edit, so the fingerprint keeps only what a user can actually
* change: the set of pages, each page's name, and each page's cell tree
* (tags + sorted attributes + text). Byte equality is kept as a fast path.
*/
import { isMxGraphModel, normalizeToMxfile, parseMxfile } from "./pages.js"
export type EditGateResult =
| { ok: true }
| { ok: false; reason: "no-context" | "stale" }
/**
* Canonical serialisation of an element subtree: tag + attributes sorted by
* name + child elements in order + non-whitespace text. Whitespace-only text
* nodes (pretty-printing) are dropped.
*/
function canonicalizeElement(el: Element): string {
const attrs = Array.from(el.attributes)
.map((a) => `${a.name}=${JSON.stringify(a.value)}`)
.sort()
.join(" ")
let children = ""
for (const child of Array.from(el.childNodes)) {
if (child.nodeType === 1) {
children += canonicalizeElement(child as Element)
} else if (child.nodeType === 3 || child.nodeType === 4) {
const text = (child.textContent ?? "").trim()
if (text) children += JSON.stringify(text)
}
}
return `<${el.tagName} ${attrs}>${children}</${el.tagName}>`
}
/**
* Structural fingerprint of a diagram document: page names + each page's
* <root> subtree, ignoring everything draw.io rewrites on re-serialisation
* (mxfile/mxGraphModel attributes, diagram ids, formatting). A bare
* <mxGraphModel> fingerprints identically to its single-page mxfile wrapping.
* Unparseable input falls back to the trimmed raw string, degrading to the
* plain string comparison.
*
* `includeNames=false` drops page names from the fingerprint — used when the
* other side of a comparison is a bare <mxGraphModel>, which carries no page
* name at all (normalizeToMxfile would invent "Page-1", falsely mismatching
* any real page name).
*/
export function contentFingerprint(xml: string, includeNames = true): string {
const normalized = normalizeToMxfile(xml)
const doc = normalized ? parseMxfile(normalized) : null
if (!doc) return xml.trim()
const pages: string[] = []
doc.querySelectorAll("diagram").forEach((d) => {
const name = includeNames ? d.getAttribute("name") || "" : ""
const root = d.querySelector("root")
// No <root> means the page content is not plain XML (e.g. draw.io's
// compressed format) — fingerprint the raw text instead.
const body = root
? canonicalizeElement(root)
: (d.textContent || "").trim()
pages.push(`${name}=${body}`)
})
return pages.join("\n")
}
export function checkEditGate(
lastSeenXml: string,
liveXml: string,
): EditGateResult {
// Model never fetched or produced any diagram state in this session.
if (!lastSeenXml) return { ok: false, reason: "no-context" }
// Browser state moved since the model last looked (e.g. manual user
// edits): force a re-fetch so update/delete operations don't build on
// stale cell contents. An empty liveXml means the store has no entry to
// compare against, so there is nothing newer to have missed.
if (liveXml && liveXml !== lastSeenXml) {
// A bare <mxGraphModel> on either side carries no page name, so
// comparing names would mismatch against anything not called
// "Page-1". Compare cell trees only in that case.
const includeNames =
!isMxGraphModel(liveXml) && !isMxGraphModel(lastSeenXml)
if (
contentFingerprint(liveXml, includeNames) !==
contentFingerprint(lastSeenXml, includeNames)
)
return { ok: false, reason: "stale" }
}
return { ok: true }
}

View File

@@ -93,6 +93,7 @@ interface SessionState {
svg?: string // Cached SVG from last browser save
syncRequested?: number // Timestamp when sync requested, cleared when browser responds
exportFormat?: "png" | "svg" // Set by MCP tool to request browser export
exportXml?: string // Single-page projection to load before a page-targeted export
exportData?: string // Base64/SVG data returned by browser after export
}
@@ -117,12 +118,37 @@ export function setState(sessionId: string, xml: string, svg?: string): number {
svg: svg || existing?.svg, // Preserve cached SVG if not provided
syncRequested: undefined, // Clear sync request when browser pushes state
exportFormat: existing?.exportFormat, // Preserve pending export request
exportXml: existing?.exportXml, // Preserve pending projection
exportData: existing?.exportData, // Preserve export result
})
log.debug(`State updated: session=${sessionId}, version=${newVersion}`)
return newVersion
}
/**
* Ask the browser bridge to export the current diagram as png/svg.
*
* When `projectionXml` is given (a single-page <mxfile>), the bridge loads it
* first, waits for draw.io's own load event, exports, then reloads the
* session's real document — so a page-targeted export never mutates the
* canonical session state and needs no fixed-delay guessing on the server.
*
* Returns false when the session is unknown. Callers should then poll
* `getState(sessionId)?.exportData` for the result.
*/
export function requestExport(
sessionId: string,
format: "png" | "svg",
projectionXml?: string,
): boolean {
const state = stateStore.get(sessionId)
if (!state) return false
state.exportData = undefined
state.exportXml = projectionXml
state.exportFormat = format
return true
}
export function requestSync(sessionId: string): boolean {
const state = stateStore.get(sessionId)
if (state) {
@@ -286,6 +312,7 @@ function handleStateApi(
version: state?.version || 0,
syncRequested: !!state?.syncRequested,
exportFormat: state?.exportFormat || null,
exportXml: state?.exportXml || null,
}),
)
} else if (req.method === "POST") {
@@ -305,6 +332,7 @@ function handleStateApi(
if (state) {
state.exportData = data.exportData
state.exportFormat = undefined
state.exportXml = undefined
log.debug(
`Export data received for session=${sessionId}`,
)
@@ -675,6 +703,8 @@ function getHtmlPage(sessionId: string): string {
let pendingSvgExport = null;
let pendingAiSvg = false;
let pendingMcpExport = null; // 'png' or 'svg' when MCP requested export
let projectionExportActive = false; // page-targeted export: showing a transient single-page projection
let projectionRestoreXml = null; // the real document to reload once a projection export finishes
window.addEventListener('message', (e) => {
if (e.origin !== '${DRAWIO_ORIGIN}') return;
@@ -684,6 +714,10 @@ function getHtmlPage(sessionId: string): string {
isReady = true;
if (pendingXml) { loadDiagram(pendingXml); pendingXml = null; }
} else if ((msg.event === 'save' || msg.event === 'autosave') && msg.xml && msg.xml !== lastXml) {
// Ignore autosave while a single-page projection is on screen
// for a page-targeted export — otherwise we'd push the
// transient projection back as the canonical session state.
if (projectionExportActive) return;
// Request SVG export, then push state with SVG
pendingSvgExport = msg.xml;
iframe.contentWindow.postMessage(JSON.stringify({ action: 'export', format: 'svg' }), '*');
@@ -704,6 +738,9 @@ function getHtmlPage(sessionId: string): string {
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ sessionId, exportData: d })
}).catch(() => {});
// Page-targeted export: restore the user's real
// multi-page document now that we have the image.
restoreFromProjection();
return;
}
}
@@ -761,6 +798,22 @@ function getHtmlPage(sessionId: string): string {
}
}
// Restore the user's real document after a page-targeted projection
// export. If we never captured one (lastXml was null at projection
// start), fall back to forcing a reload from the server on the next
// poll by rewinding currentVersion — never leave the iframe stuck on
// the transient projection.
function restoreFromProjection() {
if (!projectionExportActive) return;
projectionExportActive = false;
if (projectionRestoreXml) {
iframe.contentWindow.postMessage(JSON.stringify({ action: 'load', xml: projectionRestoreXml, autosave: 1 }), '*');
projectionRestoreXml = null;
} else {
currentVersion = -1; // force the next poll to reload from server
}
}
async function pushState(xml, svg = '') {
if (!sessionId) return;
try {
@@ -786,20 +839,54 @@ function getHtmlPage(sessionId: string): string {
pendingSyncExport = true;
iframe.contentWindow.postMessage(JSON.stringify({ action: 'export', format: 'xml' }), '*');
}
// Load new diagram from server (before export, so we export latest)
if (s.version > currentVersion && s.xml) {
// Load new diagram from server (before export, so we export latest).
// While a page-targeted projection is on screen, skip the reload
// so it doesn't fight the projection — and leave currentVersion
// unadvanced so this bump is re-detected and applied once the
// real document is restored.
if (s.version > currentVersion && s.xml && !projectionExportActive) {
currentVersion = s.version;
loadDiagram(s.xml, true);
}
// Handle export request from MCP server (png/svg) - after version update
// Handle export request from MCP server (png/svg).
//
// Plain export: capture whatever tab is currently displayed.
//
// Page-targeted export: the server sends a single-page <mxfile>
// projection in s.exportXml. We load it into the iframe, let
// draw.io render it, export, then reload the user's real
// document — all browser-side. The canonical session state is
// never mutated, so there is no server-side restore race and no
// dependence on poll timing. autosave is suppressed while the
// projection is showing (see projectionExportActive guard).
if (s.exportFormat && !pendingMcpExport && isReady) {
pendingMcpExport = s.exportFormat;
const exportOpts = s.exportFormat === 'png'
? { action: 'export', format: 'png', scale: 2 }
: { action: 'export', format: 'svg' };
iframe.contentWindow.postMessage(JSON.stringify(exportOpts), '*');
// Timeout: reset if draw.io never responds
setTimeout(() => { if (pendingMcpExport) { pendingMcpExport = null; } }, 8000);
const fireExport = () => {
const exportOpts = pendingMcpExport === 'png'
? { action: 'export', format: 'png', scale: 2 }
: { action: 'export', format: 'svg' };
iframe.contentWindow.postMessage(JSON.stringify(exportOpts), '*');
};
if (s.exportXml) {
// Stash the real document so we can restore after export.
projectionRestoreXml = lastXml;
projectionExportActive = true;
// Load the projection without touching lastXml/server state.
iframe.contentWindow.postMessage(JSON.stringify({ action: 'load', xml: s.exportXml, autosave: 0 }), '*');
// Let draw.io render the loaded page before exporting
// (same proven settle delay as the AI-preview path).
setTimeout(fireExport, 600);
} else {
fireExport();
}
// Timeout: reset if draw.io never responds, and restore the
// real document if a projection was left showing.
setTimeout(() => {
if (pendingMcpExport) {
pendingMcpExport = null;
restoreFromProjection();
}
}, 10000);
}
} catch {}
}
@@ -839,7 +926,11 @@ function getHtmlPage(sessionId: string): string {
saveConfirmBtn.textContent = 'Exporting...';
if (format === 'drawio') {
// Use lastXml directly instead of requesting export (avoids race with SVG exports)
// Use lastXml directly instead of requesting export (avoids race with SVG exports).
// session.xml is canonically <mxfile> after the multi-page refactor,
// so no wrapper injection is needed. The legacy fallback below
// remains only for documents that somehow slipped past
// normalisation (e.g. an older session loaded from external state).
let xmlData = lastXml || '';
if (xmlData && !xmlData.includes('<mxfile')) {
xmlData = '<mxfile host="mcp"><diagram name="Page-1">' + xmlData + '</diagram></mxfile>';

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,101 @@
/**
* File-loading helpers for the load_diagram tool.
*
* A .drawio file is an <mxfile> whose <diagram> children hold each page's
* <mxGraphModel> either as plain XML or — draw.io's default save format —
* compressed: encodeURIComponent(xml) → raw deflate → base64 as the
* diagram's text content. The rest of the server assumes plain XML inside
* every <diagram>, so loading decompresses all pages up front.
*/
import { inflateRawSync } from "node:zlib"
import { DOMParser } from "linkedom"
import {
isMxFile,
isMxGraphModel,
normalizeToMxfile,
parseMxfile,
serializeMxfile,
} from "./pages.js"
export type LoadResult =
| { ok: true; xml: string }
| { ok: false; error: string }
/**
* Decode one compressed page body (base64 → raw deflate → URI-decode).
* Returns null if the text isn't in that format.
*/
export function decompressPageContent(compressed: string): string | null {
try {
const inflated = inflateRawSync(
Buffer.from(compressed.trim(), "base64"),
).toString("utf-8")
try {
return decodeURIComponent(inflated)
} catch {
// Not URI-encoded (older files) — the inflated text is the XML.
return inflated
}
} catch {
return null
}
}
/**
* Parse the content of a .drawio file into the canonical session shape:
* an <mxfile> whose every page holds plain <mxGraphModel> XML. Accepts a
* bare <mxGraphModel> (wrapped into a one-page mxfile) and decompresses
* any compressed pages.
*/
export function parseDrawioFileContent(content: string): LoadResult {
const trimmed = content.trim()
if (!trimmed) return { ok: false, error: "File is empty." }
if (isMxGraphModel(trimmed)) {
const normalized = normalizeToMxfile(trimmed)
return normalized
? { ok: true, xml: normalized }
: { ok: false, error: "Failed to parse <mxGraphModel> XML." }
}
if (!isMxFile(trimmed)) {
return {
ok: false,
error: "Not a draw.io file: expected an <mxfile> or <mxGraphModel> root element.",
}
}
const doc = parseMxfile(trimmed)
if (!doc) return { ok: false, error: "Failed to parse <mxfile> XML." }
let decompressedAny = false
for (const d of Array.from(doc.querySelectorAll("diagram"))) {
if (d.querySelector("mxGraphModel")) continue
const text = (d.textContent || "").trim()
if (!text) continue // an empty page is valid
const pageLabel =
d.getAttribute("name") || d.getAttribute("id") || "unnamed"
const xml = decompressPageContent(text)
if (!xml || !isMxGraphModel(xml)) {
return {
ok: false,
error: `Page "${pageLabel}" has content that is neither plain <mxGraphModel> XML nor draw.io's compressed format.`,
}
}
const inner = new DOMParser().parseFromString(xml, "text/xml")
if (
inner.querySelector("parsererror") ||
inner.documentElement?.tagName !== "mxGraphModel"
) {
return {
ok: false,
error: `Page "${pageLabel}" decompressed but its XML failed to parse.`,
}
}
d.textContent = ""
d.appendChild(
doc.importNode(inner.documentElement as unknown as Node, true),
)
decompressedAny = true
}
// Nothing changed — keep the file's own serialisation.
return { ok: true, xml: decompressedAny ? serializeMxfile(doc) : trimmed }
}

View File

@@ -0,0 +1,316 @@
/**
* Multi-page (mxfile) helpers for draw.io diagrams.
*
* The on-disk and embed-protocol shape of a draw.io document is:
*
* <mxfile host="...">
* <diagram id="..." name="...">
* <mxGraphModel><root><mxCell .../>...</root></mxGraphModel>
* </diagram>
* ...one or more <diagram> children...
* </mxfile>
*
* This module centralises page CRUD so that index.ts, xml-validation.ts,
* and diagram-operations.ts can all agree on:
* - what "the canonical in-memory shape" is (always mxfile),
* - how to find a page (id, name, or index),
* - how to add/rename/delete pages without re-parsing ad-hoc.
*/
import { DOMParser } from "linkedom"
export interface PageInfo {
id: string
name: string
index: number
cellCount: number
}
/** Selector used by all multi-page-aware tools. All fields optional. */
export interface PageSelector {
page_id?: string
page_name?: string
page_index?: number
}
/** True if the selector targets a specific page (any field set). */
export function hasPageSelector(s?: PageSelector | null): boolean {
if (!s) return false
return (
Boolean(s.page_id) || Boolean(s.page_name) || s.page_index !== undefined
)
}
/**
* Generate a short page id similar in shape to drawio's auto-assigned ids.
* Format: 12 chars alphanumeric with a single dash. Not a UUID — drawio itself
* uses short ids; collisions are still astronomically unlikely for one session.
*/
export function generatePageId(): string {
const a = Math.random().toString(36).substring(2, 10)
const b = Math.random().toString(36).substring(2, 6)
return `${a}-${b}`
}
/** Cheap regex check — does the XML start with an <mxfile> root? */
export function isMxFile(xml: string): boolean {
return /^\s*(<\?xml[^>]*\?>\s*)?<mxfile[\s>]/i.test(xml)
}
/** Cheap regex check — does the XML start with a bare <mxGraphModel>? */
export function isMxGraphModel(xml: string): boolean {
return /^\s*(<\?xml[^>]*\?>\s*)?<mxGraphModel[\s>]/i.test(xml)
}
function escapeAttr(s: string): string {
return s
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;")
}
/**
* Strip a leading <?xml ... ?> declaration from an XML string. The XML spec
* only permits the declaration at the very start of a document, so embedding
* a declaration inside another element produces invalid XML. Callers must
* strip before splicing a fragment into a wrapper.
*/
function stripXmlDeclaration(xml: string): string {
return xml.replace(/^\s*<\?xml[^>]*\?>\s*/i, "")
}
/**
* Wrap a bare <mxGraphModel> XML string in <mxfile><diagram>...</diagram></mxfile>.
* If the input is already an mxfile, returns it unchanged.
* If the input is neither shape, returns null so the caller can surface a clear error.
*
* Strips any leading <?xml ?> declaration before embedding — a declaration is
* only valid at the very start of a document, never inside a <diagram>.
*/
export function normalizeToMxfile(
xml: string,
opts: { pageId?: string; pageName?: string; host?: string } = {},
): string | null {
const trimmed = xml.trim()
if (!trimmed) return null
if (isMxFile(trimmed)) return trimmed
if (!isMxGraphModel(trimmed)) return null
const pageId = opts.pageId || generatePageId()
const pageName = opts.pageName || "Page-1"
const host = opts.host || "app.diagrams.net"
const inner = stripXmlDeclaration(trimmed)
return `<mxfile host="${escapeAttr(host)}"><diagram id="${escapeAttr(pageId)}" name="${escapeAttr(pageName)}">${inner}</diagram></mxfile>`
}
/**
* Parse an mxfile XML string. Returns null on parse error or if the root
* isn't <mxfile> — callers are expected to have run normalizeToMxfile first.
*/
export function parseMxfile(xml: string): Document | null {
try {
const doc = new DOMParser().parseFromString(xml, "text/xml")
if (doc.querySelector("parsererror")) return null
if (doc.documentElement?.tagName !== "mxfile") return null
return doc as unknown as Document
} catch {
return null
}
}
/** Serialise an mxfile doc back to a string via the global XMLSerializer polyfill. */
export function serializeMxfile(doc: Document): string {
const serializer = new XMLSerializer()
return serializer.serializeToString(doc)
}
export type PageProjection =
| { ok: true; xml: string; index: number; name: string }
| { ok: false; reason: "parse" | "notfound" }
/**
* Project a single page out of an mxfile string into a standalone one-page
* <mxfile>. Used by get_diagram and export_diagram so the three call sites
* share one parse → find → serialise path.
*
* Returns { ok:false, reason:"parse" } if the xml isn't a parseable mxfile,
* or { ok:false, reason:"notfound" } if the selector matches no page.
*/
export function projectPage(
xml: string,
selector: PageSelector,
): PageProjection {
const doc = parseMxfile(xml)
if (!doc) return { ok: false, reason: "parse" }
const found = findPageElement(doc, selector)
if (!found) return { ok: false, reason: "notfound" }
const serializer = new XMLSerializer()
return {
ok: true,
xml: `<mxfile host="app.diagrams.net">${serializer.serializeToString(found.element)}</mxfile>`,
index: found.index,
name: found.element.getAttribute("name") || "",
}
}
/** Walk every <diagram> child of <mxfile> and return summary info. */
export function listPagesFromDoc(doc: Document): PageInfo[] {
const diagrams = doc.querySelectorAll("diagram")
const result: PageInfo[] = []
diagrams.forEach((d, idx) => {
const root = d.querySelector("root")
const cellCount = root ? root.querySelectorAll("mxCell").length : 0
result.push({
id: d.getAttribute("id") || "",
name: d.getAttribute("name") || `Page-${idx + 1}`,
index: idx,
cellCount,
})
})
return result
}
/**
* Resolve a page selector to its <diagram> element.
* Resolution order: page_id → page_name → page_index → default (first page).
*
* When no selector field is set we return the first page — the "active page
* by convention" mentioned in §3.4 of the design doc.
*/
export function findPageElement(
doc: Document,
selector?: PageSelector,
): { element: Element; index: number } | null {
const diagrams = Array.from(doc.querySelectorAll("diagram"))
if (diagrams.length === 0) return null
if (!hasPageSelector(selector)) {
return { element: diagrams[0], index: 0 }
}
if (selector?.page_id) {
for (let i = 0; i < diagrams.length; i++) {
if (diagrams[i].getAttribute("id") === selector.page_id) {
return { element: diagrams[i], index: i }
}
}
return null
}
if (selector?.page_name) {
for (let i = 0; i < diagrams.length; i++) {
if (diagrams[i].getAttribute("name") === selector.page_name) {
return { element: diagrams[i], index: i }
}
}
return null
}
if (selector && selector.page_index !== undefined) {
const idx = selector.page_index
if (Number.isInteger(idx) && idx >= 0 && idx < diagrams.length) {
return { element: diagrams[idx], index: idx }
}
return null
}
return null
}
/**
* Append a new <diagram> to the mxfile doc. The new page's model defaults to
* an empty <mxGraphModel><root><mxCell id="0"/><mxCell id="1" parent="0"/></root></mxGraphModel>.
*
* `opts.xml` must be a BARE <mxGraphModel> — passing a full <mxfile> would
* end up nested inside <diagram>, which is malformed. We reject the mxfile
* shape explicitly and strip any <?xml ?> declaration (only valid at
* document start, never inside <diagram>).
*
* Returns the new PageInfo. Throws if the requested id collides or the xml
* shape is wrong.
*/
export function addPageToDoc(
doc: Document,
opts: { id?: string; name?: string; xml?: string } = {},
): PageInfo {
const existing = listPagesFromDoc(doc)
const id = opts.id || generatePageId()
if (existing.some((p) => p.id === id)) {
throw new Error(`Page id "${id}" already exists`)
}
const name = opts.name || `Page-${existing.length + 1}`
let inner: string
if (opts.xml?.trim()) {
const trimmed = stripXmlDeclaration(opts.xml.trim())
if (isMxFile(trimmed)) {
throw new Error(
"addPageToDoc: opts.xml must be a bare <mxGraphModel>; received a full <mxfile>. Extract the target diagram's <mxGraphModel> first.",
)
}
if (!isMxGraphModel(trimmed)) {
throw new Error(
"addPageToDoc: opts.xml must be a bare <mxGraphModel>.",
)
}
inner = trimmed
} else {
inner = `<mxGraphModel><root><mxCell id="0"/><mxCell id="1" parent="0"/></root></mxGraphModel>`
}
const snippet = `<wrapper><diagram id="${escapeAttr(id)}" name="${escapeAttr(name)}">${inner}</diagram></wrapper>`
const tempDoc = new DOMParser().parseFromString(snippet, "text/xml")
if (tempDoc.querySelector("parsererror")) {
throw new Error(
"Failed to parse new page xml — make sure it is a valid <mxGraphModel>",
)
}
const newDiagram = tempDoc.querySelector("diagram")
if (!newDiagram) {
throw new Error("Failed to construct <diagram> element for new page")
}
const imported = doc.importNode(newDiagram, true) as Element
doc.documentElement.appendChild(imported)
return {
id,
name,
index: existing.length,
cellCount: imported.querySelectorAll("mxCell").length,
}
}
/** Rename the page matched by selector. Returns true on success. */
export function renamePageInDoc(
doc: Document,
selector: PageSelector,
newName: string,
): boolean {
const found = findPageElement(doc, selector)
if (!found) return false
found.element.setAttribute("name", newName)
return true
}
/**
* Delete a page. Refuses to delete the last remaining page — the embed needs
* at least one diagram to render anything, and silently recreating one would
* be surprising behaviour for an MCP caller.
*/
export function deletePageFromDoc(
doc: Document,
selector: PageSelector,
): { ok: boolean; reason?: string; deletedId?: string; deletedIndex?: number } {
const pages = listPagesFromDoc(doc)
if (pages.length <= 1) {
return { ok: false, reason: "Cannot delete the only remaining page" }
}
const found = findPageElement(doc, selector)
if (!found) {
return { ok: false, reason: "Page not found" }
}
const id = found.element.getAttribute("id") || ""
const index = found.index
found.element.parentNode?.removeChild(found.element)
return { ok: true, deletedId: id, deletedIndex: index }
}

View File

@@ -119,8 +119,74 @@ function checkDuplicateAttributes(xml: string): string | null {
return null
}
/** Check for duplicate IDs in XML */
/**
* Check for duplicate IDs in XML.
*
* For multi-page documents (<mxfile> with multiple <diagram> children), cell
* IDs are unique **within a page**, not across the whole document — drawio
* legitimately reuses "0" and "1" for the root cells of every page. So we
* scope the cell-ID uniqueness check per <diagram>, and additionally check
* that the <diagram> ids themselves are unique.
*
* The legacy regex-based check is kept as a fallback for non-mxfile inputs
* and for XML that won't DOM-parse.
*/
function checkDuplicateIds(xml: string): string | null {
// The DOM-aware path only matters for <mxfile> wrappers; for legacy
// bare <mxGraphModel> inputs (the overwhelming majority of historic
// traffic), the cheap regex fallback at the bottom is enough. A quick
// string check avoids paying the DOMParser cost on every call.
const mightBeMxFile = /<mxfile[\s>]/i.test(xml)
// Try DOM-aware, page-scoped check first when the input looks mxfile-ish.
if (mightBeMxFile)
try {
const doc = new DOMParser().parseFromString(xml, "text/xml")
if (!doc.querySelector("parsererror")) {
const rootEl = doc.documentElement
if (rootEl && rootEl.tagName === "mxfile") {
const diagrams = doc.querySelectorAll("diagram")
// 1) <diagram> ids must be unique across the file.
const diagramIds = new Map<string, number>()
diagrams.forEach((d) => {
const id = d.getAttribute("id")
if (id)
diagramIds.set(id, (diagramIds.get(id) || 0) + 1)
})
const dupDiagrams = Array.from(diagramIds.entries())
.filter(([, c]) => c > 1)
.map(([id]) => `'${id}'`)
if (dupDiagrams.length > 0) {
return `Invalid XML: Found duplicate <diagram> id(s): ${dupDiagrams.slice(0, 3).join(", ")}. Each page must have a unique id.`
}
// 2) Within each page, mxCell ids must be unique.
for (let i = 0; i < diagrams.length; i++) {
const diagram = diagrams[i]
const pageId =
diagram.getAttribute("id") || `(index ${i})`
const cells = diagram.querySelectorAll("mxCell")
const cellIds = new Map<string, number>()
cells.forEach((c) => {
const id = c.getAttribute("id")
if (id) cellIds.set(id, (cellIds.get(id) || 0) + 1)
})
const dups = Array.from(cellIds.entries())
.filter(([, c]) => c > 1)
.map(([id, count]) => `'${id}' (${count}x)`)
if (dups.length > 0) {
return `Invalid XML: Found duplicate cell ID(s) in page "${pageId}": ${dups.slice(0, 3).join(", ")}. All mxCell ids must be unique within a page.`
}
}
return null
}
}
} catch {
// fall through to regex
}
// Legacy regex-based check for bare <mxGraphModel> and parse-error cases.
const idPattern = /\bid\s*=\s*["']([^"']+)["']/gi
const ids = new Map<string, number>()
let idMatch
@@ -770,35 +836,46 @@ export function autoFixXml(xml: string): { fixed: string; fixes: string[] } {
fixes.push(`Fixed ${trueNestedFixed} true nested mxCell(s)`)
}
// 22. Fix duplicate IDs by appending suffix
const seenIds = new Map<string, number>()
const duplicateIds: string[] = []
// 22. Fix duplicate IDs by appending suffix.
// Skipped for multi-page <mxfile> documents — cell ids "0" and "1" repeat
// across pages legitimately (every page has its own <root> with id="0"/"1"
// sentinel cells). Renaming them would break drawio's parent references.
// For mxfile inputs, duplicate-id validation is page-scoped in
// checkDuplicateIds() and a true duplicate produces a hard error rather
// than a silent rename.
if (!/<mxfile[\s>]/i.test(fixed)) {
const seenIds = new Map<string, number>()
const duplicateIds: string[] = []
const idPattern = /\bid\s*=\s*["']([^"']+)["']/gi
let idMatch
while ((idMatch = idPattern.exec(fixed)) !== null) {
const id = idMatch[1]
seenIds.set(id, (seenIds.get(id) || 0) + 1)
}
const idPattern = /\bid\s*=\s*["']([^"']+)["']/gi
let idMatch
while ((idMatch = idPattern.exec(fixed)) !== null) {
const id = idMatch[1]
seenIds.set(id, (seenIds.get(id) || 0) + 1)
}
for (const [id, count] of seenIds) {
if (count > 1) duplicateIds.push(id)
}
for (const [id, count] of seenIds) {
if (count > 1) duplicateIds.push(id)
}
if (duplicateIds.length > 0) {
const idCounters = new Map<string, number>()
fixed = fixed.replace(/\bid\s*=\s*["']([^"']+)["']/gi, (match, id) => {
if (!duplicateIds.includes(id)) return match
if (duplicateIds.length > 0) {
const idCounters = new Map<string, number>()
fixed = fixed.replace(
/\bid\s*=\s*["']([^"']+)["']/gi,
(match, id) => {
if (!duplicateIds.includes(id)) return match
const count = idCounters.get(id) || 0
idCounters.set(id, count + 1)
const count = idCounters.get(id) || 0
idCounters.set(id, count + 1)
if (count === 0) return match
if (count === 0) return match
const newId = `${id}_dup${count}`
return match.replace(id, newId)
})
fixes.push(`Renamed ${duplicateIds.length} duplicate ID(s)`)
const newId = `${id}_dup${count}`
return match.replace(id, newId)
},
)
fixes.push(`Renamed ${duplicateIds.length} duplicate ID(s)`)
}
}
// 23. Fix empty id attributes

View File

@@ -0,0 +1,132 @@
/**
* Unit tests for the edit_diagram workflow gate (edit-gate.ts).
*
* The gate replaced the old 30-second wall-clock rule (#885): an edit is
* allowed when the model has seen the current browser state, no matter how
* long ago — and rejected when the browser state moved since. "Seen" is
* judged structurally, so draw.io's re-serialisation of the same content
* (attribute order, whitespace, viewport attributes, wrapper shape) never
* reads as a user edit.
*/
import { DOMParser } from "linkedom"
import { beforeAll, describe, expect, it } from "vitest"
beforeAll(() => {
;(globalThis as any).DOMParser = DOMParser
})
import { checkEditGate, contentFingerprint } from "../src/edit-gate.js"
const XML_A = `<mxfile host="app.diagrams.net"><diagram id="p1" name="Page-1"><mxGraphModel><root><mxCell id="0"/><mxCell id="1" parent="0"/><mxCell id="box1" value="Hello" style="rounded=0;" vertex="1" parent="1"><mxGeometry x="40" y="40" width="120" height="60" as="geometry"/></mxCell></root></mxGraphModel></diagram></mxfile>`
// The same document as draw.io re-serialises it on autosave: different host,
// regenerated diagram id, viewport attributes on mxGraphModel, re-ordered
// cell attributes, pretty-printed whitespace.
const XML_A_RESERIALIZED = `<mxfile host="embed.diagrams.net">
<diagram id="regenerated-id" name="Page-1">
<mxGraphModel dx="1596" dy="743" grid="1" pageWidth="827" pageHeight="1169">
<root>
<mxCell id="0" />
<mxCell id="1" parent="0" />
<mxCell id="box1" parent="1" style="rounded=0;" value="Hello" vertex="1">
<mxGeometry height="60" width="120" x="40" y="40" as="geometry" />
</mxCell>
</root>
</mxGraphModel>
</diagram>
</mxfile>`
// A real user edit: box1 moved to a different position.
const XML_B = XML_A.replace('x="40" y="40"', 'x="300" y="200"')
// Bare mxGraphModel with identical page content to XML_A.
const XML_A_BARE = `<mxGraphModel><root><mxCell id="0"/><mxCell id="1" parent="0"/><mxCell id="box1" value="Hello" style="rounded=0;" vertex="1" parent="1"><mxGeometry x="40" y="40" width="120" height="60" as="geometry"/></mxCell></root></mxGraphModel>`
describe("checkEditGate", () => {
it("rejects when no diagram context was ever established", () => {
expect(checkEditGate("", XML_A)).toEqual({
ok: false,
reason: "no-context",
})
})
it("allows when the browser state is exactly what the model saw", () => {
expect(checkEditGate(XML_A, XML_A)).toEqual({ ok: true })
})
it("allows when the browser state is a re-serialisation of the same content", () => {
expect(checkEditGate(XML_A, XML_A_RESERIALIZED)).toEqual({ ok: true })
})
it("rejects when a cell actually changed", () => {
expect(checkEditGate(XML_A, XML_B)).toEqual({
ok: false,
reason: "stale",
})
})
it("rejects a real edit even when wrapped in re-serialisation noise", () => {
const movedAndReserialized = XML_A_RESERIALIZED.replace(
'x="40" y="40"',
'x="300" y="200"',
)
expect(checkEditGate(XML_A, movedAndReserialized)).toEqual({
ok: false,
reason: "stale",
})
})
it("allows when the store has no live entry to compare against", () => {
expect(checkEditGate(XML_A, "")).toEqual({ ok: true })
})
// A bare <mxGraphModel> push carries no page name, so the gate must not
// compare the invented "Page-1" wrapper name against the real one.
it("allows a bare mxGraphModel push when the page has a custom name", () => {
const seenRenamed = XML_A.replace('name="Page-1"', 'name="Arch"')
expect(checkEditGate(seenRenamed, XML_A_BARE)).toEqual({ ok: true })
})
it("still rejects a bare mxGraphModel push whose cells changed", () => {
const seenRenamed = XML_A.replace('name="Page-1"', 'name="Arch"')
const bareMoved = XML_A_BARE.replace('x="40" y="40"', 'x="300" y="200"')
expect(checkEditGate(seenRenamed, bareMoved)).toEqual({
ok: false,
reason: "stale",
})
})
})
describe("contentFingerprint", () => {
it("is invariant under draw.io re-serialisation", () => {
expect(contentFingerprint(XML_A)).toBe(
contentFingerprint(XML_A_RESERIALIZED),
)
})
it("treats a bare mxGraphModel like its one-page mxfile wrapping", () => {
expect(contentFingerprint(XML_A_BARE)).toBe(contentFingerprint(XML_A))
})
it("changes when a cell attribute changes", () => {
expect(contentFingerprint(XML_A)).not.toBe(contentFingerprint(XML_B))
})
it("changes when a page is renamed", () => {
const renamed = XML_A.replace('name="Page-1"', 'name="Renamed"')
expect(contentFingerprint(XML_A)).not.toBe(contentFingerprint(renamed))
})
it("changes when a page is added", () => {
const twoPages = XML_A.replace(
"</mxfile>",
`<diagram id="p2" name="Page-2"><mxGraphModel><root><mxCell id="0"/><mxCell id="1" parent="0"/></root></mxGraphModel></diagram></mxfile>`,
)
expect(contentFingerprint(XML_A)).not.toBe(contentFingerprint(twoPages))
})
it("falls back to the raw string for unparseable input", () => {
expect(contentFingerprint("not xml at all")).toBe("not xml at all")
})
})

View File

@@ -0,0 +1,126 @@
/**
* Unit tests for load_diagram's file parsing (load-diagram.ts).
*
* A .drawio file stores each page's <mxGraphModel> either as plain XML or
* as draw.io's compressed default (encodeURIComponent → raw deflate →
* base64 text content). The loader must produce the canonical session
* shape: an <mxfile> whose every page is plain XML.
*/
import { deflateRawSync } from "node:zlib"
import { DOMParser } from "linkedom"
import { beforeAll, describe, expect, it } from "vitest"
// Install the DOM polyfills exactly as index.ts does at runtime.
beforeAll(() => {
;(globalThis as any).DOMParser = DOMParser
class XMLSerializerPolyfill {
serializeToString(node: any): string {
if (node.outerHTML !== undefined) return node.outerHTML
if (node.documentElement) return node.documentElement.outerHTML
return ""
}
}
;(globalThis as any).XMLSerializer = XMLSerializerPolyfill
})
import {
decompressPageContent,
parseDrawioFileContent,
} from "../src/load-diagram.js"
const MODEL_XML = `<mxGraphModel><root><mxCell id="0"/><mxCell id="1" parent="0"/><mxCell id="box1" value="Hello" style="rounded=0;" vertex="1" parent="1"><mxGeometry x="40" y="40" width="120" height="60" as="geometry"/></mxCell></root></mxGraphModel>`
/** Compress a page body exactly the way draw.io does when saving. */
function drawioCompress(xml: string): string {
return deflateRawSync(
Buffer.from(encodeURIComponent(xml), "utf-8"),
).toString("base64")
}
const PLAIN_MXFILE = `<mxfile host="app.diagrams.net"><diagram id="p1" name="Page-1">${MODEL_XML}</diagram></mxfile>`
const COMPRESSED_MXFILE = `<mxfile host="app.diagrams.net" compressed="true"><diagram id="p1" name="Page-1">${drawioCompress(MODEL_XML)}</diagram></mxfile>`
describe("decompressPageContent", () => {
it("round-trips draw.io's compressed format", () => {
expect(decompressPageContent(drawioCompress(MODEL_XML))).toBe(MODEL_XML)
})
it("handles non-URI-encoded legacy payloads", () => {
const legacy = deflateRawSync(Buffer.from(MODEL_XML, "utf-8")).toString(
"base64",
)
expect(decompressPageContent(legacy)).toBe(MODEL_XML)
})
it("returns null for garbage", () => {
expect(decompressPageContent("not base64 deflate")).toBeNull()
})
})
describe("parseDrawioFileContent", () => {
it("passes a plain-XML mxfile through unchanged", () => {
const r = parseDrawioFileContent(PLAIN_MXFILE)
expect(r).toEqual({ ok: true, xml: PLAIN_MXFILE })
})
it("wraps a bare mxGraphModel into a one-page mxfile", () => {
const r = parseDrawioFileContent(MODEL_XML)
expect(r.ok).toBe(true)
if (r.ok) {
expect(r.xml).toContain("<mxfile")
expect(r.xml).toContain('value="Hello"')
}
})
it("decompresses a compressed mxfile into plain XML pages", () => {
const r = parseDrawioFileContent(COMPRESSED_MXFILE)
expect(r.ok).toBe(true)
if (r.ok) {
expect(r.xml).toContain("<mxGraphModel")
expect(r.xml).toContain('value="Hello"')
// The compressed blob must be gone.
expect(r.xml).not.toContain(drawioCompress(MODEL_XML))
}
})
it("decompresses only the compressed pages of a mixed file", () => {
const mixed = `<mxfile><diagram id="a" name="Plain">${MODEL_XML}</diagram><diagram id="b" name="Squeezed">${drawioCompress(MODEL_XML)}</diagram></mxfile>`
const r = parseDrawioFileContent(mixed)
expect(r.ok).toBe(true)
if (r.ok) {
const doc = new DOMParser().parseFromString(r.xml, "text/xml")
const diagrams = Array.from(
doc.querySelectorAll("diagram"),
) as Element[]
expect(diagrams).toHaveLength(2)
for (const d of diagrams) {
expect(d.querySelector("mxGraphModel")).not.toBeNull()
}
}
})
it("keeps empty pages as-is", () => {
const withEmpty = `<mxfile><diagram id="a" name="Page-1">${MODEL_XML}</diagram><diagram id="b" name="Empty"></diagram></mxfile>`
const r = parseDrawioFileContent(withEmpty)
expect(r).toEqual({ ok: true, xml: withEmpty })
})
it("rejects empty files", () => {
const r = parseDrawioFileContent(" ")
expect(r.ok).toBe(false)
})
it("rejects non-drawio content", () => {
const r = parseDrawioFileContent("<svg><rect/></svg>")
expect(r.ok).toBe(false)
if (!r.ok) expect(r.error).toContain("Not a draw.io file")
})
it("rejects a page whose content is neither XML nor compressed", () => {
const bad = `<mxfile><diagram id="a" name="Broken">!!! not a diagram !!!</diagram></mxfile>`
const r = parseDrawioFileContent(bad)
expect(r.ok).toBe(false)
if (!r.ok) expect(r.error).toContain('"Broken"')
})
})

View File

@@ -0,0 +1,545 @@
/**
* Unit tests for multi-page (mxfile) support.
*
* Pinned to the user-visible contract described in
* multi-page-mcp-support-plan.md §5 (acceptance criteria):
*
* AC1. create_new_diagram accepts both bare <mxGraphModel> and full <mxfile>.
* AC2. get_diagram returns the full <mxfile> regardless of page count.
* AC3. edit_diagram accepts an optional page selector.
* AC6. Two tool calls reproduce the Transformer/CNN scenario.
* AC9. The wrapper-injection hack at http-server.ts:845 is unnecessary.
*
* These tests pin the helpers (pages.ts), the validator update
* (xml-validation.ts), and the page-targeted edit logic
* (diagram-operations.ts) — i.e. the layers underneath the MCP tool surface.
*/
import { DOMParser } from "linkedom"
import { beforeAll, describe, expect, it } from "vitest"
// Install the DOM polyfill exactly as index.ts does at runtime — the
// helpers under test rely on it.
beforeAll(() => {
;(globalThis as any).DOMParser = DOMParser
class XMLSerializerPolyfill {
serializeToString(node: any): string {
if (node.outerHTML !== undefined) return node.outerHTML
if (node.documentElement) return node.documentElement.outerHTML
return ""
}
}
;(globalThis as any).XMLSerializer = XMLSerializerPolyfill
})
import { applyDiagramOperations } from "../src/diagram-operations.js"
import {
addPageToDoc,
deletePageFromDoc,
findPageElement,
generatePageId,
hasPageSelector,
isMxFile,
isMxGraphModel,
listPagesFromDoc,
normalizeToMxfile,
parseMxfile,
projectPage,
renamePageInDoc,
serializeMxfile,
} from "../src/pages.js"
import { validateAndFixXml } from "../src/xml-validation.js"
const BARE_MODEL_ONE_CELL = `<mxGraphModel><root><mxCell id="0"/><mxCell id="1" parent="0"/><mxCell id="2" vertex="1" parent="1" value="Hello"><mxGeometry x="40" y="40" width="100" height="40" as="geometry"/></mxCell></root></mxGraphModel>`
const TWO_PAGE_MXFILE = `<mxfile host="app.diagrams.net"><diagram id="page-transformer" name="Transformer"><mxGraphModel><root><mxCell id="0"/><mxCell id="1" parent="0"/><mxCell id="2" vertex="1" parent="1" value="Encoder"><mxGeometry x="40" y="40" width="120" height="60" as="geometry"/></mxCell></root></mxGraphModel></diagram><diagram id="page-cnn" name="CNN"><mxGraphModel><root><mxCell id="0"/><mxCell id="1" parent="0"/><mxCell id="2" vertex="1" parent="1" value="Conv1"><mxGeometry x="40" y="40" width="120" height="60" as="geometry"/></mxCell></root></mxGraphModel></diagram></mxfile>`
describe("pages.ts — shape detection", () => {
it("isMxFile detects a multi-page mxfile", () => {
expect(isMxFile(TWO_PAGE_MXFILE)).toBe(true)
})
it("isMxFile rejects a bare mxGraphModel", () => {
expect(isMxFile(BARE_MODEL_ONE_CELL)).toBe(false)
})
it("isMxGraphModel detects a bare model", () => {
expect(isMxGraphModel(BARE_MODEL_ONE_CELL)).toBe(true)
expect(isMxGraphModel(TWO_PAGE_MXFILE)).toBe(false)
})
it("isMxFile tolerates an XML declaration prefix", () => {
expect(
isMxFile(
`<?xml version="1.0" encoding="UTF-8"?>${TWO_PAGE_MXFILE}`,
),
).toBe(true)
})
})
describe("pages.ts — normalizeToMxfile (backward compatibility, AC1)", () => {
it("wraps a bare mxGraphModel into a single-page mxfile", () => {
const out = normalizeToMxfile(BARE_MODEL_ONE_CELL, {
pageId: "p1",
pageName: "Page-1",
})
expect(out).not.toBeNull()
expect(out).toMatch(/^<mxfile/)
expect(out).toContain(`<diagram id="p1" name="Page-1">`)
expect(out).toContain("<mxGraphModel>")
})
it("returns mxfile inputs unchanged", () => {
const out = normalizeToMxfile(TWO_PAGE_MXFILE)
expect(out).toBe(TWO_PAGE_MXFILE)
})
it("returns null for neither shape", () => {
expect(normalizeToMxfile("<random/>")).toBeNull()
expect(normalizeToMxfile("")).toBeNull()
})
it("generated page ids look reasonable", () => {
for (let i = 0; i < 50; i++) {
const id = generatePageId()
expect(id).toMatch(/^[a-z0-9]+-[a-z0-9]+$/)
}
})
it("strips a leading <?xml ?> declaration when wrapping a bare model", () => {
// Regression for the bug Copilot caught: isMxGraphModel tolerates a
// declaration prefix, but the wrapper used to embed it inside
// <diagram>, producing invalid XML (<?xml ?> is only valid at the
// document start). The result must round-trip through parseMxfile
// and the declaration must be gone from inside <diagram>.
const withDecl = `<?xml version="1.0" encoding="UTF-8"?>${BARE_MODEL_ONE_CELL}`
const out = normalizeToMxfile(withDecl, {
pageId: "p1",
pageName: "Page-1",
})
expect(out).not.toBeNull()
expect(out).toMatch(/^<mxfile/)
// No <?xml inside the body of the wrapped document.
expect(out!.indexOf("<?xml")).toBe(-1)
// And it must still parse cleanly.
const doc = parseMxfile(out!)
expect(doc).not.toBeNull()
expect(listPagesFromDoc(doc!)).toHaveLength(1)
})
})
describe("pages.ts — addPageToDoc input validation", () => {
it("rejects opts.xml shaped as a full <mxfile>", () => {
// Regression for the Copilot-flagged bug: an mxfile passed as
// starting page xml would end up nested inside <diagram>, corrupting
// the document. Must throw with a clear message.
const doc = parseMxfile(TWO_PAGE_MXFILE)!
expect(() =>
addPageToDoc(doc, { name: "Bad", xml: TWO_PAGE_MXFILE }),
).toThrowError(/bare <mxGraphModel>/i)
})
it("rejects opts.xml that is neither mxGraphModel nor mxfile", () => {
const doc = parseMxfile(TWO_PAGE_MXFILE)!
expect(() =>
addPageToDoc(doc, { name: "Junk", xml: "<root><x/></root>" }),
).toThrowError(/bare <mxGraphModel>/i)
})
it("strips a <?xml ?> declaration prefix on opts.xml", () => {
const doc = parseMxfile(TWO_PAGE_MXFILE)!
const withDecl = `<?xml version="1.0"?>${BARE_MODEL_ONE_CELL}`
const info = addPageToDoc(doc, { name: "Sequence", xml: withDecl })
expect(info.cellCount).toBeGreaterThanOrEqual(3)
// Serialised document must not have <?xml ?> inside <diagram>.
const out = serializeMxfile(doc)
// The mxfile may have one <?xml ?> at the very start (the doc decl),
// but no further occurrence inside <diagram>.
const matches = out.match(/<\?xml/g) || []
expect(matches.length).toBeLessThanOrEqual(1)
})
})
describe("pages.ts — listPagesFromDoc / findPageElement", () => {
it("lists both pages in a two-page mxfile", () => {
const doc = parseMxfile(TWO_PAGE_MXFILE)!
const pages = listPagesFromDoc(doc)
expect(pages).toHaveLength(2)
expect(pages[0]).toMatchObject({
id: "page-transformer",
name: "Transformer",
index: 0,
})
expect(pages[1]).toMatchObject({
id: "page-cnn",
name: "CNN",
index: 1,
})
// Cell count is per-page (3 cells per page including the two root sentinels).
expect(pages[0].cellCount).toBe(3)
expect(pages[1].cellCount).toBe(3)
})
it("findPageElement defaults to the first page when selector is empty", () => {
const doc = parseMxfile(TWO_PAGE_MXFILE)!
const found = findPageElement(doc)
expect(found?.index).toBe(0)
expect(found?.element.getAttribute("id")).toBe("page-transformer")
})
it("findPageElement matches by id, name, and index — id wins when several are set", () => {
const doc = parseMxfile(TWO_PAGE_MXFILE)!
expect(findPageElement(doc, { page_id: "page-cnn" })?.index).toBe(1)
expect(findPageElement(doc, { page_name: "CNN" })?.index).toBe(1)
expect(findPageElement(doc, { page_index: 1 })?.index).toBe(1)
// id beats name beats index
const winner = findPageElement(doc, {
page_id: "page-cnn",
page_name: "Transformer",
page_index: 0,
})
expect(winner?.index).toBe(1)
})
it("findPageElement returns null for an unknown selector", () => {
const doc = parseMxfile(TWO_PAGE_MXFILE)!
expect(findPageElement(doc, { page_id: "ghost" })).toBeNull()
expect(findPageElement(doc, { page_name: "ghost" })).toBeNull()
expect(findPageElement(doc, { page_index: 99 })).toBeNull()
expect(findPageElement(doc, { page_index: -1 })).toBeNull()
})
it("hasPageSelector correctly detects empty vs populated selectors", () => {
expect(hasPageSelector()).toBe(false)
expect(hasPageSelector({})).toBe(false)
expect(hasPageSelector({ page_id: "x" })).toBe(true)
expect(hasPageSelector({ page_index: 0 })).toBe(true)
})
})
describe("pages.ts — addPageToDoc", () => {
it("appends a third page and returns its info", () => {
const doc = parseMxfile(TWO_PAGE_MXFILE)!
const info = addPageToDoc(doc, { name: "Sequence" })
expect(info.name).toBe("Sequence")
expect(info.index).toBe(2)
expect(info.id).toMatch(/.+/)
const pages = listPagesFromDoc(doc)
expect(pages).toHaveLength(3)
expect(pages[2].name).toBe("Sequence")
})
it("rejects a duplicate explicit id", () => {
const doc = parseMxfile(TWO_PAGE_MXFILE)!
expect(() =>
addPageToDoc(doc, { id: "page-transformer", name: "X" }),
).toThrowError(/already exists/)
})
it("uses a sensible default name when none is supplied", () => {
const doc = parseMxfile(TWO_PAGE_MXFILE)!
const info = addPageToDoc(doc, {})
expect(info.name).toBe("Page-3")
})
it("accepts an inline starting mxGraphModel", () => {
const doc = parseMxfile(TWO_PAGE_MXFILE)!
const inner = `<mxGraphModel><root><mxCell id="0"/><mxCell id="1" parent="0"/><mxCell id="2" vertex="1" parent="1" value="A"><mxGeometry x="10" y="10" width="20" height="20" as="geometry"/></mxCell></root></mxGraphModel>`
const info = addPageToDoc(doc, { name: "Custom", xml: inner })
expect(info.cellCount).toBeGreaterThanOrEqual(3)
})
})
describe("pages.ts — renamePageInDoc / deletePageFromDoc", () => {
it("renames an existing page by name", () => {
const doc = parseMxfile(TWO_PAGE_MXFILE)!
const ok = renamePageInDoc(doc, { page_name: "CNN" }, "CNN-v2")
expect(ok).toBe(true)
const pages = listPagesFromDoc(doc)
expect(pages[1].name).toBe("CNN-v2")
})
it("rename returns false when target page is missing", () => {
const doc = parseMxfile(TWO_PAGE_MXFILE)!
expect(renamePageInDoc(doc, { page_id: "ghost" }, "Z")).toBe(false)
})
it("deletes a page and removes the <diagram> element from the doc", () => {
const doc = parseMxfile(TWO_PAGE_MXFILE)!
const outcome = deletePageFromDoc(doc, { page_id: "page-cnn" })
expect(outcome.ok).toBe(true)
expect(outcome.deletedId).toBe("page-cnn")
expect(listPagesFromDoc(doc)).toHaveLength(1)
})
it("refuses to delete the only remaining page", () => {
// Build a single-page doc to test the guard.
const single = normalizeToMxfile(BARE_MODEL_ONE_CELL)!
const doc = parseMxfile(single)!
const outcome = deletePageFromDoc(doc, { page_index: 0 })
expect(outcome.ok).toBe(false)
expect(outcome.reason).toMatch(/only remaining page/)
})
})
describe("xml-validation.ts — multi-page support", () => {
it("accepts a valid two-page mxfile (the exact payload that used to fail)", () => {
const result = validateAndFixXml(TWO_PAGE_MXFILE)
expect(result.valid).toBe(true)
expect(result.error).toBeNull()
})
it("does NOT flag root sentinel ids 0 and 1 repeating across pages", () => {
// This is the regression the planning doc explicitly called out:
// before this work, the legacy regex-based duplicate-id check rejected
// any multi-page document because cells "0" and "1" appear in every page.
const result = validateAndFixXml(TWO_PAGE_MXFILE)
expect(result.valid).toBe(true)
})
it("rejects duplicate cell ids WITHIN a single page", () => {
const bad = `<mxfile host="app.diagrams.net"><diagram id="p1" name="P1"><mxGraphModel><root><mxCell id="0"/><mxCell id="1" parent="0"/><mxCell id="dup" vertex="1" parent="1"/><mxCell id="dup" vertex="1" parent="1"/></root></mxGraphModel></diagram></mxfile>`
const result = validateAndFixXml(bad)
expect(result.valid).toBe(false)
expect(result.error).toMatch(/duplicate cell ID/i)
})
it("rejects duplicate <diagram> ids across the file", () => {
const bad = `<mxfile host="app.diagrams.net"><diagram id="p1" name="A"><mxGraphModel><root><mxCell id="0"/><mxCell id="1" parent="0"/></root></mxGraphModel></diagram><diagram id="p1" name="B"><mxGraphModel><root><mxCell id="0"/><mxCell id="1" parent="0"/></root></mxGraphModel></diagram></mxfile>`
const result = validateAndFixXml(bad)
expect(result.valid).toBe(false)
expect(result.error).toMatch(/duplicate <diagram> id/i)
})
it("still validates a bare <mxGraphModel> (legacy callers)", () => {
const result = validateAndFixXml(BARE_MODEL_ONE_CELL)
expect(result.valid).toBe(true)
})
it("auto-fix does NOT rename mxfile root cells 0/1 (would break drawio refs)", () => {
// Build a doc that triggers some other auto-fix (so autoFixXml runs)
// but contains valid multi-page 0/1 cells that must NOT be renamed.
const malformedButMultiPage = `<mxfile host="app.diagrams.net"><diagram id="p1" name="A"><mxGraphModel><root><mxCell id="0"/><mxCell id="1" parent="0"/><mxCell id="2" vertex="1" parent="1" value="Q & A"><mxGeometry x="0" y="0" width="10" height="10" as="geometry"/></mxCell></root></mxGraphModel></diagram><diagram id="p2" name="B"><mxGraphModel><root><mxCell id="0"/><mxCell id="1" parent="0"/></root></mxGraphModel></diagram></mxfile>`
const result = validateAndFixXml(malformedButMultiPage)
// The doc has an unescaped & — autoFix will repair that. After repair
// it should be valid AND must not have renamed the 0/1 cells.
const finalXml = result.fixed || malformedButMultiPage
expect(finalXml).not.toMatch(/id="0_dup/)
expect(finalXml).not.toMatch(/id="1_dup/)
})
})
describe("diagram-operations.ts — page-targeted edits (AC3)", () => {
it("adds a cell to the targeted page by id, leaving the other page untouched", () => {
const { result, errors } = applyDiagramOperations(
TWO_PAGE_MXFILE,
[
{
operation: "add",
cell_id: "conv-2",
new_xml: `<mxCell id="conv-2" vertex="1" parent="1" value="Conv2"><mxGeometry x="200" y="40" width="120" height="60" as="geometry"/></mxCell>`,
},
],
{ page_id: "page-cnn" },
)
expect(errors).toHaveLength(0)
const doc = parseMxfile(result)!
const pages = listPagesFromDoc(doc)
// Transformer untouched (still 3 cells), CNN gained one cell.
expect(pages[0].cellCount).toBe(3)
expect(pages[1].cellCount).toBe(4)
expect(result).toContain(`id="conv-2"`)
})
it("defaults to the first page when no selector is given", () => {
const { result, errors } = applyDiagramOperations(TWO_PAGE_MXFILE, [
{
operation: "add",
cell_id: "shape-x",
new_xml: `<mxCell id="shape-x" vertex="1" parent="1"><mxGeometry x="0" y="0" width="10" height="10" as="geometry"/></mxCell>`,
},
])
expect(errors).toHaveLength(0)
const doc = parseMxfile(result)!
const pages = listPagesFromDoc(doc)
expect(pages[0].cellCount).toBe(4) // Transformer (first page) grew
expect(pages[1].cellCount).toBe(3) // CNN untouched
})
it("errors clearly when the page is not found", () => {
const { errors } = applyDiagramOperations(
TWO_PAGE_MXFILE,
[
{
operation: "delete",
cell_id: "2",
},
],
{ page_id: "does-not-exist" },
)
expect(errors).toHaveLength(1)
expect(errors[0].message).toMatch(/Page.*not found/i)
// Page-level errors carry an empty cellId — edit_diagram relies on
// this to distinguish "nothing applied" from per-cell warnings and
// return a hard error instead of a false success.
expect(errors[0].cellId).toBe("")
})
it("delete on page 2 does NOT touch page 1's mxCell with the same id", () => {
// Both pages have a cell with id="2". A delete on CNN's "2" must not
// remove Transformer's "2".
const { result, errors } = applyDiagramOperations(
TWO_PAGE_MXFILE,
[{ operation: "delete", cell_id: "2" }],
{ page_id: "page-cnn" },
)
expect(errors).toHaveLength(0)
const doc = parseMxfile(result)!
const pages = listPagesFromDoc(doc)
// CNN lost its only non-sentinel cell, Transformer keeps its three.
expect(pages[1].cellCount).toBe(2)
expect(pages[0].cellCount).toBe(3)
})
it("legacy bare-mxGraphModel input still works when no selector is given", () => {
const { result, errors } = applyDiagramOperations(BARE_MODEL_ONE_CELL, [
{
operation: "add",
cell_id: "new",
new_xml: `<mxCell id="new" vertex="1" parent="1"><mxGeometry x="100" y="100" width="50" height="50" as="geometry"/></mxCell>`,
},
])
expect(errors).toHaveLength(0)
expect(result).toContain(`id="new"`)
})
it("page selector on a bare mxGraphModel returns a clear error", () => {
const { errors } = applyDiagramOperations(
BARE_MODEL_ONE_CELL,
[{ operation: "delete", cell_id: "2" }],
{ page_id: "page-1" },
)
expect(errors).toHaveLength(1)
expect(errors[0].message).toMatch(/not multi-page/i)
})
})
describe("export_diagram — single-page projection (regression for selectPage bug)", () => {
// The previous implementation tried to drive drawio's iframe with an
// `action: 'selectPage'` postMessage, which the embed protocol silently
// ignores. The result was that PNG/SVG exports targeted the currently
// active tab regardless of the page selector — two visually different
// pages would yield byte-identical PNGs.
//
// The current implementation builds a single-page <mxfile> projection via
// the shared pages.ts:projectPage helper and hands it to the browser
// bridge to load BEFORE triggering export. These tests pin that helper so
// a future refactor can't silently re-introduce the multi-page drift.
function projectSinglePage(fullMxfile: string, sel: any): string {
const result = projectPage(fullMxfile, sel)
if (!result.ok) throw new Error(`projection failed: ${result.reason}`)
return result.xml
}
it("returns a parse error for a non-mxfile source", () => {
const result = projectPage(BARE_MODEL_ONE_CELL, { page_id: "x" })
expect(result.ok).toBe(false)
if (!result.ok) expect(result.reason).toBe("parse")
})
it("returns a notfound error for an unknown page", () => {
const result = projectPage(TWO_PAGE_MXFILE, { page_id: "ghost" })
expect(result.ok).toBe(false)
if (!result.ok) expect(result.reason).toBe("notfound")
})
it("projects only the requested page when targeted by id", () => {
const projected = projectSinglePage(TWO_PAGE_MXFILE, {
page_id: "page-cnn",
})
const pages = listPagesFromDoc(parseMxfile(projected)!)
expect(pages).toHaveLength(1)
expect(pages[0].id).toBe("page-cnn")
expect(pages[0].name).toBe("CNN")
// The projection must NOT contain the Transformer page anywhere.
expect(projected).not.toContain('id="page-transformer"')
expect(projected).not.toContain('name="Transformer"')
})
it("projects only the requested page when targeted by name", () => {
const projected = projectSinglePage(TWO_PAGE_MXFILE, {
page_name: "Transformer",
})
const pages = listPagesFromDoc(parseMxfile(projected)!)
expect(pages).toHaveLength(1)
expect(pages[0].name).toBe("Transformer")
expect(projected).not.toContain('id="page-cnn"')
})
it("projects only the requested page when targeted by index", () => {
const projected = projectSinglePage(TWO_PAGE_MXFILE, {
page_index: 1,
})
const pages = listPagesFromDoc(parseMxfile(projected)!)
expect(pages).toHaveLength(1)
expect(pages[0].index).toBe(0) // re-indexed: it's the only page in the projection
expect(pages[0].id).toBe("page-cnn")
})
it("two different page selectors produce visually distinct projections", () => {
// The regression: under the old selectPage bug, two exports would
// return the same active tab. With the projection approach, the
// payload that drawio renders is provably different.
const a = projectSinglePage(TWO_PAGE_MXFILE, {
page_id: "page-transformer",
})
const b = projectSinglePage(TWO_PAGE_MXFILE, { page_id: "page-cnn" })
expect(a).not.toBe(b)
expect(a).toContain('"Encoder"')
expect(a).not.toContain('"Conv1"')
expect(b).toContain('"Conv1"')
expect(b).not.toContain('"Encoder"')
})
it("the projection parses to a valid one-page mxfile", () => {
const projected = projectSinglePage(TWO_PAGE_MXFILE, {
page_id: "page-cnn",
})
// Validator accepts it.
expect(validateAndFixXml(projected).valid).toBe(true)
// And it has a real <root> with the cells from the source page.
const doc = parseMxfile(projected)!
const root = doc.querySelector("root")
expect(root).not.toBeNull()
const conv1 = doc.querySelector('mxCell[value="Conv1"]')
expect(conv1).not.toBeNull()
})
})
describe("end-to-end — Transformer + CNN scenario (AC6)", () => {
it("two tool-equivalent steps reproduce the motivating user scenario", () => {
// Step 1 — caller passes a single-page mxfile.
const step1 = normalizeToMxfile(BARE_MODEL_ONE_CELL, {
pageId: "page-transformer",
pageName: "Transformer",
})
expect(step1).not.toBeNull()
let xml = step1 as string
const validate1 = validateAndFixXml(xml)
expect(validate1.valid).toBe(true)
// Step 2 — equivalent of add_page("CNN") with a starting model.
const doc = parseMxfile(xml)!
addPageToDoc(doc, {
id: "page-cnn",
name: "CNN",
xml: `<mxGraphModel><root><mxCell id="0"/><mxCell id="1" parent="0"/><mxCell id="2" vertex="1" parent="1" value="Conv1"><mxGeometry x="40" y="40" width="120" height="60" as="geometry"/></mxCell></root></mxGraphModel>`,
})
xml = serializeMxfile(doc)
// Now: two pages, both valid, with the right names.
const pages = listPagesFromDoc(parseMxfile(xml)!)
expect(pages.map((p) => p.name)).toEqual(["Transformer", "CNN"])
expect(validateAndFixXml(xml).valid).toBe(true)
})
})

View File

@@ -0,0 +1,142 @@
/**
* Server-wiring test: boot the actual MCP stdio server (from source via tsx)
* and drive it the way a real MCP client does — initialize handshake,
* tools/list — to catch registration/schema regressions that the unit tests
* (which import helpers directly) can't see.
*
* This replaces the old standalone tests/smoke.mjs, which spawned the BUILT
* dist/index.js and was therefore never run in CI (CI doesn't build this
* package before testing). Running from source via tsx means it executes as
* part of the normal `vitest run`.
*
* We deliberately do NOT call start_session — it would open a real browser
* window via open(). The browser bridge is covered by the Playwright e2e suite.
*/
import { type ChildProcessWithoutNullStreams, spawn } from "node:child_process"
import path from "node:path"
import { fileURLToPath } from "node:url"
import { afterAll, beforeAll, describe, expect, it } from "vitest"
const __dirname = path.dirname(fileURLToPath(import.meta.url))
const entry = path.resolve(__dirname, "..", "src", "index.ts")
const tsxBin = path.resolve(
__dirname,
"..",
"node_modules",
".bin",
process.platform === "win32" ? "tsx.cmd" : "tsx",
)
const EXPECTED_TOOLS = [
"start_session",
"create_new_diagram",
"load_diagram",
"edit_diagram",
"get_diagram",
"export_diagram",
"list_pages",
"add_page",
"rename_page",
"delete_page",
]
let proc: ChildProcessWithoutNullStreams
let stdoutBuf = ""
const pending = new Map<
number,
{ resolve: (m: any) => void; reject: (e: Error) => void; timeout: any }
>()
let nextId = 1
function send(method: string, params: unknown, isNotification = false) {
const msg: Record<string, unknown> = { jsonrpc: "2.0", method, params }
if (!isNotification) msg.id = nextId++
proc.stdin.write(`${JSON.stringify(msg)}\n`)
if (isNotification) return Promise.resolve(undefined)
return new Promise<any>((resolve, reject) => {
const id = msg.id as number
const timeout = setTimeout(() => {
pending.delete(id)
reject(new Error(`Timed out waiting for response to ${method}`))
}, 15000)
pending.set(id, { resolve, reject, timeout })
})
}
beforeAll(async () => {
proc = spawn(tsxBin, [entry], {
stdio: ["pipe", "pipe", "pipe"],
}) as ChildProcessWithoutNullStreams
proc.stdout.on("data", (chunk: Buffer) => {
stdoutBuf += chunk.toString()
const lines = stdoutBuf.split("\n")
stdoutBuf = lines.pop() || ""
for (const line of lines) {
const trimmed = line.trim()
if (!trimmed) continue
let msg: any
try {
msg = JSON.parse(trimmed)
} catch {
// Non-JSON-RPC log line — ignore.
continue
}
const p = msg.id !== undefined ? pending.get(msg.id) : undefined
if (p) {
clearTimeout(p.timeout)
pending.delete(msg.id)
p.resolve(msg)
}
}
})
const initResp = await send("initialize", {
protocolVersion: "2024-11-05",
capabilities: {},
clientInfo: { name: "wiring-test", version: "0.0.0" },
})
expect(initResp.error, JSON.stringify(initResp.error)).toBeUndefined()
expect(initResp.result?.serverInfo?.name).toBeTruthy()
await send("notifications/initialized", {}, true)
}, 30000)
afterAll(() => {
proc?.kill("SIGTERM")
})
describe("MCP server wiring", () => {
it("registers all nine multi-page tools", async () => {
const resp = await send("tools/list", {})
expect(resp.error, JSON.stringify(resp.error)).toBeUndefined()
const names: string[] = (resp.result?.tools ?? []).map(
(t: { name: string }) => t.name,
)
for (const expected of EXPECTED_TOOLS) {
expect(names, `missing tool: ${expected}`).toContain(expected)
}
})
it("advertises page-selector params on edit_diagram", async () => {
const resp = await send("tools/list", {})
const edit = resp.result.tools.find(
(t: { name: string }) => t.name === "edit_diagram",
)
const props = edit?.inputSchema?.properties ?? {}
expect(props.page_id).toBeTruthy()
expect(props.page_name).toBeTruthy()
expect(props.page_index).toBeTruthy()
})
it("advertises name/id/xml on add_page", async () => {
const resp = await send("tools/list", {})
const addPage = resp.result.tools.find(
(t: { name: string }) => t.name === "add_page",
)
const props = addPage?.inputSchema?.properties ?? {}
expect(props.name).toBeTruthy()
expect(props.id).toBeTruthy()
expect(props.xml).toBeTruthy()
})
})

View File

@@ -0,0 +1,11 @@
import { defineConfig } from "vitest/config"
export default defineConfig({
test: {
include: ["tests/**/*.test.ts"],
environment: "node",
// The package source uses Node16 module resolution with explicit .js
// extensions in imports. Vitest+esbuild handles the .ts→.js mapping
// transparently, so no extra alias config is needed.
},
})

View File

@@ -0,0 +1,12 @@
<svg width="163" height="26" viewBox="0 0 163 26" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M32.9477 25.7973C30.9997 25.7973 29.4796 25.2568 28.3986 24.1871C27.3176 23.1174 26.7771 21.6085 26.7771 19.6492V12.0148H23.8157V8.28764H24.131C24.9755 8.28764 25.6286 8.06243 26.0902 7.62328C26.5407 7.17287 26.7771 6.53104 26.7771 5.68652V4.34655H30.9772V8.28764H34.9521V12.0148H30.9772V19.4353C30.9772 20.0095 31.0785 20.4937 31.2812 20.8991C31.4839 21.3045 31.7992 21.6085 32.2383 21.8224C32.6775 22.0364 33.2292 22.1377 33.9049 22.1377C34.0512 22.1377 34.2314 22.1377 34.4341 22.104C34.6368 22.0814 34.8282 22.0589 35.0196 22.0364V25.6059C34.7269 25.6509 34.3778 25.696 34.0062 25.7297C33.6233 25.7748 33.2743 25.7973 32.959 25.7973H32.9477Z" fill="#FAF7F8"/>
<path d="M36.5734 25.6059V1.52026H40.7734V25.6059H36.5734Z" fill="#FAF7F8"/>
<path d="M48.284 25.9887C47.0792 25.9887 46.0207 25.7861 45.1199 25.3807C44.219 24.9753 43.5209 24.401 43.0367 23.6466C42.5525 22.8922 42.3048 22.0139 42.3048 21.023C42.3048 20.0321 42.5188 19.2101 42.9579 18.4556C43.3971 17.7012 44.0501 17.0706 44.951 16.5639C45.8405 16.0572 46.9665 15.6969 48.329 15.4829L53.9592 14.5596V17.7462L49.1173 18.602C48.2953 18.7484 47.6759 19.0074 47.2706 19.379C46.8652 19.7506 46.6625 20.246 46.6625 20.8541C46.6625 21.4621 46.8877 21.9238 47.3494 22.2729C47.7998 22.6219 48.3741 22.8021 49.0497 22.8021C49.9167 22.8021 50.6937 22.6219 51.358 22.2503C52.0224 21.8787 52.5404 21.3608 52.9007 20.7077C53.261 20.0546 53.4412 19.3339 53.4412 18.5795V14.0867C53.4412 13.3435 53.1597 12.7242 52.5854 12.2287C52.0111 11.7333 51.2454 11.4855 50.2996 11.4855C49.41 11.4855 48.6218 11.7333 47.9237 12.2175C47.2368 12.7017 46.7301 13.3322 46.4148 14.0979L43.0142 12.4427C43.352 11.5306 43.8925 10.7424 44.6244 10.0668C45.3563 9.39114 46.2234 8.87317 47.2143 8.49032C48.2164 8.10748 49.2974 7.91605 50.4572 7.91605C51.876 7.91605 53.1259 8.17504 54.2069 8.69301C55.2879 9.21098 56.1324 9.94289 56.7404 10.8775C57.3485 11.8121 57.6525 12.8818 57.6525 14.0867V25.6059H53.7114V22.6444L54.601 22.6107C54.1506 23.3313 53.6214 23.9506 52.9908 24.4573C52.3602 24.9641 51.6621 25.3469 50.8851 25.6059C50.1082 25.8649 49.2411 25.9887 48.2953 25.9887H48.284Z" fill="#FAF7F8"/>
<path d="M66.4804 25.9887C64.6337 25.9887 63.0235 25.5496 61.661 24.6713C60.2872 23.793 59.3414 22.5994 58.8121 21.0905L61.965 19.5929C62.4154 20.5726 63.0347 21.3383 63.8229 21.89C64.6224 22.4418 65.5007 22.712 66.4804 22.712C67.2235 22.712 67.8203 22.5431 68.2595 22.2053C68.7099 21.8675 68.9238 21.4171 68.9238 20.8653C68.9238 20.5275 68.8338 20.246 68.6536 20.0208C68.4734 19.7956 68.237 19.6042 67.9329 19.4465C67.6402 19.2889 67.2911 19.1538 66.9195 19.0524L64.0819 18.253C62.6406 17.8476 61.5371 17.2058 60.7827 16.3275C60.0282 15.4492 59.6566 14.4132 59.6566 13.2196C59.6566 12.1612 59.9269 11.2266 60.4674 10.4383C61.0079 9.65013 61.7623 9.01955 62.7307 8.58041C63.6991 8.13 64.8026 7.91605 66.0525 7.91605C67.6852 7.91605 69.1265 8.31016 70.3764 9.09838C71.6263 9.88659 72.5159 10.9901 73.0451 12.4089L69.8584 13.9065C69.5657 13.1183 69.059 12.499 68.3608 12.0486C67.6627 11.5982 66.8745 11.3617 66.0074 11.3617C65.3093 11.3617 64.7575 11.5193 64.3522 11.8234C63.9468 12.1274 63.7441 12.5553 63.7441 13.0845C63.7441 13.3773 63.8342 13.6475 64.0031 13.884C64.172 14.1204 64.4085 14.3119 64.7238 14.4583C65.0278 14.6046 65.3881 14.7398 65.7935 14.8749L68.5635 15.6969C69.9823 16.1248 71.0858 16.7553 71.8628 17.6111C72.6397 18.4556 73.0226 19.5028 73.0226 20.7302C73.0226 21.7661 72.7411 22.6895 72.2006 23.4777C71.6488 24.2772 70.8831 24.8965 69.9147 25.3356C68.9351 25.7861 67.7978 26 66.4804 26V25.9887Z" fill="#FAF7F8"/>
<path d="M90.2283 25.9889C88.528 25.9889 86.9628 25.6849 85.5215 25.0656C84.0802 24.4463 82.8303 23.5905 81.7718 22.487C80.7134 21.3835 79.8801 20.0886 79.2721 18.6022C78.664 17.1159 78.36 15.4944 78.36 13.7378C78.36 11.9812 78.6527 10.3484 79.2495 8.85083C79.8463 7.35322 80.6796 6.05829 81.7493 4.96605C82.819 3.8738 84.0689 3.02929 85.499 2.42123C86.929 1.81318 88.5055 1.50915 90.2283 1.50915C91.9511 1.50915 93.4487 1.79066 94.8 2.36493C96.1512 2.9392 97.2885 3.69364 98.2231 4.6395C99.1577 5.58536 99.822 6.6213 100.227 7.74733L96.3426 9.59401C95.8922 8.38916 95.149 7.39826 94.0793 6.6213C93.0208 5.84435 91.7372 5.4615 90.2283 5.4615C88.7194 5.4615 87.4357 5.81057 86.2985 6.5087C85.1612 7.20684 84.2829 8.17522 83.6523 9.40258C83.0217 10.63 82.7177 12.0713 82.7177 13.7265C82.7177 15.3818 83.033 16.8343 83.6523 18.073C84.2829 19.3116 85.1612 20.28 86.2985 20.9894C87.4357 21.6875 88.7419 22.0366 90.2283 22.0366C91.7146 22.0366 93.0208 21.6537 94.0793 20.8768C95.1378 20.0998 95.8922 19.1202 96.3426 17.9378L100.227 19.7507C99.822 20.8768 99.1577 21.9127 98.2231 22.8586C97.2885 23.8044 96.1512 24.5589 94.8 25.1331C93.4487 25.7074 91.9286 25.9889 90.2283 25.9889Z" fill="#FAF7F8"/>
<path d="M101.748 25.6059V1.52026H105.948V25.6059H101.748Z" fill="#FAF7F8"/>
<path d="M116.645 25.9884C114.967 25.9884 113.436 25.5943 112.051 24.806C110.666 24.0178 109.551 22.9481 108.729 21.5969C107.907 20.2344 107.49 18.6917 107.49 16.9464C107.49 15.2011 107.907 13.6584 108.729 12.2959C109.551 10.9334 110.655 9.86369 112.04 9.08674C113.413 8.29852 114.956 7.90441 116.656 7.90441C118.357 7.90441 119.922 8.29852 121.307 9.08674C122.681 9.87495 123.784 10.9334 124.606 12.2846C125.417 13.6359 125.833 15.1898 125.833 16.9464C125.833 18.703 125.417 20.2344 124.595 21.5969C123.773 22.9594 122.669 24.0291 121.284 24.806C119.911 25.5943 118.368 25.9884 116.668 25.9884H116.645ZM116.645 22.1711C117.602 22.1711 118.435 21.9459 119.145 21.5068C119.854 21.0564 120.417 20.4371 120.834 19.6488C121.25 18.8494 121.453 17.9598 121.453 16.9576C121.453 15.9555 121.25 15.0659 120.834 14.289C120.417 13.5008 119.854 12.8927 119.145 12.4423C118.435 11.9919 117.602 11.7779 116.645 11.7779C115.688 11.7779 114.888 12.0031 114.168 12.4423C113.447 12.8927 112.884 13.5008 112.467 14.289C112.051 15.0772 111.848 15.9667 111.848 16.9576C111.848 17.9485 112.051 18.8494 112.467 19.6488C112.884 20.4483 113.447 21.0676 114.168 21.5068C114.888 21.9572 115.722 22.1711 116.645 22.1711Z" fill="#FAF7F8"/>
<path d="M133.535 25.9883C132.173 25.9883 131.013 25.6956 130.033 25.0988C129.054 24.502 128.299 23.68 127.77 22.6215C127.241 21.5631 126.97 20.3244 126.97 18.8944V8.2985H131.171V18.5453C131.171 19.266 131.317 19.8965 131.598 20.437C131.88 20.9775 132.297 21.4054 132.837 21.7094C133.378 22.0135 133.986 22.1711 134.672 22.1711C135.359 22.1711 135.956 22.0135 136.485 21.7094C137.015 21.4054 137.431 20.9775 137.724 20.4258C138.017 19.874 138.174 19.2209 138.174 18.4552V8.2985H142.341V25.6055H138.4V22.2049L138.715 22.8129C138.31 23.8714 137.656 24.6709 136.744 25.2001C135.832 25.7294 134.763 25.9996 133.535 25.9996V25.9883Z" fill="#FAF7F8"/>
<path d="M152.7 25.9888C151.022 25.9888 149.525 25.5947 148.196 24.7952C146.867 23.9957 145.82 22.9147 145.066 21.5297C144.3 20.156 143.917 18.6246 143.917 16.9468C143.917 15.269 144.3 13.7264 145.077 12.3639C145.854 11.0014 146.901 9.9204 148.207 9.12093C149.525 8.31019 151.011 7.91608 152.666 7.91608C153.984 7.91608 155.155 8.17506 156.179 8.69304C157.204 9.21101 158.015 9.94292 158.612 10.8775L157.97 11.7333V1.52026H162.136V25.6059H158.195V22.2616L158.645 23.0836C158.049 24.0407 157.227 24.7614 156.168 25.2456C155.11 25.7298 153.95 25.9775 152.7 25.9775V25.9888ZM153.139 22.1715C154.074 22.1715 154.907 21.9463 155.639 21.5072C156.371 21.0568 156.945 20.4487 157.362 19.6605C157.778 18.8723 157.981 17.9715 157.981 16.9581C157.981 15.9446 157.778 15.0663 157.362 14.2894C156.945 13.5012 156.371 12.8931 155.639 12.4427C154.907 11.9923 154.074 11.7783 153.139 11.7783C152.205 11.7783 151.371 12.0035 150.628 12.4427C149.885 12.8931 149.311 13.5012 148.894 14.2894C148.477 15.0776 148.275 15.9672 148.275 16.9581C148.275 17.949 148.477 18.8836 148.894 19.6605C149.311 20.4487 149.885 21.0568 150.628 21.5072C151.371 21.9576 152.205 22.1715 153.139 22.1715Z" fill="#FAF7F8"/>
<path d="M13.4447 0L0 25.9886C6.22692 23.5226 11.249 23.1623 15.7643 23.3763L13.7037 18.8159C12.8029 18.7258 10.1905 18.7258 8.9519 19.0523L13.4447 9.06449C13.4447 9.06449 20.2009 23.7366 20.2121 23.7366C21.5183 23.9393 24.9977 25.1103 26.8895 25.9886L13.4447 0Z" fill="#FAF7F8"/>
</svg>

After

Width:  |  Height:  |  Size: 8.1 KiB

View File

@@ -0,0 +1,12 @@
<svg width="163" height="26" viewBox="0 0 163 26" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M32.9475 25.7973C30.9995 25.7973 29.4793 25.2568 28.3984 24.1871C27.3174 23.1174 26.7769 21.6085 26.7769 19.6492V12.0148H23.8154V8.28766H24.1307C24.9752 8.28766 25.6283 8.06245 26.09 7.6233C26.5404 7.17289 26.7769 6.53106 26.7769 5.68654V4.34657H30.9769V8.28766H34.9518V12.0148H30.9769V19.4353C30.9769 20.0096 31.0783 20.4938 31.281 20.8991C31.4837 21.3045 31.7989 21.6085 32.2381 21.8225C32.6772 22.0364 33.229 22.1378 33.9046 22.1378C34.051 22.1378 34.2312 22.1378 34.4338 22.104C34.6365 22.0814 34.828 22.0589 35.0194 22.0364V25.6059C34.7266 25.6509 34.3775 25.696 34.006 25.7298C33.6231 25.7748 33.274 25.7973 32.9588 25.7973H32.9475Z" fill="#0F1111"/>
<path d="M36.5732 25.6059V1.52028H40.7733V25.6059H36.5732Z" fill="#0F1111"/>
<path d="M48.2839 25.9888C47.079 25.9888 46.0206 25.7861 45.1197 25.3807C44.2189 24.9753 43.5208 24.4011 43.0366 23.6466C42.5524 22.8922 42.3047 22.0139 42.3047 21.023C42.3047 20.0321 42.5186 19.2101 42.9578 18.4557C43.3969 17.7012 44.05 17.0706 44.9508 16.5639C45.8404 16.0572 46.9664 15.6969 48.3289 15.483L53.959 14.5596V17.7463L49.1171 18.602C48.2951 18.7484 47.6758 19.0074 47.2705 19.379C46.8651 19.7506 46.6624 20.246 46.6624 20.8541C46.6624 21.4621 46.8876 21.9238 47.3493 22.2729C47.7997 22.6219 48.374 22.8021 49.0496 22.8021C49.9166 22.8021 50.6936 22.6219 51.3579 22.2504C52.0223 21.8788 52.5403 21.3608 52.9006 20.7077C53.2609 20.0546 53.4411 19.334 53.4411 18.5795V14.0867C53.4411 13.3435 53.1596 12.7242 52.5853 12.2287C52.011 11.7333 51.2453 11.4856 50.2995 11.4856C49.4099 11.4856 48.6217 11.7333 47.9236 12.2175C47.2367 12.7017 46.73 13.3322 46.4147 14.0979L43.0141 12.4427C43.3519 11.5306 43.8924 10.7424 44.6243 10.0668C45.3562 9.39116 46.2233 8.87319 47.2142 8.49034C48.2163 8.10749 49.2973 7.91607 50.4571 7.91607C51.8759 7.91607 53.1258 8.17505 54.2068 8.69303C55.2878 9.211 56.1323 9.94291 56.7403 10.8775C57.3484 11.8121 57.6524 12.8818 57.6524 14.0867V25.6059H53.7113V22.6445L54.6009 22.6107C54.1505 23.3313 53.6212 23.9507 52.9907 24.4574C52.3601 24.9641 51.662 25.3469 50.885 25.6059C50.108 25.8649 49.241 25.9888 48.2951 25.9888H48.2839Z" fill="#0F1111"/>
<path d="M66.4802 25.9888C64.6336 25.9888 63.0233 25.5496 61.6609 24.6713C60.2871 23.793 59.3412 22.5994 58.812 21.0906L61.9649 19.5929C62.4153 20.5726 63.0346 21.3383 63.8228 21.89C64.6223 22.4418 65.5006 22.712 66.4802 22.712C67.2234 22.712 67.8202 22.5431 68.2594 22.2053C68.7098 21.8675 68.9237 21.4171 68.9237 20.8653C68.9237 20.5275 68.8336 20.246 68.6535 20.0208C68.4733 19.7956 68.2368 19.6042 67.9328 19.4466C67.64 19.2889 67.291 19.1538 66.9194 19.0524L64.0818 18.253C62.6405 17.8476 61.537 17.2058 60.7826 16.3275C60.0281 15.4492 59.6565 14.4132 59.6565 13.2196C59.6565 12.1612 59.9268 11.2266 60.4673 10.4384C61.0078 9.65015 61.7622 9.01957 62.7306 8.58042C63.699 8.13001 64.8025 7.91607 66.0524 7.91607C67.6851 7.91607 69.1264 8.31018 70.3763 9.09839C71.6262 9.88661 72.5157 10.9901 73.045 12.4089L69.8583 13.9065C69.5656 13.1183 69.0588 12.499 68.3607 12.0486C67.6626 11.5982 66.8743 11.3617 66.0073 11.3617C65.3092 11.3617 64.7574 11.5193 64.3521 11.8234C63.9467 12.1274 63.744 12.5553 63.744 13.0845C63.744 13.3773 63.8341 13.6475 64.003 13.884C64.1719 14.1205 64.4084 14.3119 64.7236 14.4583C65.0277 14.6047 65.388 14.7398 65.7934 14.8749L68.5634 15.6969C69.9822 16.1248 71.0857 16.7554 71.8626 17.6111C72.6396 18.4557 73.0224 19.5029 73.0224 20.7302C73.0224 21.7662 72.7409 22.6895 72.2005 23.4777C71.6487 24.2772 70.883 24.8965 69.9146 25.3357C68.935 25.7861 67.7977 26 66.4802 26V25.9888Z" fill="#0F1111"/>
<path d="M90.2282 25.9889C88.5279 25.9889 86.9627 25.6849 85.5214 25.0656C84.0801 24.4463 82.8302 23.5905 81.7717 22.487C80.7133 21.3835 79.88 20.0886 79.2719 18.6022C78.6639 17.1159 78.3599 15.4944 78.3599 13.7378C78.3599 11.9812 78.6526 10.3485 79.2494 8.85085C79.8462 7.35324 80.6795 6.05831 81.7492 4.96606C82.8189 3.87382 84.0688 3.0293 85.4989 2.42125C86.9289 1.8132 88.5053 1.50917 90.2282 1.50917C91.951 1.50917 93.4486 1.79068 94.7998 2.36495C96.1511 2.93922 97.2884 3.69366 98.223 4.63952C99.1576 5.58538 99.8219 6.62132 100.227 7.74735L96.3425 9.59403C95.8921 8.38918 95.1489 7.39828 94.0792 6.62132C93.0207 5.84436 91.737 5.46152 90.2282 5.46152C88.7193 5.46152 87.4356 5.81058 86.2983 6.50872C85.1611 7.20685 84.2828 8.17523 83.6522 9.4026C83.0216 10.63 82.7176 12.0713 82.7176 13.7265C82.7176 15.3818 83.0329 16.8344 83.6522 18.073C84.2828 19.3116 85.1611 20.28 86.2983 20.9894C87.4356 21.6875 88.7418 22.0366 90.2282 22.0366C91.7145 22.0366 93.0207 21.6537 94.0792 20.8768C95.1376 20.0998 95.8921 19.1202 96.3425 17.9379L100.227 19.7508C99.8219 20.8768 99.1576 21.9127 98.223 22.8586C97.2884 23.8045 96.1511 24.5589 94.7998 25.1332C93.4486 25.7074 91.9285 25.9889 90.2282 25.9889Z" fill="#0F1111"/>
<path d="M101.748 25.6059V1.52028H105.948V25.6059H101.748Z" fill="#0F1111"/>
<path d="M116.645 25.9884C114.968 25.9884 113.436 25.5943 112.051 24.8061C110.666 24.0178 109.551 22.9481 108.729 21.5969C107.907 20.2344 107.491 18.6917 107.491 16.9464C107.491 15.2011 107.907 13.6584 108.729 12.2959C109.551 10.9334 110.655 9.86371 112.04 9.08676C113.414 8.29854 114.956 7.90443 116.657 7.90443C118.357 7.90443 119.922 8.29854 121.307 9.08676C122.681 9.87497 123.784 10.9334 124.606 12.2847C125.417 13.6359 125.834 15.1898 125.834 16.9464C125.834 18.703 125.417 20.2344 124.595 21.5969C123.773 22.9594 122.67 24.0291 121.285 24.8061C119.911 25.5943 118.368 25.9884 116.668 25.9884H116.645ZM116.645 22.1712C117.602 22.1712 118.436 21.946 119.145 21.5068C119.854 21.0564 120.417 20.4371 120.834 19.6489C121.251 18.8494 121.453 17.9598 121.453 16.9577C121.453 15.9555 121.251 15.0659 120.834 14.289C120.417 13.5008 119.854 12.8927 119.145 12.4423C118.436 11.9919 117.602 11.778 116.645 11.778C115.688 11.778 114.889 12.0032 114.168 12.4423C113.447 12.8927 112.884 13.5008 112.468 14.289C112.051 15.0772 111.848 15.9668 111.848 16.9577C111.848 17.9486 112.051 18.8494 112.468 19.6489C112.884 20.4483 113.447 21.0677 114.168 21.5068C114.889 21.9572 115.722 22.1712 116.645 22.1712Z" fill="#0F1111"/>
<path d="M133.535 25.9884C132.172 25.9884 131.013 25.6956 130.033 25.0988C129.053 24.502 128.299 23.68 127.77 22.6215C127.24 21.5631 126.97 20.3245 126.97 18.8944V8.29852H131.17V18.5453C131.17 19.266 131.317 19.8966 131.598 20.4371C131.88 20.9775 132.296 21.4054 132.837 21.7095C133.377 22.0135 133.985 22.1711 134.672 22.1711C135.359 22.1711 135.956 22.0135 136.485 21.7095C137.014 21.4054 137.431 20.9775 137.724 20.4258C138.017 19.874 138.174 19.221 138.174 18.4553V8.29852H142.34V25.6055H138.399V22.2049L138.715 22.813C138.309 23.8714 137.656 24.6709 136.744 25.2001C135.832 25.7294 134.762 25.9996 133.535 25.9996V25.9884Z" fill="#0F1111"/>
<path d="M152.7 25.9888C151.022 25.9888 149.525 25.5947 148.196 24.7952C146.867 23.9957 145.82 22.9147 145.066 21.5297C144.3 20.156 143.917 18.6246 143.917 16.9468C143.917 15.269 144.3 13.7264 145.077 12.3639C145.854 11.0014 146.901 9.92042 148.207 9.12094C149.525 8.31021 151.011 7.9161 152.666 7.9161C153.984 7.9161 155.155 8.17508 156.179 8.69305C157.204 9.21103 158.015 9.94294 158.612 10.8775L157.97 11.7333V1.52028H162.136V25.6059H158.195V22.2616L158.645 23.0836C158.049 24.0408 157.227 24.7614 156.168 25.2456C155.11 25.7298 153.95 25.9775 152.7 25.9775V25.9888ZM153.139 22.1716C154.074 22.1716 154.907 21.9464 155.639 21.5072C156.371 21.0568 156.945 20.4487 157.362 19.6605C157.778 18.8723 157.981 17.9715 157.981 16.9581C157.981 15.9447 157.778 15.0664 157.362 14.2894C156.945 13.5012 156.371 12.8931 155.639 12.4427C154.907 11.9923 154.074 11.7784 153.139 11.7784C152.205 11.7784 151.371 12.0036 150.628 12.4427C149.885 12.8931 149.311 13.5012 148.894 14.2894C148.477 15.0776 148.275 15.9672 148.275 16.9581C148.275 17.949 148.477 18.8836 148.894 19.6605C149.311 20.4487 149.885 21.0568 150.628 21.5072C151.371 21.9576 152.205 22.1716 153.139 22.1716Z" fill="#0F1111"/>
<path d="M13.4447 1.71661e-05L0 25.9887C6.22692 23.5227 11.249 23.1623 15.7643 23.3763L13.7037 18.8159C12.8029 18.7258 10.1905 18.7258 8.9519 19.0523L13.4447 9.06451C13.4447 9.06451 20.2009 23.7366 20.2121 23.7366C21.5183 23.9393 24.9977 25.1104 26.8895 25.9887L13.4447 1.71661e-05Z" fill="#0F1111"/>
</svg>

After

Width:  |  Height:  |  Size: 8.1 KiB

View File

@@ -18,6 +18,26 @@ test.describe("Settings", () => {
await expect(dialog.locator('text="English"')).toBeVisible()
})
test("max output tokens is editable and persists", async ({ page }) => {
await openSettings(page)
const input = page.locator("#max-output-tokens")
await expect(input).toBeVisible()
await input.fill("48000")
await expect
.poll(() =>
page.evaluate(() =>
localStorage.getItem("next-ai-draw-io-max-output-tokens"),
),
)
.toBe("48000")
// Non-digits are dropped so the header always carries a plain number
await input.fill("12k000")
await expect(input).toHaveValue("12000")
})
test("draw.io theme toggle exists", async ({ page }) => {
await openSettings(page)

View File

@@ -1,10 +1,35 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"
import {
getAIModel,
isAihubmixStandardBaseURL,
resolveBaseURL,
supportsImageInput,
supportsPromptCaching,
} from "@/lib/ai-providers"
import { extractAihubmixModelIds } from "@/lib/aihubmix-models"
describe("extractAihubmixModelIds", () => {
it("extracts unique chat model IDs from the AIHubMix model list payload", () => {
const models = extractAihubmixModelIds({
data: [
{ model_id: "claude-sonnet-4-5-20250929", types: "llm" },
{ model_id: "gpt-5.1", types: "llm" },
{ model_id: "gpt-5.1", types: "llm" },
{ model_id: "gpt-image-2", types: "image_generation,llm" },
{ model_id: "cohere-rerank-v4.0", types: "rerank" },
{ model_id: "", types: "llm" },
{ types: "llm" },
],
})
expect(models).toEqual(["claude-sonnet-4-5-20250929", "gpt-5.1"])
})
it("returns an empty list for malformed payloads", () => {
expect(extractAihubmixModelIds({ data: null })).toEqual([])
expect(extractAihubmixModelIds({})).toEqual([])
expect(extractAihubmixModelIds(null)).toEqual([])
})
})
describe("resolveBaseURL", () => {
const SERVER_BASE_URL = "https://server-proxy.example.com"
@@ -157,89 +182,6 @@ describe("supportsPromptCaching", () => {
})
})
describe("supportsImageInput", () => {
it("returns true for models with vision capability", () => {
expect(supportsImageInput("gpt-4-vision")).toBe(true)
expect(supportsImageInput("qwen-vl")).toBe(true)
expect(supportsImageInput("deepseek-vl")).toBe(true)
})
it("returns false for Kimi K2 models without vision", () => {
expect(supportsImageInput("kimi-k2")).toBe(false)
expect(supportsImageInput("moonshot/kimi-k2")).toBe(false)
})
it("returns true for Kimi K2.5 models (supports vision)", () => {
expect(supportsImageInput("kimi-k2.5")).toBe(true)
expect(supportsImageInput("moonshotai/kimi-k2.5")).toBe(true)
})
it("returns false for Moonshot v1 text models", () => {
expect(supportsImageInput("moonshot-v1-8k")).toBe(false)
expect(supportsImageInput("moonshot-v1-32k")).toBe(false)
expect(supportsImageInput("moonshot-v1-128k")).toBe(false)
})
it("returns false for MiniMax M2 text models", () => {
expect(supportsImageInput("MiniMax-M2.7")).toBe(false)
expect(supportsImageInput("MiniMax-M2.7-highspeed")).toBe(false)
expect(supportsImageInput("MiniMax-M2")).toBe(false)
})
it("returns true for MiniMax M3 (supports image input)", () => {
expect(supportsImageInput("MiniMax-M3")).toBe(true)
})
it("returns false for DeepSeek text models", () => {
expect(supportsImageInput("deepseek-chat")).toBe(false)
expect(supportsImageInput("deepseek-coder")).toBe(false)
})
it("returns false for Qwen text models", () => {
expect(supportsImageInput("qwen-turbo")).toBe(false)
expect(supportsImageInput("qwen-plus")).toBe(false)
expect(supportsImageInput("qwen3-max")).toBe(false)
})
it("returns true for Qwen vision models", () => {
expect(supportsImageInput("qwen-vl")).toBe(true)
expect(supportsImageInput("Qwen3.5")).toBe(true)
expect(supportsImageInput("qwen3.5")).toBe(true)
expect(supportsImageInput("qwen3.5-plus")).toBe(true)
expect(supportsImageInput("qwen3.5-flash")).toBe(true)
expect(supportsImageInput("qwen3-vl-plus")).toBe(true)
expect(supportsImageInput("qwen3-vl-flash")).toBe(true)
})
it("returns true for QvQ (Qwen Visual QA) models including OpenRouter-prefixed names", () => {
expect(supportsImageInput("qvq-72b-preview")).toBe(true)
expect(supportsImageInput("qvq-max")).toBe(true)
expect(supportsImageInput("qwen/qvq-72b-preview")).toBe(true)
expect(supportsImageInput("qwen/qvq-max")).toBe(true)
})
it("returns false for GLM text models", () => {
expect(supportsImageInput("glm-4")).toBe(false)
expect(supportsImageInput("glm-4-plus")).toBe(false)
expect(supportsImageInput("glm-4-flash")).toBe(false)
expect(supportsImageInput("glm-4-long")).toBe(false)
expect(supportsImageInput("glm-4.7")).toBe(false)
expect(supportsImageInput("glm-5")).toBe(false)
})
it("returns true for GLM vision models", () => {
expect(supportsImageInput("glm-4v")).toBe(true)
expect(supportsImageInput("glm-4v-9b")).toBe(true)
expect(supportsImageInput("glm-4.1v-9b-thinking")).toBe(true)
})
it("returns true for Claude and GPT models by default", () => {
expect(supportsImageInput("claude-sonnet-4-5")).toBe(true)
expect(supportsImageInput("gpt-4o")).toBe(true)
expect(supportsImageInput("gemini-pro")).toBe(true)
})
})
vi.mock("ollama-ai-provider-v2", () => {
const mockModel = { modelId: "test-model" }
const mockProviderFn = vi.fn(() => mockModel)
@@ -256,6 +198,128 @@ vi.mock("@ai-sdk/deepseek", () => {
return { createDeepSeek: mockCreateDeepSeek, deepseek: mockDeepseek }
})
vi.mock("@aihubmix/ai-sdk-provider", () => {
const mockModel = { modelId: "test-model" }
const mockProviderFn = vi.fn(() => mockModel)
const mockCreateAihubmix = vi.fn(() => mockProviderFn)
const mockAihubmix = vi.fn(() => mockModel)
return { aihubmix: mockAihubmix, createAihubmix: mockCreateAihubmix }
})
vi.mock("@ai-sdk/openai", () => {
const mockModel = { modelId: "test-model" }
const mockChat = vi.fn(() => mockModel)
const mockProviderFn = vi.fn(() => mockModel) as any
mockProviderFn.chat = mockChat
const mockCreateOpenAI = vi.fn(() => mockProviderFn)
const mockOpenai = vi.fn(() => mockModel)
return { createOpenAI: mockCreateOpenAI, openai: mockOpenai }
})
describe("AIHubMix provider", () => {
let createAihubmixMock: ReturnType<typeof vi.fn>
const savedEnv: Record<string, string | undefined> = {}
beforeEach(async () => {
savedEnv.AIHUBMIX_API_KEY = process.env.AIHUBMIX_API_KEY
savedEnv.AIHUBMIX_BASE_URL = process.env.AIHUBMIX_BASE_URL
delete process.env.AIHUBMIX_BASE_URL
const mod = await import("@aihubmix/ai-sdk-provider")
createAihubmixMock = mod.createAihubmix as ReturnType<typeof vi.fn>
createAihubmixMock.mockClear()
})
afterEach(() => {
process.env.AIHUBMIX_API_KEY = savedEnv.AIHUBMIX_API_KEY
process.env.AIHUBMIX_BASE_URL = savedEnv.AIHUBMIX_BASE_URL
})
it("uses AIHUBMIX_API_KEY for server configured AIHubMix", () => {
process.env.AIHUBMIX_API_KEY = "server-aihubmix-key"
getAIModel({
provider: "aihubmix",
modelId: "claude-sonnet-4-5-20250929",
})
expect(createAihubmixMock).toHaveBeenCalledWith({
apiKey: "server-aihubmix-key",
appCode: "MSBS9675",
})
})
it("uses client BYOK API key for AIHubMix", () => {
getAIModel({
provider: "aihubmix",
apiKey: "client-aihubmix-key",
modelId: "gpt-5.1",
})
expect(createAihubmixMock).toHaveBeenCalledWith({
apiKey: "client-aihubmix-key",
appCode: "MSBS9675",
})
})
it("recognizes AIHubMix standard endpoints", () => {
expect(isAihubmixStandardBaseURL(undefined)).toBe(true)
expect(isAihubmixStandardBaseURL("https://aihubmix.com")).toBe(true)
expect(isAihubmixStandardBaseURL("https://aihubmix.com/v1/")).toBe(true)
expect(isAihubmixStandardBaseURL("https://proxy.example.com/v1")).toBe(
false,
)
})
})
describe("Atlas Cloud provider", () => {
let createOpenAIMock: ReturnType<typeof vi.fn>
const savedEnv: Record<string, string | undefined> = {}
beforeEach(async () => {
savedEnv.ATLASCLOUD_API_KEY = process.env.ATLASCLOUD_API_KEY
savedEnv.ATLASCLOUD_BASE_URL = process.env.ATLASCLOUD_BASE_URL
delete process.env.ATLASCLOUD_BASE_URL
const mod = await import("@ai-sdk/openai")
createOpenAIMock = mod.createOpenAI as ReturnType<typeof vi.fn>
createOpenAIMock.mockClear()
})
afterEach(() => {
process.env.ATLASCLOUD_API_KEY = savedEnv.ATLASCLOUD_API_KEY
process.env.ATLASCLOUD_BASE_URL = savedEnv.ATLASCLOUD_BASE_URL
})
it("uses Atlas Cloud default endpoint with ATLASCLOUD_API_KEY", () => {
process.env.ATLASCLOUD_API_KEY = "server-atlas-key"
getAIModel({
provider: "atlascloud",
modelId: "qwen/qwen3.5-flash",
})
expect(createOpenAIMock).toHaveBeenCalledWith({
apiKey: "server-atlas-key",
baseURL: "https://api.atlascloud.ai/v1",
})
})
it("uses custom Atlas Cloud base URL when provided", () => {
getAIModel({
provider: "atlascloud",
apiKey: "client-atlas-key",
baseUrl: "https://proxy.example.com/v1",
modelId: "deepseek-ai/deepseek-v4-pro",
})
expect(createOpenAIMock).toHaveBeenCalledWith({
apiKey: "client-atlas-key",
baseURL: "https://proxy.example.com/v1",
})
})
})
describe("Kimi provider uses createDeepSeek for reasoning_content support", () => {
let createDeepSeekMock: ReturnType<typeof vi.fn>
const savedEnv: Record<string, string | undefined> = {}

View File

@@ -0,0 +1,271 @@
import { describe, expect, it } from "vitest"
import {
DEFAULT_MAX_OUTPUT_TOKENS,
parseOutputTokenLimit,
resolveMaxOutputTokens,
withOutputTokenLimitFallback,
} from "@/lib/output-token-limit"
describe("parseOutputTokenLimit", () => {
it("reads the ceiling from a Bedrock rejection", () => {
const error = {
message:
"The maximum tokens you requested exceeds the model limit of 4096. Try again with a maximum tokens value that is lower than 4096.",
}
expect(parseOutputTokenLimit(error)).toBe(4096)
})
it("subtracts the input when the ceiling covers input plus output", () => {
const error = {
message:
"This endpoint's maximum context length is 64000 tokens. However, you requested about 64025 tokens (25 of text input, 64000 in the output).",
}
// 64000 - 25 - 1024 margin
expect(parseOutputTokenLimit(error)).toBe(62951)
})
it("reads the ceiling from an Anthropic rejection", () => {
const error = {
message:
"max_tokens: 200000 > 64000, which is the maximum allowed number of output tokens for claude-sonnet-4-5",
}
expect(parseOutputTokenLimit(error)).toBe(64000)
})
it("reads the ceiling from an OpenAI rejection", () => {
const error = {
message:
"max_tokens is too large: 64000. This model supports at most 16384 completion tokens",
}
expect(parseOutputTokenLimit(error)).toBe(16384)
})
it("looks in the response body too", () => {
const error = {
message: "Bad request",
responseBody: '{"message":"exceeds the model limit of 10000."}',
}
expect(parseOutputTokenLimit(error)).toBe(10000)
})
it("returns null for unrelated errors", () => {
expect(parseOutputTokenLimit({ message: "Invalid API key" })).toBeNull()
expect(parseOutputTokenLimit(undefined)).toBeNull()
})
it("ignores a number that is not about tokens", () => {
// An earlier draft matched "lower than N" generically, which turned any
// message shaped like this into a bogus budget
expect(
parseOutputTokenLimit({
message: "temperature must be lower than 2",
statusCode: 400,
}),
).toBeNull()
expect(
parseOutputTokenLimit({
message: "reduce requests to lower than 60 per minute",
statusCode: 429,
}),
).toBeNull()
})
it("skips errors whose status is not a bad request", () => {
const error = {
message: "exceeds the model limit of 4096",
statusCode: 429,
}
expect(parseOutputTokenLimit(error)).toBeNull()
})
it("rejects a ceiling too small to hold a diagram", () => {
expect(
parseOutputTokenLimit({ message: "model limit of 200" }),
).toBeNull()
// Context ceiling that leaves almost nothing after the input
expect(
parseOutputTokenLimit({
message:
"This endpoint's maximum context length is 64000 tokens. However, you requested about 128000 tokens (63500 of text input, 64000 in the output).",
}),
).toBeNull()
})
it("returns null when the input alone fills the context", () => {
const error = {
message:
"This endpoint's maximum context length is 1000 tokens. However, you requested about 65000 tokens (64000 of text input, 1000 in the output).",
}
expect(parseOutputTokenLimit(error)).toBeNull()
})
})
describe("resolveMaxOutputTokens", () => {
it("uses a valid header value", () => {
expect(resolveMaxOutputTokens("32000")).toBe(32000)
})
it("falls back to the default for missing or bogus values", () => {
expect(resolveMaxOutputTokens(null)).toBe(DEFAULT_MAX_OUTPUT_TOKENS)
expect(resolveMaxOutputTokens("")).toBe(DEFAULT_MAX_OUTPUT_TOKENS)
expect(resolveMaxOutputTokens("abc")).toBe(DEFAULT_MAX_OUTPUT_TOKENS)
expect(resolveMaxOutputTokens("0")).toBe(DEFAULT_MAX_OUTPUT_TOKENS)
expect(resolveMaxOutputTokens("-5")).toBe(DEFAULT_MAX_OUTPUT_TOKENS)
expect(resolveMaxOutputTokens("1.5")).toBe(DEFAULT_MAX_OUTPUT_TOKENS)
// Above the sanity ceiling, e.g. an extra zero
expect(resolveMaxOutputTokens("640000")).toBe(DEFAULT_MAX_OUTPUT_TOKENS)
})
it("uses the env value when no header is sent, and validates it too", () => {
const original = process.env.MAX_OUTPUT_TOKENS
try {
process.env.MAX_OUTPUT_TOKENS = "24000"
expect(resolveMaxOutputTokens(null)).toBe(24000)
// Header still wins
expect(resolveMaxOutputTokens("8000")).toBe(8000)
process.env.MAX_OUTPUT_TOKENS = "-1"
expect(resolveMaxOutputTokens(null)).toBe(DEFAULT_MAX_OUTPUT_TOKENS)
} finally {
if (original === undefined) delete process.env.MAX_OUTPUT_TOKENS
else process.env.MAX_OUTPUT_TOKENS = original
}
})
})
/** Minimal stand-in for a v3 language model that records what it was asked for. */
function fakeModel(
behaviors: Array<() => Promise<unknown>>,
): [any, Array<Record<string, unknown>>] {
const calls: Array<Record<string, unknown>> = []
let index = 0
const model = {
specificationVersion: "v3" as const,
provider: "test",
modelId: "test-model",
supportedUrls: {},
doGenerate: async () => {
throw new Error("not used")
},
doStream: async (options: Record<string, unknown>) => {
calls.push(options)
const behavior = behaviors[index] ?? behaviors[behaviors.length - 1]
index++
return behavior()
},
}
return [model, calls]
}
const STREAM_OK = { stream: new ReadableStream() }
describe("withOutputTokenLimitFallback", () => {
it("retries once with the ceiling named in the rejection", async () => {
const [model, calls] = fakeModel([
() =>
Promise.reject(
Object.assign(
new Error("exceeds the model limit of 4096"),
{ statusCode: 400 },
),
),
() => Promise.resolve(STREAM_OK),
])
const wrapped = withOutputTokenLimitFallback(model)
await wrapped.doStream({ prompt: [], maxOutputTokens: 64000 } as any)
expect(calls.map((c) => c.maxOutputTokens)).toEqual([64000, 4096])
})
it("does not retry an error it cannot attribute to the budget", async () => {
const [model, calls] = fakeModel([
() =>
Promise.reject(
Object.assign(new Error("Invalid API key"), {
statusCode: 401,
}),
),
])
const wrapped = withOutputTokenLimitFallback(model)
await expect(
wrapped.doStream({ prompt: [], maxOutputTokens: 64000 } as any),
).rejects.toThrow("Invalid API key")
expect(calls).toHaveLength(1)
})
it("does not retry when the ceiling is not actually smaller", async () => {
const [model, calls] = fakeModel([
() =>
Promise.reject(
Object.assign(
new Error("exceeds the model limit of 64000"),
{ statusCode: 400 },
),
),
])
const wrapped = withOutputTokenLimitFallback(model)
await expect(
wrapped.doStream({ prompt: [], maxOutputTokens: 64000 } as any),
).rejects.toThrow()
expect(calls).toHaveLength(1)
})
it("retries at most once, so a second rejection propagates", async () => {
const [model, calls] = fakeModel([
() =>
Promise.reject(
Object.assign(
new Error("exceeds the model limit of 4096"),
{ statusCode: 400 },
),
),
() =>
Promise.reject(
Object.assign(
new Error("exceeds the model limit of 2048"),
{ statusCode: 400 },
),
),
])
const wrapped = withOutputTokenLimitFallback(model)
await expect(
wrapped.doStream({ prompt: [], maxOutputTokens: 64000 } as any),
).rejects.toThrow("model limit of 2048")
expect(calls).toHaveLength(2)
})
it("keeps the other call options when retrying", async () => {
const [model, calls] = fakeModel([
() =>
Promise.reject(
Object.assign(
new Error("exceeds the model limit of 4096"),
{ statusCode: 400 },
),
),
() => Promise.resolve(STREAM_OK),
])
const wrapped = withOutputTokenLimitFallback(model)
await wrapped.doStream({
prompt: [],
maxOutputTokens: 64000,
temperature: 0.4,
providerOptions: {
bedrock: { reasoningConfig: { type: "enabled" } },
},
} as any)
expect(calls[1].temperature).toBe(0.4)
expect(calls[1].providerOptions).toEqual({
bedrock: { reasoningConfig: { type: "enabled" } },
})
})
})

View File

@@ -39,6 +39,22 @@ describe("ServerModelsConfigSchema", () => {
expect(() => ServerModelsConfigSchema.parse(config)).not.toThrow()
})
it("accepts Atlas Cloud provider names", () => {
const config: ServerModelsConfig = {
providers: [
{
name: "Atlas Cloud Server",
provider: "atlascloud",
models: ["qwen/qwen3.5-flash"],
apiKeyEnv: "ATLASCLOUD_API_KEY",
baseUrlEnv: "ATLASCLOUD_BASE_URL",
},
],
}
expect(() => ServerModelsConfigSchema.parse(config)).not.toThrow()
})
it("rejects invalid provider names", () => {
const invalidConfig = {
providers: [
@@ -159,6 +175,44 @@ describe("loadFlattenedServerModels", () => {
expect(defaultModel.modelId).toBe("gpt-4o") // First model of default provider
})
it("falls back to comma-separated AI_MODEL when no other config is set", async () => {
process.env.AI_MODELS_CONFIG = ""
process.env.AI_MODELS_CONFIG_PATH = `non-existent-config-${Date.now()}.json`
process.env.AI_PROVIDER = "openai"
process.env.AI_MODEL = "gpt-4o, gpt-4o-mini, gpt-4o"
const models = await loadFlattenedServerModels()
// Trims, deduplicates, and preserves order
expect(models.map((m) => m.modelId)).toEqual(["gpt-4o", "gpt-4o-mini"])
expect(models.every((m) => m.provider === "openai")).toBe(true)
// First model is marked default (provider has default: true)
const defaults = models.filter((m) => m.isDefault)
expect(defaults.length).toBe(1)
expect(defaults[0].modelId).toBe("gpt-4o")
})
it("does not synthesize when AI_MODEL has no comma", async () => {
process.env.AI_MODELS_CONFIG = ""
process.env.AI_MODELS_CONFIG_PATH = `non-existent-config-${Date.now()}.json`
process.env.AI_PROVIDER = "openai"
process.env.AI_MODEL = "gpt-4o"
const models = await loadFlattenedServerModels()
expect(models).toEqual([])
})
it("does not synthesize when AI_PROVIDER is unset", async () => {
process.env.AI_MODELS_CONFIG = ""
process.env.AI_MODELS_CONFIG_PATH = `non-existent-config-${Date.now()}.json`
delete process.env.AI_PROVIDER
process.env.AI_MODEL = "gpt-4o, gpt-4o-mini"
const models = await loadFlattenedServerModels()
expect(models).toEqual([])
})
it("preserves apiKeyEnv array in flattened models for load balancing", async () => {
const config: ServerModelsConfig = {
providers: [

View File

@@ -1,21 +1,79 @@
import { describe, expect, it } from "vitest"
import { beforeEach, describe, expect, it, vi } from "vitest"
import { isPrivateUrl } from "@/lib/ssrf-protection"
// Mock DNS so tests are deterministic and never hit the network.
const lookupMock = vi.hoisted(() => vi.fn())
vi.mock("node:dns/promises", () => ({
default: { lookup: lookupMock },
lookup: lookupMock,
}))
describe("isPrivateUrl", () => {
it("blocks private IPv6 URLs", () => {
expect(isPrivateUrl("http://[::1]/")).toBe(true)
expect(isPrivateUrl("http://[0:0:0:0:0:0:0:1]/")).toBe(true)
expect(isPrivateUrl("http://[::]/")).toBe(true)
expect(isPrivateUrl("http://[::ffff:127.0.0.1]/")).toBe(true)
expect(isPrivateUrl("http://[fc00::1]/")).toBe(true)
expect(isPrivateUrl("http://[fd12:3456:789a::1]/")).toBe(true)
expect(isPrivateUrl("http://[fe80::1]/")).toBe(true)
expect(isPrivateUrl("http://[fe9f::1]/")).toBe(true)
expect(isPrivateUrl("http://[febf::1]/")).toBe(true)
beforeEach(() => {
lookupMock.mockReset()
})
it("allows public URLs", () => {
expect(isPrivateUrl("https://example.com/article")).toBe(false)
expect(isPrivateUrl("https://fc00.example.com/article")).toBe(false)
it("blocks private IPv6 URLs (string-only fast path, no DNS)", async () => {
expect(await isPrivateUrl("http://[::1]/")).toBe(true)
expect(await isPrivateUrl("http://[0:0:0:0:0:0:0:1]/")).toBe(true)
expect(await isPrivateUrl("http://[::]/")).toBe(true)
expect(await isPrivateUrl("http://[::ffff:127.0.0.1]/")).toBe(true)
expect(await isPrivateUrl("http://[fc00::1]/")).toBe(true)
expect(await isPrivateUrl("http://[fd12:3456:789a::1]/")).toBe(true)
expect(await isPrivateUrl("http://[fe80::1]/")).toBe(true)
expect(await isPrivateUrl("http://[fe9f::1]/")).toBe(true)
expect(await isPrivateUrl("http://[febf::1]/")).toBe(true)
expect(lookupMock).not.toHaveBeenCalled()
})
it("blocks literal private IPv4 without DNS", async () => {
expect(await isPrivateUrl("http://127.0.0.1/")).toBe(true)
expect(await isPrivateUrl("http://10.0.0.5/")).toBe(true)
expect(await isPrivateUrl("http://192.168.1.1/")).toBe(true)
expect(await isPrivateUrl("http://169.254.169.254/")).toBe(true)
expect(await isPrivateUrl("http://0.0.0.0/")).toBe(true)
// 100.64.0.0/10 CGNAT (RFC 6598), routable in some cloud internal nets
expect(await isPrivateUrl("http://100.64.0.1/")).toBe(true)
expect(await isPrivateUrl("http://100.127.255.255/")).toBe(true)
expect(lookupMock).not.toHaveBeenCalled()
})
it("treats CGNAT boundaries correctly", async () => {
// 100.63.x and 100.128.x are outside 100.64.0.0/10 → public
lookupMock.mockResolvedValue([{ address: "100.63.255.255", family: 4 }])
expect(await isPrivateUrl("http://just-below.example/")).toBe(false)
lookupMock.mockResolvedValue([{ address: "100.128.0.1", family: 4 }])
expect(await isPrivateUrl("http://just-above.example/")).toBe(false)
})
it("blocks a hostname that resolves to a private IPv6 address", async () => {
lookupMock.mockResolvedValue([{ address: "fd00::1", family: 6 }])
expect(await isPrivateUrl("http://v6.example.com/")).toBe(true)
})
it("allows public URLs that resolve to public IPs", async () => {
lookupMock.mockResolvedValue([{ address: "93.184.216.34", family: 4 }])
expect(await isPrivateUrl("https://example.com/article")).toBe(false)
})
it("blocks public-looking hostnames that resolve to a private IP (DNS-rebinding-style bypass)", async () => {
// e.g. 127-0-0-1.sslip.io resolves to 127.0.0.1
lookupMock.mockResolvedValue([{ address: "127.0.0.1", family: 4 }])
expect(await isPrivateUrl("http://127-0-0-1.sslip.io/")).toBe(true)
})
it("blocks when any resolved address is private", async () => {
lookupMock.mockResolvedValue([
{ address: "93.184.216.34", family: 4 },
{ address: "10.1.2.3", family: 4 },
])
expect(await isPrivateUrl("http://mixed.example.com/")).toBe(true)
})
it("blocks when DNS resolution fails", async () => {
lookupMock.mockRejectedValue(new Error("ENOTFOUND"))
expect(await isPrivateUrl("http://does-not-resolve.example/")).toBe(
true,
)
})
})

View File

@@ -2,7 +2,7 @@
"functions": {
"app/api/chat/route.ts": {
"memory": 512,
"maxDuration": 120
"maxDuration": 300
},
"app/api/**/route.ts": {
"memory": 256,